Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f7cd148aa
|
||
|
|
cb7800cc6e
|
||
|
|
a3e9a43dd6
|
||
|
|
a4027d5626
|
||
|
|
d111cad22e
|
||
|
|
788db0ff4d
|
||
|
|
e349d829a1
|
||
|
|
fe0fa62a51
|
||
|
|
f3db970c9d
|
||
|
|
ae1201540a
|
||
|
|
b70cfe483b
|
||
|
|
ecfd1b0b8a
|
||
|
|
8c5edc0f01
|
||
|
|
b66891e186
|
||
|
|
6199028358
|
||
|
|
4b8786b7f6
|
||
|
|
6a0e0ed145
|
||
|
|
3a206959b6
|
||
|
|
6993937522
|
||
|
|
f88921667e
|
||
|
|
0c3596d044
|
||
|
|
6e0582dc22
|
||
|
|
a26dd8a5e0
|
||
|
|
02c7282e74
|
||
|
|
9499e93bc4
|
||
|
|
6a82e6cb61
|
||
|
|
536fd2aa45
|
||
|
|
d3af8c2f02
|
||
|
|
6ec95fbd66
|
||
|
|
fbefe7f844
|
||
|
|
5c23087cef
|
||
|
|
880289970b
|
||
|
|
d09d51cfec
|
||
|
|
fdfcf3666a
|
No files matched your search
+170
@@ -4,6 +4,176 @@ All notable changes to LLeMbas CLI. The format follows
|
|||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
||||||
[Semantic Versioning](https://semver.org/).
|
[Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [2.0.0-beta.7] — 2026-11-10
|
||||||
|
|
||||||
|
Harness spec 3.5.0: scheduled work on a linked computer.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Accept scheduled work.** A new switch, off by default: `lembas service scheduled on` (or
|
||||||
|
`lembas remote scheduled on`; `remote.scheduled` in the global config). On, the schedules you
|
||||||
|
make on your LLeMbas server may run on this computer as agent turns, in the folders remote work
|
||||||
|
may use. `lembas remote list --json` says `scheduled`; the server is told at once (the link
|
||||||
|
dials again once nothing is working).
|
||||||
|
- **Scheduled turns.** A run is an agent session in the schedule's mode — Edit unless the schedule
|
||||||
|
says Auto, never Plan, and never beyond `remote.max_mode` — with the schedule's Subagents
|
||||||
|
Min/Max. Its answer reports the run: `ok`, `silent` (the reply said `[SILENT]` on its first or
|
||||||
|
last line), `failed` or `cancelled`, and the final reply. A switch turned off is a fixed refusal
|
||||||
|
the server shows as it is.
|
||||||
|
- **Approvals in a scheduled run** go to you through the server (a notification on your phone)
|
||||||
|
and wait the schedule's wait — 30 minutes unless it says otherwise — then count as denied, and
|
||||||
|
the model is told so. Questions and plans have nobody to answer in a scheduled run.
|
||||||
|
- **Watch commands** (monitor mode): the server asks this computer to run a schedule's watch
|
||||||
|
command in its folder, with a timeout, and gets back a hash of the output and a short excerpt;
|
||||||
|
the model runs only when the output changed. A command the schedule's mode would ask about is
|
||||||
|
refused rather than run.
|
||||||
|
|
||||||
|
- **`/schedule`**: your schedules on the LLeMbas server from the terminal — the list with each
|
||||||
|
one's next run and last result, `new <words>` (the server works out the rule; you see what it
|
||||||
|
understood and the next runs before anything is made, and may have it run on this computer in
|
||||||
|
the current folder), `pause`, `resume`, `run` (now, without using up the next scheduled run) and
|
||||||
|
`history`. It uses this computer's sign-in; a server too old to allow that says so.
|
||||||
|
|
||||||
|
- **A notice when a reply finishes** — or when the agent waits for your answer — while the
|
||||||
|
terminal is not focused: the bell, and a desktop notification (OSC 9, in terminals that show
|
||||||
|
one) naming the session, never what the reply says. `notify: false` (or `/settings`) turns it
|
||||||
|
off.
|
||||||
|
- **`/rename <title>`** names this session — in `/sessions`, and in the web UI when it is shared.
|
||||||
|
- **`/search <words>`** finds a past session in this project by what was said in it (add `all`
|
||||||
|
for every project) and resumes the one you pick.
|
||||||
|
- **`/memory` shows the server's memory** when the server keeps it (`library: lembas`): its
|
||||||
|
entries beside this machine's files, each one to change or forget, through the server.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `team_add` under a sub-manager says where the report goes: "Its report goes to <sub-manager>
|
||||||
|
when it finishes", not "comes to you".
|
||||||
|
|
||||||
|
## [2.0.0-beta.6] — 2026-11-10
|
||||||
|
|
||||||
|
Harness spec 3.4.0 (spec commits 4b8786b, b66891e): one progress vocabulary for every client, and
|
||||||
|
subagents organised as the main agent's tree.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Progress phases.** The line under the conversation says what the agent is doing in the words
|
||||||
|
every LLeMbas client uses — *Queued*, *Waiting for {model}*, *Reading the prompt: n / total
|
||||||
|
tokens (p%)*, *Thinking*, *Writing: n tok/s*, *Running {tool}: {what}*, *Waiting for you*,
|
||||||
|
*Waiting for subagents*, *Compacting the conversation*, *Retrying in n s*, *Stopped*, *Failed* —
|
||||||
|
with how long it has lasted, then the turn's time and tokens. The CLI keeps its own estimate of
|
||||||
|
how long the rest of a long prompt will take. Over the link each phase is a `phase` event and is
|
||||||
|
in the session's status (capability `progress`).
|
||||||
|
- **The main agent and its subagents.** Only the main agent adds, reassigns (`team_assign`),
|
||||||
|
replaces (`team_replace`), stops and decides; a subagent added with `lead: true` is a
|
||||||
|
sub-manager that directs the subagents placed under it (`parent`) and adds none. Each subagent
|
||||||
|
has an id (`s1`, `s2`, …), a name, a position, a parent, a task, a mode and a model, and can ask
|
||||||
|
`team_whoami`. With Min above 1 the main agent starts with Min − 1 open slots to fill.
|
||||||
|
- **Permissions only go down.** A subagent's mode is its parent's or stricter. A narrowed one may
|
||||||
|
be given request options (read, write, execute) and ask the main agent with `team_request`; the
|
||||||
|
main agent decides with `team_decide`, and never allows more than its own mode would. What
|
||||||
|
nobody in the tree could allow — a `bash` call in an all-Edit tree — is put to you, on an
|
||||||
|
approval card that names the subagent.
|
||||||
|
- **A subagent's end.** It reports and ends with `team_finish`. Its findings (`team_findings`) are
|
||||||
|
kept after it ends. When its context fills it does not compact: it is paused, gets one last
|
||||||
|
turn of at most 8 steps to save its findings and tell its parent, and ends; the main agent
|
||||||
|
replaces it or uses what it found.
|
||||||
|
- **Seeing the tree.** `/team` draws every agent with its position, state, model, mode, context
|
||||||
|
use, current phase and messages, the open slots and the subagents that ended; `/team open
|
||||||
|
<name>` shows a subagent's session read-only, live or after it ended; `/team findings <name>`;
|
||||||
|
`/context <name>` for a subagent's context.
|
||||||
|
- **An agent chat's subagents run on this device.** The chat's Min and Max come with every turn
|
||||||
|
(capability `agents`) and the tree, progress, findings and transcripts go back to the server
|
||||||
|
(capability `tree`: the `team` event with the tree, `_lembas/team`, `/transcript`, `/findings`,
|
||||||
|
`/stop`).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- A subagent can no longer add subagents of its own or start a `task`; it asks the main agent.
|
||||||
|
`team_stop` is the main agent's alone.
|
||||||
|
|
||||||
|
## [2.0.0-beta.5] — 2026-11-09
|
||||||
|
|
||||||
|
Harness spec 3.3.0 (spec commit 02c7282): subagents run on your model unless you name another,
|
||||||
|
and `/context` shows where the window goes the way the side panel of the other apps does.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Subagents run on the session's model**, unless you named another model in this session's own
|
||||||
|
messages — its id, the part after its provider, or its display name, as a whole word, in any
|
||||||
|
letter case. A `task` or `team_add` that asks for any other model is refused ("Use another
|
||||||
|
model for subagents only when the person names it."), and an agent file's own `model:` is no
|
||||||
|
longer used. Nothing in the settings chooses a subagent's model.
|
||||||
|
- **Only the logical capacity limits remain.** A named model on the same one-model server as the
|
||||||
|
session's model is refused ("That model is on the same one-model server as this chat's model;
|
||||||
|
name a model on another server."). On a model that serves one request at a time, a subagent on
|
||||||
|
that same model is refused ("This model serves one request at a time: name another model, on
|
||||||
|
another server, for subagents.") and nothing else is picked instead.
|
||||||
|
- **Subagents are offered** — the size setting, the team tools — when subagents are on and some
|
||||||
|
model could serve one: the session's, when it serves two requests at once, or any model of the
|
||||||
|
server that is not on its one-model server. When the session's model cannot serve its own
|
||||||
|
subagents, the agent is told so in its first note, and `/effort`, `/team size` and `/team` say it
|
||||||
|
beside the setting.
|
||||||
|
- **"Subagents" is the word** for the setting, with **Min** and **Max**: the row beside the effort
|
||||||
|
in `/effort`, `/team size`, `/settings`. "Helper" is gone. The status bar reads
|
||||||
|
`agents 3/4 · 2 subagents working` while there is a team.
|
||||||
|
- **The numbers count this agent**: `Subagents: Min 1, Max 4 — 1 is this agent alone`. Max 1 is
|
||||||
|
the agent alone, with no subagents; Max 4 is the agent and up to three subagents.
|
||||||
|
- **The setting is one range with two handles.** In `/effort` and `/team size` the Subagents row
|
||||||
|
shows `Min 1`, a track from 1 to the cap with a handle for each, and `Max 4`: ←→ move the handle in focus, tab switches to the other, enter
|
||||||
|
sets both for the session (Min never passes Max; Max goes up to `agents.cap`). `/team size` also
|
||||||
|
keeps the usual pairs and `<min> <max>` typed.
|
||||||
|
- **`/context`** shows the window as used tokens of the window and a percentage, one bar split by
|
||||||
|
category, every category with its tokens and its share of the window, what is free, and how far
|
||||||
|
it is to automatic compaction (`N until auto-compact`, with `/compact` to do it now).
|
||||||
|
|
||||||
|
## [2.0.0-beta.4] — 2026-11-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`lembas login` no longer loops on a sign-in the server stopped accepting.** With an earlier login
|
||||||
|
for the same server kept here (from an older install, or LLeMbas Desktop), login only refreshed it;
|
||||||
|
when the server refused the old token it said “run login again”, and running it again did the same.
|
||||||
|
Now a refused sign-in starts a new one, which replaces the old.
|
||||||
|
- The installer's last line says to run `lembas login`, not to edit `connections.yaml` (gone in 2.0).
|
||||||
|
|
||||||
|
## [2.0.0-beta.3] — 2026-11-09
|
||||||
|
|
||||||
|
Harness spec 3.2.0: teams, effort levels that mean what they say, the model's reasoning sent
|
||||||
|
back while it works, and no absolute paths in the prompt.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Teams.** Where the model serves at least two requests at once, the agent can add **members**
|
||||||
|
(`team_add`): background agents that each own a part of the work, briefed on their own and told
|
||||||
|
the team's goal. Once it has a member the agent **leads**: tools that change things answer that
|
||||||
|
it should give the work to a member, while reading, reviewing and managing stay. Members and the
|
||||||
|
lead talk in the team's **room** (`team_send`, to a name or to `all`); a member's final reply
|
||||||
|
comes back as its report and wakes the lead if it was idle, and a message wakes a member that
|
||||||
|
had finished. A member made with `lead: true` leads an area with members of its own.
|
||||||
|
`team_read` shows the team and the room, `team_stop` stops a member and everything under it.
|
||||||
|
- **How many agents a team may have**: the `agents` setting (`min` 1 and `max` 4 by default, at
|
||||||
|
most `agents.cap`, 32), changed with `/team size <min> <max>`, from the **agents** row beside the
|
||||||
|
effort in `/effort`, or in `/settings`; the agent is told the size and every change in a note.
|
||||||
|
`max: 1` means one agent and no helpers; `min: 2` or more makes the agent lead from the start.
|
||||||
|
`subagents: false` switches teams off together with `task`.
|
||||||
|
- **`/team`** shows the team as a tree and the room, and stops a member (`/team stop <name>`); the
|
||||||
|
agent is told who you stopped. The status bar shows `team 3/4 · 2 working` while there is a team.
|
||||||
|
- When every slot of the model is busy, a member (or the agent itself) **waits for a free slot**
|
||||||
|
and starts when one frees, instead of being refused; one-off `task` helpers count toward the
|
||||||
|
team's size.
|
||||||
|
- **A Thinking switch.** A model that can switch thinking off offers `off` as a real level: no
|
||||||
|
thinking at all, not the model's default. `/effort` shows a list for several levels, an on/off
|
||||||
|
switch where that is the only choice, and nothing where there is nothing to choose.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **The model's reasoning goes back to it during its turn**, so it carries on from what it thought
|
||||||
|
instead of working it out again at every step; earlier turns are sent without it, as before.
|
||||||
|
- **No absolute paths in the prompt.** The agent is told the project's name and, when you started
|
||||||
|
below its root, that directory relative to it. Relative paths in every file tool and the
|
||||||
|
directory `bash` starts in are taken from the project root; absolute paths still work.
|
||||||
|
Instruction files are named from the project root or as `~/…`.
|
||||||
|
|
||||||
## [2.0.0-beta.2] — 2026-11-07
|
## [2.0.0-beta.2] — 2026-11-07
|
||||||
|
|
||||||
Harness spec 3.1.0: what the model has already read stays exactly as it was, so its server
|
Harness spec 3.1.0: what the model has already read stays exactly as it was, so its server
|
||||||
|
|||||||
+132
-6
@@ -15,16 +15,19 @@ page *Harness-parity* in LLeMbas CLI's wiki.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `VERSION` | The spec's own version, apart from LLeMbas CLI's |
|
| `VERSION` | The spec's own version, apart from LLeMbas CLI's |
|
||||||
| `purpose.json` | The `purpose` argument added to a tool whose calls say what they are for |
|
| `purpose.json` | The `purpose` argument added to a tool whose calls say what they are for |
|
||||||
| `tools/<name>.json` | One tool each: name, `scope`, `sessions`, `risk`, `exclusive`, `block` (how a call is shown: `shell`, `edit`, `read`, `search`, `web`, `task`, `plan` — not drawn, the plan card refreshes — `hidden`, `tool`), `purpose`, former names (`aliases`, per project), renamed arguments, `variables`, what a project offers of the parameters (`narrow`, per project), `offered` (`subagent`: given to subagents only, never to the main agent — `report`), `requires` and `unavailable` (when it can be offered, and what a call answers when that has gone), `confirm` (`always`: the person is asked in every mode), the description and the JSON Schema of its parameters — exactly what a model is sent |
|
| `tools/<name>.json` | One tool each: name, `scope`, `sessions`, `risk`, `exclusive`, `block` (how a call is shown: `shell`, `edit`, `read`, `search`, `web`, `task`, `plan` — not drawn, the plan card refreshes — `hidden`, `tool`), `purpose`, former names (`aliases`, per project), renamed arguments, `variables`, what a project offers of the parameters (`narrow`, per project), `offered` (`subagent`: given to subagents only, never to the main agent — `report`, `team_finish`, `team_request`; `main`, spec 3.4.0: given to the main agent only, never to a subagent — `team_add`, `team_assign`, `team_replace`, `team_stop`, `team_decide`), `requires` and `unavailable` (when it can be offered, and what a call answers when that has gone), `confirm` (`always`: the person is asked in every mode), the description and the JSON Schema of its parameters — exactly what a model is sent |
|
||||||
| `prompts/` | The prompt texts; `prompts/index.json` says which are shared, which LLeMbas fragment each one is, and what each `{{variable}}` in a text means. `prompts/personality/` holds the personality presets both offer |
|
| `prompts/` | The prompt texts; `prompts/index.json` says which are shared, which LLeMbas fragment each one is, and what each `{{variable}}` in a text means. `prompts/personality/` holds the personality presets both offer |
|
||||||
| `permission/hardline.json` | The floor: commands refused in every mode, protected paths, and how a command is spelled plainly before it is checked again |
|
| `permission/hardline.json` | The floor: commands refused in every mode, protected paths, and how a command is spelled plainly before it is checked again |
|
||||||
| `permission/arity.json` | How much of a command an "always allow" covers |
|
| `permission/arity.json` | How much of a command an "always allow" covers |
|
||||||
| `permission/defaults.json` | The rules every session starts from |
|
| `permission/defaults.json` | The rules every session starts from |
|
||||||
| `permission/modes.json` | The four modes — manual, edit, auto, plan — as what each does with each risk class |
|
| `permission/modes.json` | The four modes — manual, edit, auto, plan — as what each does with each risk class |
|
||||||
| `models/schema.json` | Model metadata: context, output limit, efforts, vision, tools, notes, capacity |
|
| `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; `context` — result ids, stubs, the output cap, the compaction table |
|
| `loop.json` | How one prompt runs: the step backstop, budgets, wrap-up, nudges, parallel calls, capacity; `jobs` — background work, its marks and limits; `subagents` — the model refusals, what names a model, the setting's label; `team` — a team's size, names, marks, refusals and texts; `progress` — the phases' texts; `context` — result ids, stubs, the output cap, the compaction table, the environment lines, the session notes; `scheduled` (3.5.0) — scheduled work on a linked computer: the setting, the modes, the approval wait, the sentinel, the watch command's limits and the refusals |
|
||||||
| `filesystem.md` | The file system a session works in: paths, node kinds, the four access levels, errors, listings, search and indexing by tokens, stacked instruction files, skills, memory |
|
| `filesystem.md` | The file system a session works in: paths, node kinds, the four access levels, errors, listings, search and indexing by tokens, stacked instruction files, skills, memory |
|
||||||
| `context.md` | Context hygiene: what happens to a tool result when it is inserted, and never afterwards |
|
| `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; from 3.4.0 (§5–§13) the main agent and its tree — only it adds, permissions inherited and never widened, request options, a subagent's life and its findings, what clients show |
|
||||||
|
| `progress.md` | Progress (3.4.0): the thirteen phases every client shows the same way, the data each carries, their texts, and where they are sent |
|
||||||
| `quirks.json` | What real servers do, and how both harnesses cope |
|
| `quirks.json` | What real servers do, and how both harnesses cope |
|
||||||
| `conformance/<area>.json` | Cases with their expected results, run by both test suites |
|
| `conformance/<area>.json` | Cases with their expected results, run by both test suites |
|
||||||
| `acp.md` | The link between them: ACP plus every `_lembas/*` method, field and capability |
|
| `acp.md` | The link between them: ACP plus every `_lembas/*` method, field and capability |
|
||||||
@@ -32,7 +35,7 @@ page *Harness-parity* in LLeMbas CLI's wiki.
|
|||||||
## Vocabulary
|
## Vocabulary
|
||||||
|
|
||||||
- **Risk classes:** `read`, `write`, `execute`, `interact`. **Reading and writing are never one
|
- **Risk classes:** `read`, `write`, `execute`, `interact`. **Reading and writing are never one
|
||||||
tool** among the shared ones, so a read-only helper is given exactly the readers.
|
tool** among the shared ones, so a read-only subagent is given exactly the readers.
|
||||||
- **Modes:** `manual`, `edit`, `auto`, `plan` (`unrestricted` is read as `auto`).
|
- **Modes:** `manual`, `edit`, `auto`, `plan` (`unrestricted` is read as `auto`).
|
||||||
- **Scopes:** `shared` — both projects implement it; `cli` or `llembas` — only that one. The same
|
- **Scopes:** `shared` — both projects implement it; `cli` or `llembas` — only that one. The same
|
||||||
two names key what is per project elsewhere: a tool's `aliases`, `loop.json`'s budgets, a
|
two names key what is per project elsewhere: a tool's `aliases`, `loop.json`'s budgets, a
|
||||||
@@ -47,7 +50,9 @@ page *Harness-parity* in LLeMbas CLI's wiki.
|
|||||||
page of the chat is open and attached (not a schedule, not a background job); `display`: the
|
page of the chat is open and attached (not a schedule, not a background job); `display`: the
|
||||||
session is shown somewhere a file can be opened (the web UI, Desktop; not a bare terminal);
|
session is shown somewhere a file can be opened (the web UI, Desktop; not a bare terminal);
|
||||||
`forge`: the session's view holds a repository mounted from a forge; `plan`: the session is in
|
`forge`: the session's view holds a repository mounted from a forge; `plan`: the session is in
|
||||||
plan mode. 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
|
tool's `unavailable` text, word for word, as its result. **From 3.1.0 a requirement that can come
|
||||||
and go during a session does not take the tool out of the list** (`context.md` §10, rule 4): the
|
and go during a session does not take the tool out of the list** (`context.md` §10, rule 4): the
|
||||||
tool is offered from the start and answers `unavailable` while the requirement is not met.
|
tool is offered from the start and answers `unavailable` while the requirement is not met.
|
||||||
@@ -66,6 +71,127 @@ page *Harness-parity* in LLeMbas CLI's wiki.
|
|||||||
approval in plan mode and answers its `unavailable` text outside it. Their permission key is
|
approval in plan mode and answers its `unavailable` text outside it. Their permission key is
|
||||||
`plan` (`permission/defaults.json`), which a rule written for the former `todo` key still reaches.
|
`plan` (`permission/defaults.json`), which a rule written for the former `todo` key still reaches.
|
||||||
|
|
||||||
|
## 3.5.0
|
||||||
|
|
||||||
|
Written ahead of both implementations; the server vendors it as written, both carry it in the same
|
||||||
|
round (`acp.md` *Scheduled work on a linked computer*, `loop.json` `scheduled`).
|
||||||
|
|
||||||
|
- **Scheduled turns on a linked computer.** A schedule may run each time as an agent turn on a
|
||||||
|
linked device, in a folder of it. The device must allow it: **Accept scheduled work**
|
||||||
|
(`remote.scheduled`, off by default; `lembas service scheduled on|off`), said to the server as
|
||||||
|
`limits.scheduled` and judged again at every request — off, every scheduled request is refused
|
||||||
|
with one fixed text. The server opens an agent session marked `scheduled` (schedule, run, mode,
|
||||||
|
approval wait) with the schedule's Min/Max as an agent chat's, and prompts it with the run's own
|
||||||
|
text. **The mode** is the schedule's — Manual, Edit or Auto, **Edit when none was chosen**, so never
|
||||||
|
wider than Edit unless the person picked Auto; Plan refused — lowered to `remote.max_mode`. The
|
||||||
|
answer reports the run: `status` `ok`, `silent` (the `[SILENT]` sentinel on the first or last
|
||||||
|
line, the server's rule), `failed` or `cancelled`, and the final reply's `text`. A device that is
|
||||||
|
away is waited for like a busy model, then the run is missed; a refusal fails the run.
|
||||||
|
- **Approvals in a scheduled turn** go to the person through the server (a phone notification with
|
||||||
|
the card), carry `scheduled` on `session/request_permission`, and wait the schedule's wait
|
||||||
|
(default 30 minutes) instead of `remote.approval_timeout`; then they count as denied and the turn
|
||||||
|
goes on. `ask_user` and `plan_submit` have nobody to answer; `show` is not offered.
|
||||||
|
- **Monitor mode, the device's half**: `_lembas/monitor` runs a schedule's watch command in its
|
||||||
|
folder, with a timeout, under the mode's permission rules — **a command the mode would ask about
|
||||||
|
is refused** — and answers the output's hash (`sha256:` over the bytes and then the exit status) and a
|
||||||
|
short excerpt; the server runs the model only when the hash changed, and records `unchanged`
|
||||||
|
otherwise.
|
||||||
|
- **Schedules over `/api/v2` with the device's token** (scope `link`): the CLI's `/schedule` lists,
|
||||||
|
creates from words (compile), pauses, resumes, runs now and reads history. A run may be
|
||||||
|
`unchanged`, may wait for the `device`; a schedule may say `runs_on` and `watch`.
|
||||||
|
- **`team_add` under a sub-manager** says where the report goes: `team.texts.added_under` and
|
||||||
|
`added_waiting_under` ("Its report goes to <parent> when it finishes").
|
||||||
|
- Capability `scheduled`. Conformance areas **`scheduled`** (the mode, the wait, the refusals),
|
||||||
|
**`silent`** (the sentinel and the stripped text) and **`monitor`** (the hash, and what a watch
|
||||||
|
command may run); `team` cases changed for `added_under`.
|
||||||
|
|
||||||
|
## 3.4.0
|
||||||
|
|
||||||
|
Written ahead of both implementations; the server vendors it as written, both carry it in the same
|
||||||
|
round (`progress.md`, `subagents.md` §5–§13).
|
||||||
|
|
||||||
|
- **Progress** (`progress.md`, `loop.json` `progress`): one fixed set of phases — `queued`,
|
||||||
|
`waiting_for_model`, `loading_model`, `reading_prompt`, `thinking`, `writing`, `running_tool`,
|
||||||
|
`waiting_for_person`, `waiting_for_subagents`, `compacting`, `retrying`, `stopped`, `failed` —
|
||||||
|
each a structured object `{ phase, since, …data }` with fixed fields, an English text that is
|
||||||
|
the shared i18n key, and one line every client draws: `text · elapsed`. Sent beside the old
|
||||||
|
`status` text in `/api/v2` and over the link (`acp.md` capability `progress`: the `phase` event,
|
||||||
|
`progress` in `status`); `status` stays. Area `progress`.
|
||||||
|
- **Subagents v2** (`subagents.md` §5–§13; supersedes `team.md` where they differ, which says so):
|
||||||
|
the **main agent** is the session's, permanent, and **alone adds, assigns, replaces, stops and
|
||||||
|
decides** (`offered: main`); a member with `lead: true` is a **sub-manager** that directs those
|
||||||
|
placed under it (`team_add`'s `parent`). Every subagent has an id (`s1`…), a name, a position, a
|
||||||
|
parent, a task, a mode and a model. Min above 1 opens `min − 1` **slots** from the start of the
|
||||||
|
reply. **Permissions are inherited and never widened**: a subagent's mode is its parent's or
|
||||||
|
stricter; a narrowed one may get **request options** (`read`, `write`, `execute`) and ask the
|
||||||
|
main agent (`team_request` → `team_decide`, a grant that never reaches past the main agent's own
|
||||||
|
mode); a call nobody in the tree can allow goes **to the person**. A subagent ends with
|
||||||
|
`team_finish`, is stopped, or **runs out of context**: paused, one final turn to save its
|
||||||
|
findings and tell its parent, then `out_of_context` and a report; findings and transcripts are
|
||||||
|
kept, readable by the parent, the main agent and the person. New tools `team_whoami`,
|
||||||
|
`team_finish`, `team_findings`, `team_request`, `team_decide`, `team_assign`, `team_replace`;
|
||||||
|
`team_add` gains `parent`, `mode`, `request`; `team_send` takes `parent`; `team_stop` is the main
|
||||||
|
agent's. `blocks/team_submanager.md`; `team_lead.md` and `team_member.md` reworded. Over the link,
|
||||||
|
capability `tree`: the `team` event, `_lembas/team`, `/transcript` (read-only), `/findings`,
|
||||||
|
`/stop`, `subagent` on an approval. **An agent chat's subagents are the device's** (`subagents.md`
|
||||||
|
§14, `acp.md` *Subagents in an agent chat*): capability `agents` carries the chat's `{ min, max }`
|
||||||
|
down on every turn (and `said`, the person's earlier words, on `session/new`); the tree, progress,
|
||||||
|
approvals, findings and transcripts come up. Areas `team` (cases changed where a member used to add or
|
||||||
|
stop, and new ones) and `delegation`.
|
||||||
|
|
||||||
|
## 3.3.0
|
||||||
|
|
||||||
|
Written ahead of both implementations as LLeMbas CLI 02c7282; both carry it in the same round (`subagents.md`).
|
||||||
|
|
||||||
|
- **Subagent** is the word everywhere a person or a model reads one — a `task` subagent and a
|
||||||
|
team's member alike; *helper* is retired. Tool names and aliases are unchanged. The `team`
|
||||||
|
conformance area's step `helper_done` is now **`task_done`**.
|
||||||
|
- **The model**: a subagent runs on the session's model, unless the person named another one in
|
||||||
|
this session's own messages (its served id, its id at its connection or its display name, a whole
|
||||||
|
word, without case — never the model's text or a tool's output). `task`'s and `team_add`'s
|
||||||
|
`model` is refused otherwise: "Use another model for subagents only when the person names it."
|
||||||
|
No setting chooses a subagent's model; an agent definition's own `model` is not used for one.
|
||||||
|
- **Capacity, the logical restrictions only**: another model on the session's model's one-model
|
||||||
|
server is refused ("That model is on the same one-model server as this chat's model; name a
|
||||||
|
model on another server."); the session's model as its own subagent needs `parallel ≥ 2` or no
|
||||||
|
limit, else "This model serves one request at a time: name another model, on another server,
|
||||||
|
for subagents." — and nothing else is picked. A member is refused for these too, and still only
|
||||||
|
waits for a full model.
|
||||||
|
- **The offer**: the `agents {min, max}` setting is shown as **Subagents** (**Min**, **Max**;
|
||||||
|
`loop.json` `subagents.setting`) — one range from 1 to the cap with two handles, and a hint that
|
||||||
|
the numbers count the session's own agent: 1 is the agent alone, Max 4 the agent and up to three
|
||||||
|
subagents — and with it the team tools, when subagents are enabled and at
|
||||||
|
least one model could serve: the session's, with the capacity, or any usable model off its
|
||||||
|
one-model server. `context.note.team_own` tells the model, and `subagents.setting.own` the
|
||||||
|
person, when the session's model cannot serve subagents itself.
|
||||||
|
- `task`'s and `team_add`'s `model` descriptions, `task`'s description (a line on the model),
|
||||||
|
`models/schema.json` capacity texts, `team.md` §2, §4, §8. Conformance area **`subagents`**.
|
||||||
|
|
||||||
|
## 3.2.0
|
||||||
|
|
||||||
|
Written ahead of both implementations; both carry it in the same round.
|
||||||
|
|
||||||
|
- **Effort levels and the thinking switch** (`models/schema.json`). `thinking_switch`: the model's
|
||||||
|
server turns thinking off for `chat_template_kwargs.enable_thinking: false`. The level `off`
|
||||||
|
exists only then, and sends that and no effort — before, `off` sent nothing, which left the model
|
||||||
|
at its server's default effort. A model that takes no efforts has the one level `default`, which
|
||||||
|
sends nothing. The levels a chat offers are `off` (with the switch) and the efforts; one level
|
||||||
|
shows no control, `[off, X]` a Thinking on/off switch, more a slider. `capacity.parallel`: how
|
||||||
|
many requests the model serves at once (`single_session` is `parallel: 1`). Conformance area
|
||||||
|
`levels`; `quirks.json` `thinking-default`; in `acp.md` an `effort` is one of these levels.
|
||||||
|
- **Reasoning goes back during its turn** (`context.md` §8): every assistant message after the last
|
||||||
|
message that started a turn carries its reasoning (`reasoning_content`; Anthropic thinking
|
||||||
|
blocks); earlier turns carry none. Area `reasoning`.
|
||||||
|
- **No absolute paths in the prompt** (`context.md` §11): an agent session's environment block names
|
||||||
|
the project and says paths are relative to its root (`loop.json` `context.env`); relative paths
|
||||||
|
resolve against the project root, absolute ones are still taken. Area `paths`.
|
||||||
|
- **Teams** (`team.md`): `team_add`, `team_send`, `team_read`, `team_stop` (shared, both,
|
||||||
|
`requires: team`); the chat setting `agents {min, max}`; the room and its marks; reports to the
|
||||||
|
manager; the size across the tree, `task` helpers included; a manager's work tools refused with a
|
||||||
|
fixed text while the tool list stays the same; members waiting for a slot past the model's
|
||||||
|
capacity; `blocks/team_lead.md` and `blocks/team_member.md`; size notes in `context.note`. Area
|
||||||
|
`team`.
|
||||||
|
|
||||||
## 3.1.0
|
## 3.1.0
|
||||||
|
|
||||||
Written ahead of both implementations as `3.1.0-draft`, and `3.1.0` once both carried it (LLeMbas CLI 8e06590, LLeMbas 48e905f). **The prefix stays still** (`context.md` §10). Found on 2026-11-05 by measuring a local model's
|
Written ahead of both implementations as `3.1.0-draft`, and `3.1.0` once both carried it (LLeMbas CLI 8e06590, LLeMbas 48e905f). **The prefix stays still** (`context.md` §10). Found on 2026-11-05 by measuring a local model's
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.1.0
|
3.5.0
|
||||||
+263
-10
@@ -65,6 +65,10 @@ change to the protocol is a change here first, then in both projects.
|
|||||||
| `rewind` | `_lembas/session/rewind` |
|
| `rewind` | `_lembas/session/rewind` |
|
||||||
| `show` | spec 3.0.0: `_lembas/show`; the `show` tool is offered in a session the client opened when the client said `show: true` in `clientCapabilities._meta.lembas` |
|
| `show` | spec 3.0.0: `_lembas/show`; the `show` tool is offered in a session the client opened when the client said `show: true` in `clientCapabilities._meta.lembas` |
|
||||||
| `jobs` | background work (spec 2.0.0): the `job` and `job_note` events; `_lembas/jobs`, `_lembas/job/output`, `_lembas/job/stop`, `_lembas/job/send`, `_lembas/job/transcript`; `jobs` in `_lembas/session/status`; `job` on `session/request_permission`; `machine` on a `prompt` event |
|
| `jobs` | background work (spec 2.0.0): the `job` and `job_note` events; `_lembas/jobs`, `_lembas/job/output`, `_lembas/job/stop`, `_lembas/job/send`, `_lembas/job/transcript`; `jobs` in `_lembas/session/status`; `job` on `session/request_permission`; `machine` on a `prompt` event |
|
||||||
|
| `progress` | spec 3.4.0 (`progress.md`): the `phase` event; `progress` in `_lembas/session/status` |
|
||||||
|
| `agents` | spec 3.4.0: the session's Subagents setting `{ min, max }` is read from `_meta.lembas.agents` on `session/new`, `session/prompt` and `_lembas/configure` (*Subagents in an agent chat*, below) |
|
||||||
|
| `tree` | spec 3.4.0 (`subagents.md` §5–§13): the `team` event; `_lembas/team`, `_lembas/team/transcript`, `_lembas/team/findings`, `_lembas/team/stop`; `subagent` on `session/request_permission` |
|
||||||
|
| `scheduled` | spec 3.5.0 (*Scheduled work on a linked computer*, below): `scheduled` in `_meta.lembas` of `session/new` and `session/prompt` read, and the run's outcome in the prompt's answer; `scheduled` on `session/request_permission`; `_lembas/monitor`; `limits.scheduled` in `_lembas/hello` and `initialize`. Listed by every build that has it, whatever the setting says: whether this device *accepts* scheduled work is `limits.scheduled` |
|
||||||
|
|
||||||
## Standard ACP, as used here
|
## Standard ACP, as used here
|
||||||
|
|
||||||
@@ -87,14 +91,15 @@ Result:
|
|||||||
"_meta": { "lembas": {
|
"_meta": { "lembas": {
|
||||||
"protocol": 3,
|
"protocol": 3,
|
||||||
"harness_spec": "3.0.0",
|
"harness_spec": "3.0.0",
|
||||||
"limits": { "roots": ["~/work"], "max_mode": "edit" },
|
"limits": { "roots": ["~/work"], "max_mode": "edit", "scheduled": false },
|
||||||
"connection": "example",
|
"connection": "example",
|
||||||
"capabilities": ["directories", "configure", "…"]
|
"capabilities": ["directories", "configure", "…"]
|
||||||
} }
|
} }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`limits` is `null` on `serve --stdio`. `connection` is the device's name for the instance — the
|
`limits` is `null` on `serve --stdio`. `limits.scheduled` (spec 3.5.0, capability `scheduled`): whether
|
||||||
|
this device accepts scheduled work now (`remote.scheduled`, *Scheduled work on a linked computer*). `connection` is the device's name for the instance — the
|
||||||
login's connection — or `null`; the `model` event and `announce` say directly whether a model is
|
login's connection — or `null`; the `model` event and `announce` say directly whether a model is
|
||||||
the instance's (`instance`).
|
the instance's (`instance`).
|
||||||
|
|
||||||
@@ -112,7 +117,7 @@ Params: `{ cwd: <absolute>, mcpServers?: [], _meta?: { mode?, lembas_model?, mod
|
|||||||
`cwd` must be a directory inside `remote.roots`, in a trusted project when `remote.require_trust`
|
`cwd` must be a directory inside `remote.roots`, in a trusted project when `remote.require_trust`
|
||||||
(the default) — otherwise `-32602` with the reason. `mode` is lowered to `remote.max_mode`.
|
(the default) — otherwise `-32602` with the reason. `mode` is lowered to `remote.max_mode`.
|
||||||
`lembas_model` is the instance's name of a model; `model` a ref the device knows (an editor's
|
`lembas_model` is the instance's name of a model; `model` a ref the device knows (an editor's
|
||||||
choice); `effort` a level or `"off"`.
|
choice); `effort` one of the model's levels (`models/schema.json` `levels`, spec 3.2.0: `"off"` where the model has `thinking_switch`, `"default"` where it takes no efforts, else an effort); a level the model does not offer is read as its start level, and the result says the level used.
|
||||||
|
|
||||||
Result: `{ sessionId: "ses_…", modes: { currentModeId, availableModes }, _meta: { lembas: { model, mode, effort, instance } } }`.
|
Result: `{ sessionId: "ses_…", modes: { currentModeId, availableModes }, _meta: { lembas: { model, mode, effort, instance } } }`.
|
||||||
`instance` (spec 3.0.0) is a boolean, with the `model` event's meaning: true exactly when the
|
`instance` (spec 3.0.0) is a boolean, with the `model` event's meaning: true exactly when the
|
||||||
@@ -245,11 +250,197 @@ with options `once` (allow_once), `session` (allow_always, absent where "always"
|
|||||||
and `deny` (reject_once), and `_meta.lembas: { reason, purpose?, always?, patient?, job? }`. `patient`: a
|
and `deny` (reject_once), and `_meta.lembas: { reason, purpose?, always?, patient?, job? }`. `patient`: a
|
||||||
person may also answer at the terminal (a shared session), so the client does not time it out.
|
person may also answer at the terminal (a shared session), so the client does not time it out.
|
||||||
`job` (capability `jobs`): `{ id, agent, description }` when a subagent running as a background job asks, so the card
|
`job` (capability `jobs`): `{ id, agent, description }` when a subagent running as a background job asks, so the card
|
||||||
can say which — a background subagent may ask while the session itself is idle.
|
can say which — a background subagent may ask while the session itself is idle. `subagent` (capability `tree`, spec 3.4.0):
|
||||||
|
`{ id, name, position }` when the call is a tree subagent's that nobody in the tree could allow (`subagents.md` §7) —
|
||||||
|
the card names it; the answers are the same as for the main agent's own call.
|
||||||
|
`scheduled` (capability `scheduled`, spec 3.5.0): `{ schedule_id, run_id, wait_s }` when the call is
|
||||||
|
a scheduled turn's — nobody is at the chat, so the client puts the card to the person where they are
|
||||||
|
(LLeMbas: a notification on their phone carrying the card, answerable there and in every client),
|
||||||
|
and the device waits `wait_s` for it, not `remote.approval_timeout` (*Scheduled work*, below).
|
||||||
Answer: ACP's `{ outcome: { outcome: "selected", optionId } | { outcome: "cancelled" } }`, with
|
Answer: ACP's `{ outcome: { outcome: "selected", optionId } | { outcome: "cancelled" } }`, with
|
||||||
optional `_meta.lembas.command` (the corrected command line, `once` only) or
|
optional `_meta.lembas.command` (the corrected command line, `once` only) or
|
||||||
`_meta.lembas.feedback` (why it was denied). No answer within `remote.approval_timeout` is a denial.
|
`_meta.lembas.feedback` (why it was denied). No answer within `remote.approval_timeout` is a denial.
|
||||||
|
|
||||||
|
## Subagents in an agent chat (spec 3.4.0)
|
||||||
|
|
||||||
|
**An agent chat's main agent is the CLI on the device.** Its subagents are the device's: it starts
|
||||||
|
them locally, on the models of the server it is logged in to, under every rule of `subagents.md` —
|
||||||
|
the model rules (§2–§3), Min/Max, slots, permissions only ever narrowed, request options, and a
|
||||||
|
call nobody in the tree can allow going to the person. The server runs no subagent of its own for
|
||||||
|
an agent chat and offers its own subagent tool to no agent chat; what it does is carry the setting
|
||||||
|
down and the tree up. A device without the capabilities `agents` and `tree` gets neither and runs no
|
||||||
|
subagents for the chat (the chat says so beside the setting).
|
||||||
|
|
||||||
|
**What goes down, with every turn.**
|
||||||
|
|
||||||
|
- `agents: { min, max }` in `_meta.lembas` of `session/new`, of **every** `session/prompt`, and of
|
||||||
|
`_lembas/configure` when the person changes it between turns — the chat's setting as the server
|
||||||
|
keeps it (`team.md` §2: 1 ≤ min ≤ max ≤ the instance's cap). The device takes the value as the
|
||||||
|
session's setting for that turn: a change reaches the main agent as `team.md` §10's note, and
|
||||||
|
nothing else reads the device's own configuration for the chat. Absent (an older server, or a
|
||||||
|
client that is not LLeMbas): the device's own setting, as before 3.4.0. A value the device's own global cap is under is read as `team.md` §2
|
||||||
|
says (lowered, never refused).
|
||||||
|
- `subagents: false` in the same `_meta.lembas` when the instance has subagents switched off: the
|
||||||
|
device offers none whatever `agents` says.
|
||||||
|
- **What the person named** (`subagents.md` §2): the person's words reach the device as the turns'
|
||||||
|
prompt texts, so the device judges a named model from them as in its own sessions — every
|
||||||
|
`session/prompt` without `machine`; never a `machine` turn, a `_lembas/steer` from the harness, or
|
||||||
|
a tool's output. A chat continued on a device that has not seen its earlier turns gets them in
|
||||||
|
`_meta.lembas.said: string[]`, the person's earlier messages of the chat, oldest first (on
|
||||||
|
`session/new` only); the device counts them as typed in the session.
|
||||||
|
- The models: the device's subagents may run on any model of the person's (`/v1/models` as the
|
||||||
|
device's token sees it), as in its own sessions.
|
||||||
|
|
||||||
|
**What comes up.**
|
||||||
|
|
||||||
|
- The tree, as it changes: the `team` event (`{ tree }`, `subagents.md` §12's data, at most four a
|
||||||
|
second), and on request `_lembas/team` (the tree and the whole room). Every agent's `progress`
|
||||||
|
is in it; the main agent's own also as the `phase` event (capability `progress`).
|
||||||
|
- Approvals: a subagent's call that only the person can allow is an ordinary
|
||||||
|
`session/request_permission` with `_meta.lembas.subagent: { id, name, position }`; the server
|
||||||
|
shows it as its usual approval card naming the subagent, in every client. Requests the main agent
|
||||||
|
decides (`team_request` / `team_decide`) never reach the server as approvals: they are room
|
||||||
|
messages.
|
||||||
|
- Findings and transcripts on request: `_lembas/team/findings`, `_lembas/team/transcript` (live and
|
||||||
|
ended subagents, read-only — the link has no method that writes into a subagent's session).
|
||||||
|
- The person stopping a subagent: `_lembas/team/stop`.
|
||||||
|
|
||||||
|
**What the server keeps.** The latest tree with the chat (so a client opening the chat draws it at
|
||||||
|
once, and after the device has gone), and the room as `_lembas/team` last answered. Findings and
|
||||||
|
transcripts stay on the device; a client asking for one while the device is not linked is told it
|
||||||
|
is on the device, and gets it once the device links again. Over `/api/v2` the chat's stream carries
|
||||||
|
the tree as it changes and the endpoints to read a subagent's transcript and findings, the same for
|
||||||
|
a chat's own subagents and for an agent chat's — so Web, Desktop and Android draw both alike.
|
||||||
|
|
||||||
|
## Scheduled work on a linked computer (spec 3.5.0)
|
||||||
|
|
||||||
|
A schedule (the server's: its rule, its runs, its history and notifications) may run **on a linked
|
||||||
|
computer**: each run is an agent turn on that device, in a folder of it, instead of a chat on the
|
||||||
|
server. The server decides *when*; the device decides *whether*, and *how far* — every rule an agent
|
||||||
|
chat has still holds, and the ones below are added. Numbers and texts: `loop.json` `scheduled`.
|
||||||
|
Conformance areas `scheduled`, `silent`, `monitor`.
|
||||||
|
|
||||||
|
### The device's setting: Accept scheduled work
|
||||||
|
|
||||||
|
- **`remote.scheduled`** in the device's global config, **off by default**; shown as **Accept
|
||||||
|
scheduled work** (`loop.json` `scheduled.setting`). Set from the command line with
|
||||||
|
`lembas service scheduled on|off` (also `lembas remote scheduled on|off`; `lembas remote list
|
||||||
|
--json` says `scheduled`), and by LLeMbas Desktop through the same command. It is global only: a
|
||||||
|
project's config cannot turn it on.
|
||||||
|
- It narrows remote work and widens nothing: a scheduled turn also needs `remote.enabled`, a folder
|
||||||
|
inside `remote.roots`, a trusted project where `remote.require_trust`, and a mode within
|
||||||
|
`remote.max_mode`, exactly as an agent chat does.
|
||||||
|
- The device says it in `limits.scheduled` (hello and `initialize`). Turning it on or off changes
|
||||||
|
the limits, so the device dials again once nothing works (as for any limit) and the client hears
|
||||||
|
it. The client may offer the device in a schedule's form only while `limits.scheduled` is true,
|
||||||
|
and says why not otherwise. The device judges it again at every `session/new`, `session/prompt`
|
||||||
|
and `_lembas/monitor` that is scheduled, whatever it said earlier.
|
||||||
|
- **Off** (or remote work off): every scheduled request is refused with `-32602`, the message
|
||||||
|
`loop.json` `scheduled.texts.off` word for word, and `data: { reason: "scheduled_off" }`. Not
|
||||||
|
retried: it is the person's setting. A folder the device does not accept is `-32602` with the
|
||||||
|
usual reason and `data: { reason: "folder" }`.
|
||||||
|
|
||||||
|
### Starting a scheduled turn
|
||||||
|
|
||||||
|
The client opens an agent session as for an agent chat (`session/new`: `cwd`, `lembas_model`,
|
||||||
|
`effort`, `_meta.lembas` with `agents` — the schedule's **Min/Max**, as an agent chat's — and the
|
||||||
|
`Context`), marked scheduled:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"_meta": { "mode": "edit", "lembas_model": "qwen", "lembas": {
|
||||||
|
"agents": { "min": 1, "max": 4 },
|
||||||
|
"scheduled": { "schedule_id": "sched1", "run_id": "run7", "title": "Waybread check",
|
||||||
|
"mode": "edit", "approval_wait_s": 1800 } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
and runs the turn with `session/prompt`, its `_meta.lembas` carrying the same `scheduled` object
|
||||||
|
(and a `turnId`, the run's — so a prompt sent again after a dropped link gets the run's answer
|
||||||
|
instead of a second run). A schedule that continues one conversation (`continue`) prompts the same
|
||||||
|
session again on its next run; a fresh run opens a new one. The prompt's text is the server's own
|
||||||
|
for a scheduled run — the instruction, the run's time, and for a schedule that notifies only when
|
||||||
|
it matters the `[SILENT]` hint — composed exactly as for a run on the server; the device adds nothing.
|
||||||
|
|
||||||
|
**The mode** (`scheduled.mode`; `_meta.mode` is ignored for a scheduled session): `manual`, `edit`
|
||||||
|
or `auto`, and **`edit` when absent** — so a run is never wider than Edit unless the person picked
|
||||||
|
Auto on that schedule. `plan` is refused (`scheduled.texts.plan`, `data: { reason: "plan" }`):
|
||||||
|
nobody is there to approve a plan. Then lowered to `remote.max_mode`, as every remote session is —
|
||||||
|
Auto runs only where both the schedule and the device allow it (`conformance/scheduled.json`). The
|
||||||
|
mode holds for the turn; neither `_lembas/configure` nor a command in the prompt widens it.
|
||||||
|
|
||||||
|
In a scheduled turn:
|
||||||
|
|
||||||
|
- **Approvals** go to the person through the client (`session/request_permission` with
|
||||||
|
`_meta.lembas.scheduled: { schedule_id, run_id, wait_s }`), and the device waits
|
||||||
|
`approval_wait_s` — the schedule's wait, 30 minutes by default, clamped to
|
||||||
|
`scheduled.approval_wait_seconds` — instead of `remote.approval_timeout`. Unanswered by then, the
|
||||||
|
call is denied with `scheduled.texts.denied` as the feedback the model reads (`{{minutes}}` the
|
||||||
|
wait in whole minutes), the card is settled (`_lembas/permission/settled`), and the turn goes on —
|
||||||
|
the model works around the refusal or fails. Not `patient`.
|
||||||
|
- **`ask_user` and `plan_submit`** have nobody to answer: the device answers them as it does with
|
||||||
|
no client (`ask_user` tells the model nobody can be asked; `plan_submit` saves the plan and says
|
||||||
|
to stop). `show` is not offered: nobody is looking.
|
||||||
|
- Subagents as in an agent chat (`agents`, the tree, findings); a subagent's call only the person
|
||||||
|
can allow is an approval like the main agent's, with `scheduled` beside `subagent`.
|
||||||
|
|
||||||
|
### How the turn ends and reports
|
||||||
|
|
||||||
|
`session/prompt`'s answer carries the run's outcome in `_meta.lembas.scheduled`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "stopReason": "end_turn", "_meta": { "lembas": { "turnId": "run7",
|
||||||
|
"scheduled": { "run_id": "run7", "status": "ok", "text": "All the waybread is wrapped.",
|
||||||
|
"error": null } } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`text`**: the turn's final reply — the text of the last assistant message the turn wrote, as
|
||||||
|
written (`[SILENT]` included). Model output: untrusted wherever it is shown.
|
||||||
|
- **`status`**: `cancelled` — the turn was cancelled (`session/cancel`: the person stopped it, or the
|
||||||
|
client's inactivity limit); `failed` — the turn ended on an error (`error` its text), or with no
|
||||||
|
reply text at all (`scheduled.texts.no_reply`); `silent` — the reply says the sentinel
|
||||||
|
`[SILENT]` on its first or last non-empty line (any case, a trailing full stop forgiven;
|
||||||
|
`conformance/silent.json`, the server's own rule for its runs); otherwise `ok`. A turn that hit the
|
||||||
|
step backstop or a budget and still wrote a reply is `ok`; `stopReason` says why it stopped.
|
||||||
|
- The client records the run from this (its history, its notification by the schedule's policy —
|
||||||
|
`silent` is never notified, a failure is told as a failure), and keeps the session as the run's
|
||||||
|
agent chat, whose events it received as for any agent chat. What the sentinel stripped looks like
|
||||||
|
is `silent.json`'s `stripped`.
|
||||||
|
|
||||||
|
### When the device is away, or refuses
|
||||||
|
|
||||||
|
- **Away** (not linked when the run is due, or the link drops before `session/new` is answered): the
|
||||||
|
client treats the device like a busy model — the run is `waiting` (`waiting_for: "device"`) and
|
||||||
|
tried again on the same steps a run waiting for a free model is, until the run's window closes;
|
||||||
|
then it is **missed**. A turn already running when the link drops is followed again by
|
||||||
|
`_lembas/session/status` and the turn id, as for any agent chat.
|
||||||
|
- **Refused** (`-32602` from a scheduled `session/new`, `session/prompt` or `_lembas/monitor`): the
|
||||||
|
run **fails** with the device's message, said in the run's history and in the failure
|
||||||
|
notification; `data.reason` (`scheduled_off`, `folder`, `plan`, `permission`) lets the client say
|
||||||
|
what to change. Not retried.
|
||||||
|
|
||||||
|
### Monitor mode: a command watched on the device
|
||||||
|
|
||||||
|
A schedule may **watch** something and run the model only when it changed. A URL is fetched by the
|
||||||
|
server and needs nothing from the device; a **command's output** is watched on the device with
|
||||||
|
`_lembas/monitor` (below): the client asks the device to run the watch command, compares the
|
||||||
|
answer's `hash` with the one the previous check stored, and when it is the same records the run as
|
||||||
|
`unchanged` — no model, nobody notified. Changed (or the first check), the model runs as the
|
||||||
|
schedule says — on the server, or as a scheduled turn on the device — told what changed through the
|
||||||
|
`excerpt`. A check that fails (timed out, refused) is a failed run.
|
||||||
|
|
||||||
|
The command runs under the **same permission rules as the schedule's mode**, with one difference:
|
||||||
|
**a command the mode would ask about is refused**, because a watch has nobody to ask. The device
|
||||||
|
judges the command line as `bash` would be judged in a session in that folder (the hardline, the
|
||||||
|
default rules, the global rules and — in a trusted project — the project's), and runs it only on
|
||||||
|
`allow`; `ask` is refused with `scheduled.texts.monitor_asks`, `deny` with
|
||||||
|
`scheduled.texts.monitor_denied`, both `-32602` with `data: { reason: "permission" }`
|
||||||
|
(`conformance/monitor.json`).
|
||||||
|
|
||||||
|
**The hash** (`monitor.json` `hash` cases): `sha256:` and the lowercase hex SHA-256 of the
|
||||||
|
output's bytes exactly as read — standard output and standard error as one stream, as the `bash`
|
||||||
|
tool reads them — followed by a newline and the exit status as decimal digits (after the bytes, so
|
||||||
|
the device hashes the output as it streams, however long). Nothing is normalised: a watch command
|
||||||
|
that prints a time changes every time, and should not.
|
||||||
|
|
||||||
## `_lembas/*` methods
|
## `_lembas/*` methods
|
||||||
|
|
||||||
Direction is who sends it. "→ result" marks a request; the others are notifications.
|
Direction is who sends it. "→ result" marks a request; the others are notifications.
|
||||||
@@ -257,7 +448,9 @@ Direction is who sends it. "→ result" marks a request; the others are notifica
|
|||||||
### `_lembas/hello` — agent → client
|
### `_lembas/hello` — agent → client
|
||||||
|
|
||||||
Sent once when the link opens, before `initialize`:
|
Sent once when the link opens, before `initialize`:
|
||||||
`{ protocol, version, harness_spec, device: { host, platform }, limits: { roots, max_mode, require_trust } }`.
|
`{ protocol, version, harness_spec, device: { host, platform }, limits: { roots, max_mode, require_trust, scheduled } }`
|
||||||
|
(`scheduled` since spec 3.5.0: whether the device accepts scheduled work; a change of it is a
|
||||||
|
change of the limits, below).
|
||||||
|
|
||||||
A device links whether its remote work is on or not, and with no roots at all. Off, or with no
|
A device links whether its remote work is on or not, and with no roots at all. Off, or with no
|
||||||
roots, it says `limits.roots: []` here and in `initialize`, and refuses every `session/new`, every
|
roots, it says `limits.roots: []` here and in `initialize`, and refuses every `session/new`, every
|
||||||
@@ -275,7 +468,7 @@ client hears the new limits.
|
|||||||
`{ sessionId, event }`: every engine event of the session, to a client that said `_meta.lembas`.
|
`{ sessionId, event }`: every engine event of the session, to a client that said `_meta.lembas`.
|
||||||
`event.type` is one of `session`, `prompt`, `step`, `title`, `text`, `reasoning`,
|
`event.type` is one of `session`, `prompt`, `step`, `title`, `text`, `reasoning`,
|
||||||
`tool_call_delta`, `progress`, `tool_start`, `tool_end`, `tool_denied`, `usage`, `inbox`,
|
`tool_call_delta`, `progress`, `tool_start`, `tool_end`, `tool_denied`, `usage`, `inbox`,
|
||||||
`steered`, `task`, `notice`, `retract`, `plan`, `compacted`, `sub_tool`, `job`, `job_note`, `mode`, `model`, `flash`,
|
`steered`, `task`, `notice`, `retract`, `plan`, `compacted`, `sub_tool`, `job`, `job_note`, `phase`, `team`, `mode`, `model`, `flash`,
|
||||||
`setting`, `error`, `done` (`src/bus/index.ts`). Those this protocol relies on:
|
`setting`, `error`, `done` (`src/bus/index.ts`). Those this protocol relies on:
|
||||||
|
|
||||||
| Event | Fields |
|
| Event | Fields |
|
||||||
@@ -284,13 +477,31 @@ client hears the new limits.
|
|||||||
| `task` | `state: "start" \| "end"`, `turnId` — every turn has one: the client's, or one made here for a turn typed in a terminal |
|
| `task` | `state: "start" \| "end"`, `turnId` — every turn has one: the client's, or one made here for a turn typed in a terminal |
|
||||||
| `steered` | `texts`, `ids?` — the `_lembas/steer` message ids taken in at this step |
|
| `steered` | `texts`, `ids?` — the `_lembas/steer` message ids taken in at this step |
|
||||||
| `mode` | `mode` |
|
| `mode` | `mode` |
|
||||||
| `model` | `ref` (the device's ref, above), `effort` (a level or `"off"`), `connection` (the device connection it is spoken to through) and **`instance: boolean`** — true exactly when that connection is the login of **this link's** instance, i.e. the model is one the instance serves (its served id is `ref`, or `ref` less a leading `<connection>/`). Whenever either changes, for any reason: `/model`, `/effort`, the settings tool, a fallback, `_lembas/configure` |
|
| `model` | `ref` (the device's ref, above), `effort` (one of the model's levels: an effort, `"off"` or `"default"`), `connection` (the device connection it is spoken to through) and **`instance: boolean`** — true exactly when that connection is the login of **this link's** instance, i.e. the model is one the instance serves (its served id is `ref`, or `ref` less a leading `<connection>/`). Whenever either changes, for any reason: `/model`, `/effort`, the settings tool, a fallback, `_lembas/configure` |
|
||||||
| `plan` | `plan` — the structure, or `null` when cleared (what the `plan` session update carries) |
|
| `plan` | `plan` — the structure, or `null` when cleared (what the `plan` session update carries) |
|
||||||
| `compacted` | `summary` |
|
| `compacted` | `summary` |
|
||||||
| `title` | `id`, `title` |
|
| `title` | `id`, `title` |
|
||||||
| `job` | `state: "start" \| "update" \| "end"`, `job: JobInfo` (capability `jobs`) — a background job of the session began, has news (new output, a subagent's step; at most four a second per job), or ended. `JobInfo = { id, kind: "command" \| "subagent", state: "running" \| "done" \| "stopped" \| "error" \| "lost", description, command?, agent?, model?, started, ended?, exit?, unread, steps?, tools?, ownerJobId? }` — `description` what is shown for it (a command's `description` or its first line; a subagent's description), `unread` characters of output the model has not read, `exit` a command's exit code (null: a signal), `ownerJobId` set on a job a subagent started, `lost` a job that was running when the device's process ended |
|
| `job` | `state: "start" \| "update" \| "end"`, `job: JobInfo` (capability `jobs`) — a background job of the session began, has news (new output, a subagent's step; at most four a second per job), or ended. `JobInfo = { id, kind: "command" \| "subagent", state: "running" \| "done" \| "stopped" \| "error" \| "lost", description, command?, agent?, model?, started, ended?, exit?, unread, steps?, tools?, ownerJobId? }` — `description` what is shown for it (a command's `description` or its first line; a subagent's description), `unread` characters of output the model has not read, `exit` a command's exit code (null: a signal), `ownerJobId` set on a job a subagent started, `lost` a job that was running when the device's process ended |
|
||||||
| `job_note` | `jobId`, `text` (capability `jobs`) — the note about that job was given to the model: inside a turn at the next step, or as the `machine` turn whose `prompt` follows |
|
| `job_note` | `jobId`, `text` (capability `jobs`) — the note about that job was given to the model: inside a turn at the next step, or as the `machine` turn whose `prompt` follows |
|
||||||
| `sub_tool` | `callId` (the `task` call the subagent belongs to), `jobId?` (its job, when it runs as one), `name`, `title`, `isError?` — a tool call of a subagent finished; a client draws it under the task line, or in the job's panel |
|
| `sub_tool` | `callId` (the `task` call the subagent belongs to), `jobId?` (its job, when it runs as one), `name`, `title`, `isError?` — a tool call of a subagent finished; a client draws it under the task line, or in the job's panel |
|
||||||
|
| `phase` | `progress` — the progress object (`progress.md` §1), or `null` when nothing runs; `agent?` — the subagent's id when it is a subagent's, absent for the main agent's (capability `progress`). Sent when the phase changes, and for a change of its data at most four times a second. The existing `progress` event (the prompt's tokens read) is unchanged |
|
||||||
|
| `team` | `tree` — the session's subagent tree (`subagents.md` §12, the data) — whenever it changes, at most four times a second (capability `tree`). A client draws it and fetches nothing else to do so |
|
||||||
|
|
||||||
|
### `_lembas/monitor` — client → agent → result (capability `scheduled`)
|
||||||
|
|
||||||
|
The watch command of a schedule in monitor mode (*Monitor mode*, above). Params
|
||||||
|
`{ cwd, command, mode?, timeout_s?, schedule_id?, run_id? }` — `cwd` absolute, judged as
|
||||||
|
`session/new`'s; `mode` the schedule's (`manual`, `edit` or `auto`; `edit` when absent; `plan`
|
||||||
|
refused), lowered to `remote.max_mode`; `timeout_s` defaulting to and clamped by
|
||||||
|
`loop.json` `scheduled.monitor.timeout_seconds`. Refused like any scheduled request when the device
|
||||||
|
does not accept scheduled work. The command runs as `bash -c` in `cwd`, in its own process group,
|
||||||
|
with no input; at the timeout the group is stopped.
|
||||||
|
|
||||||
|
Result `{ hash, exit, timed_out, excerpt, truncated, bytes, duration_ms }`: `hash` as above (`null`
|
||||||
|
when it timed out), `exit` the exit status (`null` on a signal or a timeout), `excerpt` the start of
|
||||||
|
the output decoded as UTF-8 (invalid bytes replaced), at most `scheduled.monitor.excerpt_chars`
|
||||||
|
characters, `truncated` whether there was more, `bytes` the output's length. The device hashes all
|
||||||
|
of the output as it streams and keeps at most `scheduled.monitor.output_max_bytes` of it.
|
||||||
|
|
||||||
### `_lembas/directories` — client → agent → result
|
### `_lembas/directories` — client → agent → result
|
||||||
|
|
||||||
@@ -301,7 +512,7 @@ Only on the link (an error on `serve --stdio`).
|
|||||||
|
|
||||||
### `_lembas/configure` — client → agent → result
|
### `_lembas/configure` — client → agent → result
|
||||||
|
|
||||||
Params `{ sessionId, model?: <instance's name>, effort?: <level> | "off" }` between prompts. Result
|
Params `{ sessionId, model?: <instance's name>, effort?: <level>, agents?: { min, max } }` between prompts (a level as in `session/new`; `agents` with capability `agents`, spec 3.4.0). Result
|
||||||
`{ model: <ref>, effort }`. Emits a `model` event when something changed.
|
`{ model: <ref>, effort }`. Emits a `model` event when something changed.
|
||||||
|
|
||||||
### `_lembas/steer` — client → agent → result
|
### `_lembas/steer` — client → agent → result
|
||||||
@@ -319,9 +530,9 @@ with the message in the store; `attachments`, and the text without them) or `{ r
|
|||||||
### `_lembas/session/status` — client → agent → result
|
### `_lembas/session/status` — client → agent → result
|
||||||
|
|
||||||
Params `{ sessionId }`. Result
|
Params `{ sessionId }`. Result
|
||||||
`{ sessionId, exists, held: "here" | "terminal" | "none", busy, title, turnId?, queued: string[], lastTurnId?, plan?, jobs? }`
|
`{ sessionId, exists, held: "here" | "terminal" | "none", busy, title, turnId?, queued: string[], lastTurnId?, plan?, jobs?, progress?, tree? }`
|
||||||
— `plan` the session's plan or `null` (capability `plan_state`), so a client reattaching draws it
|
— `plan` the session's plan or `null` (capability `plan_state`), so a client reattaching draws it
|
||||||
without waiting for the next change; `jobs` the session's background jobs, `JobInfo[]` running first (capability `jobs`), for the same reason; the turn fields with capability `turns`: the turn running now, the ones waiting, the last
|
without waiting for the next change; `progress` the main agent's progress object or `null` (capability `progress`), and `tree` the subagent tree or `null` when the session has none (capability `tree`), for the same reason; `jobs` the session's background jobs, `JobInfo[]` running first (capability `jobs`), for the same reason; the turn fields with capability `turns`: the turn running now, the ones waiting, the last
|
||||||
one finished (for a session not open, `held: "none"`, the last one it finished before). **Reattaching** after a restart or a dropped link, for an incomplete row with turn T:
|
one finished (for a session not open, `held: "none"`, the last one it finished before). **Reattaching** after a restart or a dropped link, for an incomplete row with turn T:
|
||||||
T running → follow it; T queued → wait, and follow it when it starts; T done → import it from
|
T running → follow it; T queued → wait, and follow it when it starts; T done → import it from
|
||||||
`history`; T unknown → send the prompt again (safe: the device deduplicates).
|
`history`; T unknown → send the prompt again (safe: the device deduplicates).
|
||||||
@@ -362,6 +573,30 @@ with its output — so a client can show the subagent as a panel and follow it w
|
|||||||
`job` events that come after. `-32602` for a command job. A subagent's transcript lasts as long as
|
`job` events that come after. `-32602` for a command job. A subagent's transcript lasts as long as
|
||||||
its session row on the device: it is kept with the parent session and deleted with it.
|
its session row on the device: it is kept with the parent session and deleted with it.
|
||||||
|
|
||||||
|
### `_lembas/team` — client → agent → result (capability `tree`)
|
||||||
|
|
||||||
|
Params `{ sessionId, since?: number }`. Result `{ tree, room }`: the subagent tree (`subagents.md`
|
||||||
|
§12) and the room's messages numbered above `since` (default 0), every one — the person sees the
|
||||||
|
whole room — each `{ n, from, to, recipients, text, time, report, kind }` with `kind` one of
|
||||||
|
`message`, `report`, `assignment`, `request`, `decision`. `tree` is `null` for a session with no
|
||||||
|
subagents. For a session held by a terminal, answered through the hub, as `status` is.
|
||||||
|
|
||||||
|
### `_lembas/team/transcript` — client → agent → result (capability `tree`)
|
||||||
|
|
||||||
|
Params `{ sessionId, id }` — a subagent's id (`s1`), live or ended. Result `{ id, state, turns }` in
|
||||||
|
the shape of `_lembas/session/history`'s `turns`: its conversation so far, **read-only** — the link
|
||||||
|
has no method to write into it. A client following it live refetches on the `team` event and draws
|
||||||
|
the `sub_tool` events meanwhile. `-32602` for an id the session never had.
|
||||||
|
|
||||||
|
### `_lembas/team/findings` — client → agent → result (capability `tree`)
|
||||||
|
|
||||||
|
Params `{ sessionId, id }`. Result `{ id, findings: [{ n, text, time }] }`, ended subagents' too.
|
||||||
|
|
||||||
|
### `_lembas/team/stop` — client → agent → result (capability `tree`)
|
||||||
|
|
||||||
|
Params `{ sessionId, id }`: the person stops a subagent and its subtree (`subagents.md` §10), said to
|
||||||
|
the main agent as a note. Result `{ stopped: [id, …] }`; `-32602` for an id that is not live.
|
||||||
|
|
||||||
### `_lembas/session/delete` — client → agent → result
|
### `_lembas/session/delete` — client → agent → result
|
||||||
|
|
||||||
Params `{ sessionId }`: the chat was deleted. The session stops if it works, leaves the store (its
|
Params `{ sessionId }`: the chat was deleted. The session stops if it works, leaves the store (its
|
||||||
@@ -600,6 +835,24 @@ Besides `/v1/models`, the chat completions, `/api/v1/instance` and `/mcp`:
|
|||||||
older `library` scope counts as `memory`. Local memory files are not synced up; logged out, there
|
older `library` scope counts as `memory`. Local memory files are not synced up; logged out, there
|
||||||
is no memory.
|
is no memory.
|
||||||
|
|
||||||
|
- **Schedules** (spec 3.5.0): the schedule routes of `/api/v2` — `GET`/`POST /api/v2/schedules`,
|
||||||
|
`GET`/`PATCH`/`DELETE /api/v2/schedules/{id}`, `POST …/{id}/pause`, `/resume`, `/run`,
|
||||||
|
`GET …/{id}/runs`, `GET …/{id}/runs/{run}`, `POST /api/v2/schedules/compile` and `/preview` —
|
||||||
|
**accept the device's token** (scope `link`) as well as an app's (`app`), for the same person and
|
||||||
|
under the same `schedule.use` permission; nothing else of `/api/v2` does. The shapes are the
|
||||||
|
server's (its contract fixtures); what the CLI's `/schedule` relies on: a schedule's `id`,
|
||||||
|
`title`, `instruction`, `description`, `enabled`, `next_run`, `last_run` (`status`, `timing`,
|
||||||
|
`due_at`, `finished_at`, `preview`, `error.text`), `running`; a run's `id`, `status`, `due_at`,
|
||||||
|
`started_at`, `finished_at`, `model.name`, `preview`, `error`; compile's `ok`, `reason`, `title`,
|
||||||
|
`instruction`, `rule`, `description`, `next_runs`, `timezone`. A run's `status` may also be
|
||||||
|
**`unchanged`** (monitor mode: the watch saw nothing new; no model ran), and `waiting_for` may be
|
||||||
|
**`device`**. A schedule that runs on a linked computer has
|
||||||
|
**`runs_on: { device, device_name, cwd, mode, approval_wait_minutes }`** (else `null`); one that
|
||||||
|
watches has **`watch: { kind: "url", url } | { kind: "command", command, timeout_s }`** (else
|
||||||
|
`null`). Creating one with the device's token, `runs_on.device: "this"` names the calling device.
|
||||||
|
A server without these answers 401 or 403 to the device's token, and the CLI says the server is
|
||||||
|
older.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `_lembas/files/search`'s `root` is the session's working directory, which `@path` mentions
|
- `_lembas/files/search`'s `root` is the session's working directory, which `@path` mentions
|
||||||
|
|||||||
@@ -0,0 +1,433 @@
|
|||||||
|
{
|
||||||
|
"about": "Delegation (subagents.md §7, spec 3.4.0): a subagent's mode and request options, and who decides its calls. The project root is /project, nothing named exists on disk, and only the default rules apply (permission/defaults.json). `add` — team_add's mode check for a parent in `parent_mode` with `mode` and `request` as given → `{result, mode}`: the refusal word for word, or null and the mode the subagent gets. `call` — a subagent in `mode` with `request` options and `grants` (rules {tool, pattern} the main agent allowed) under a main agent in `main_mode`, calling `call` (`bash` with a `line`, or a file tool with a `path` relative to the root) → `{answer, text?}`: `run`, `person` (an approval card to the person), or `refused` with its text, word for word — except where the case says `own_reason` (a hardline's, a mode's or the unattended refusal's reason is each project's), when only the answer is compared. `unattended`: nobody can answer. `request` — team_request for `tool` by a subagent with `request` options, its first → `{result}`. `switch` — the main agent's mode changes from `main_from` to `main_to` with these subagents (each with its parent and mode) → each one's mode after. LLeMbas: services/team.py; CLI: team/delegation.ts.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"about": "No mode given: the parent's.",
|
||||||
|
"op": "add",
|
||||||
|
"parent_mode": "edit",
|
||||||
|
"expect": {
|
||||||
|
"result": null,
|
||||||
|
"mode": "edit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Stricter is allowed.",
|
||||||
|
"op": "add",
|
||||||
|
"parent_mode": "edit",
|
||||||
|
"mode": "manual",
|
||||||
|
"expect": {
|
||||||
|
"result": null,
|
||||||
|
"mode": "manual"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Looser is refused, naming the parent's mode.",
|
||||||
|
"op": "add",
|
||||||
|
"parent_mode": "manual",
|
||||||
|
"mode": "edit",
|
||||||
|
"expect": {
|
||||||
|
"result": "A subagent's mode cannot be looser than its parent's (manual).",
|
||||||
|
"mode": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Plan is the strictest: anything else under it is looser.",
|
||||||
|
"op": "add",
|
||||||
|
"parent_mode": "plan",
|
||||||
|
"mode": "auto",
|
||||||
|
"expect": {
|
||||||
|
"result": "A subagent's mode cannot be looser than its parent's (plan).",
|
||||||
|
"mode": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Request options with the parent's own mode: nothing to ask for.",
|
||||||
|
"op": "add",
|
||||||
|
"parent_mode": "auto",
|
||||||
|
"mode": "auto",
|
||||||
|
"request": [
|
||||||
|
"write"
|
||||||
|
],
|
||||||
|
"expect": {
|
||||||
|
"result": "Request options go with a mode stricter than the parent's: without one there is nothing to ask for.",
|
||||||
|
"mode": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Request options with no mode given (the parent's): refused the same way.",
|
||||||
|
"op": "add",
|
||||||
|
"parent_mode": "edit",
|
||||||
|
"request": [
|
||||||
|
"execute"
|
||||||
|
],
|
||||||
|
"expect": {
|
||||||
|
"result": "Request options go with a mode stricter than the parent's: without one there is nothing to ask for.",
|
||||||
|
"mode": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Request options with a stricter mode.",
|
||||||
|
"op": "add",
|
||||||
|
"parent_mode": "edit",
|
||||||
|
"mode": "manual",
|
||||||
|
"request": [
|
||||||
|
"write",
|
||||||
|
"execute"
|
||||||
|
],
|
||||||
|
"expect": {
|
||||||
|
"result": null,
|
||||||
|
"mode": "manual"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "An unknown kind is refused, naming it.",
|
||||||
|
"op": "add",
|
||||||
|
"parent_mode": "edit",
|
||||||
|
"mode": "manual",
|
||||||
|
"request": [
|
||||||
|
"delete"
|
||||||
|
],
|
||||||
|
"expect": {
|
||||||
|
"result": "delete is not a request option: use read, write or execute.",
|
||||||
|
"mode": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "An empty list of request options is no options, and needs nothing.",
|
||||||
|
"op": "add",
|
||||||
|
"parent_mode": "edit",
|
||||||
|
"request": [],
|
||||||
|
"expect": {
|
||||||
|
"result": null,
|
||||||
|
"mode": "edit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "All in edit: an edit inside the project runs.",
|
||||||
|
"op": "call",
|
||||||
|
"main_mode": "edit",
|
||||||
|
"mode": "edit",
|
||||||
|
"call": {
|
||||||
|
"tool": "edit",
|
||||||
|
"path": "src/a.ts"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"answer": "run"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "All in edit: bash that edit mode asks goes to the person — the main agent could not allow it either.",
|
||||||
|
"op": "call",
|
||||||
|
"main_mode": "edit",
|
||||||
|
"mode": "edit",
|
||||||
|
"call": {
|
||||||
|
"tool": "bash",
|
||||||
|
"line": "git push origin main"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"answer": "person"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "All in manual: an edit goes to the person.",
|
||||||
|
"op": "call",
|
||||||
|
"main_mode": "manual",
|
||||||
|
"mode": "manual",
|
||||||
|
"call": {
|
||||||
|
"tool": "edit",
|
||||||
|
"path": "src/a.ts"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"answer": "person"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Narrowed with no request options: what the main agent could allow is refused.",
|
||||||
|
"op": "call",
|
||||||
|
"main_mode": "edit",
|
||||||
|
"mode": "manual",
|
||||||
|
"call": {
|
||||||
|
"tool": "edit",
|
||||||
|
"path": "src/a.ts"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"answer": "refused",
|
||||||
|
"text": "Your mode does not allow this, and you may not ask for it: tell your parent what you need."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Narrowed with the kind in its options: it asks the main agent.",
|
||||||
|
"op": "call",
|
||||||
|
"main_mode": "edit",
|
||||||
|
"mode": "manual",
|
||||||
|
"request": [
|
||||||
|
"write"
|
||||||
|
],
|
||||||
|
"call": {
|
||||||
|
"tool": "edit",
|
||||||
|
"path": "src/a.ts"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"answer": "refused",
|
||||||
|
"text": "Your mode does not allow this, but the main agent may: ask for it with team_request (the tool, the pattern, why), then try again once it allows."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Narrowed, the kind not in its options.",
|
||||||
|
"op": "call",
|
||||||
|
"main_mode": "auto",
|
||||||
|
"mode": "edit",
|
||||||
|
"request": [
|
||||||
|
"write"
|
||||||
|
],
|
||||||
|
"call": {
|
||||||
|
"tool": "bash",
|
||||||
|
"line": "git push origin main"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"answer": "refused",
|
||||||
|
"text": "Your mode does not allow this, and you may not ask for it: tell your parent what you need."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Narrowed, execute in its options: asks the main agent.",
|
||||||
|
"op": "call",
|
||||||
|
"main_mode": "auto",
|
||||||
|
"mode": "edit",
|
||||||
|
"request": [
|
||||||
|
"execute"
|
||||||
|
],
|
||||||
|
"call": {
|
||||||
|
"tool": "bash",
|
||||||
|
"line": "git push origin main"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"answer": "refused",
|
||||||
|
"text": "Your mode does not allow this, but the main agent may: ask for it with team_request (the tool, the pattern, why), then try again once it allows."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A grant from the main agent lets the call run.",
|
||||||
|
"op": "call",
|
||||||
|
"main_mode": "auto",
|
||||||
|
"mode": "edit",
|
||||||
|
"request": [
|
||||||
|
"execute"
|
||||||
|
],
|
||||||
|
"grants": [
|
||||||
|
{
|
||||||
|
"tool": "bash",
|
||||||
|
"pattern": "git push *"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"call": {
|
||||||
|
"tool": "bash",
|
||||||
|
"line": "git push origin main"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"answer": "run"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A grant for another pattern does not.",
|
||||||
|
"op": "call",
|
||||||
|
"main_mode": "auto",
|
||||||
|
"mode": "edit",
|
||||||
|
"request": [
|
||||||
|
"execute"
|
||||||
|
],
|
||||||
|
"grants": [
|
||||||
|
{
|
||||||
|
"tool": "bash",
|
||||||
|
"pattern": "git push *"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"call": {
|
||||||
|
"tool": "bash",
|
||||||
|
"line": "git commit -m x"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"answer": "refused",
|
||||||
|
"text": "Your mode does not allow this, but the main agent may: ask for it with team_request (the tool, the pattern, why), then try again once it allows."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A grant never reaches past the main agent's own mode: what it would have to ask goes to the person.",
|
||||||
|
"op": "call",
|
||||||
|
"main_mode": "edit",
|
||||||
|
"mode": "manual",
|
||||||
|
"request": [
|
||||||
|
"execute"
|
||||||
|
],
|
||||||
|
"grants": [
|
||||||
|
{
|
||||||
|
"tool": "bash",
|
||||||
|
"pattern": "git push *"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"call": {
|
||||||
|
"tool": "bash",
|
||||||
|
"line": "git push origin main"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"answer": "person"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Reading .env asks in every mode: it goes to the person.",
|
||||||
|
"op": "call",
|
||||||
|
"main_mode": "edit",
|
||||||
|
"mode": "edit",
|
||||||
|
"call": {
|
||||||
|
"tool": "read",
|
||||||
|
"path": ".env"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"answer": "person"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Reading .env under an auto main agent: a narrowed subagent with read in its options asks it.",
|
||||||
|
"op": "call",
|
||||||
|
"main_mode": "auto",
|
||||||
|
"mode": "manual",
|
||||||
|
"request": [
|
||||||
|
"read"
|
||||||
|
],
|
||||||
|
"call": {
|
||||||
|
"tool": "read",
|
||||||
|
"path": ".env"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"answer": "refused",
|
||||||
|
"text": "Your mode does not allow this, but the main agent may: ask for it with team_request (the tool, the pattern, why), then try again once it allows."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Plan mode's refusal is final: request options do not reach past it.",
|
||||||
|
"op": "call",
|
||||||
|
"main_mode": "auto",
|
||||||
|
"mode": "plan",
|
||||||
|
"request": [
|
||||||
|
"execute"
|
||||||
|
],
|
||||||
|
"call": {
|
||||||
|
"tool": "bash",
|
||||||
|
"line": "rm -rf build"
|
||||||
|
},
|
||||||
|
"own_reason": true,
|
||||||
|
"expect": {
|
||||||
|
"answer": "refused"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "What the rules allow runs in every mode.",
|
||||||
|
"op": "call",
|
||||||
|
"main_mode": "edit",
|
||||||
|
"mode": "plan",
|
||||||
|
"call": {
|
||||||
|
"tool": "bash",
|
||||||
|
"line": "git status"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"answer": "run"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The hardline refuses in every mode, auto included.",
|
||||||
|
"op": "call",
|
||||||
|
"main_mode": "auto",
|
||||||
|
"mode": "auto",
|
||||||
|
"call": {
|
||||||
|
"tool": "bash",
|
||||||
|
"line": "rm -rf /"
|
||||||
|
},
|
||||||
|
"own_reason": true,
|
||||||
|
"expect": {
|
||||||
|
"answer": "refused"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Unattended: what would go to the person is refused.",
|
||||||
|
"op": "call",
|
||||||
|
"main_mode": "edit",
|
||||||
|
"mode": "edit",
|
||||||
|
"unattended": true,
|
||||||
|
"call": {
|
||||||
|
"tool": "bash",
|
||||||
|
"line": "git push origin main"
|
||||||
|
},
|
||||||
|
"own_reason": true,
|
||||||
|
"expect": {
|
||||||
|
"answer": "refused"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "team_request: a tool whose risk class is not in the options is refused, naming them.",
|
||||||
|
"op": "request",
|
||||||
|
"request": [
|
||||||
|
"write"
|
||||||
|
],
|
||||||
|
"tool": "bash",
|
||||||
|
"expect": {
|
||||||
|
"result": "You may not ask for bash: your request options are write."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "team_request: a kind in the options is asked, with the request's id.",
|
||||||
|
"op": "request",
|
||||||
|
"request": [
|
||||||
|
"write",
|
||||||
|
"execute"
|
||||||
|
],
|
||||||
|
"tool": "bash",
|
||||||
|
"expect": {
|
||||||
|
"result": "Asked the main agent (r1): you are told its decision. Carry on with what you can meanwhile."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The main agent made stricter: every subagent looser than its parent is made as strict as it, down the tree; a stricter one keeps its own.",
|
||||||
|
"op": "switch",
|
||||||
|
"agents": [
|
||||||
|
{
|
||||||
|
"name": "api",
|
||||||
|
"parent": "lead",
|
||||||
|
"mode": "auto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tests",
|
||||||
|
"parent": "api",
|
||||||
|
"mode": "edit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "docs",
|
||||||
|
"parent": "lead",
|
||||||
|
"mode": "plan"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"main_from": "auto",
|
||||||
|
"main_to": "manual",
|
||||||
|
"expect": {
|
||||||
|
"api": "manual",
|
||||||
|
"tests": "manual",
|
||||||
|
"docs": "plan"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The main agent made looser: no subagent changes.",
|
||||||
|
"op": "switch",
|
||||||
|
"agents": [
|
||||||
|
{
|
||||||
|
"name": "api",
|
||||||
|
"parent": "lead",
|
||||||
|
"mode": "edit"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"main_from": "edit",
|
||||||
|
"main_to": "auto",
|
||||||
|
"expect": {
|
||||||
|
"api": "edit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
{
|
||||||
|
"about": "The effort levels a chat offers for a model, and what each sends (models/schema.json `levels`, spec 3.2.0). `op: levels` — `model` is model metadata (`thinking_switch`, `efforts` as given — never absent here, since an absent list is each project's own fallback — `effort`); the answer is `levels` (in offering order), `control` (`none`: one level, no effort control; `switch`: two levels, one of them off — a Thinking on/off switch; `slider`: anything else, a slider or a list) and `start` (the level a new conversation starts with: `effort` when it is one of the levels, else `medium` when offered, else the first effort, else `default`). `op: request` — `level` is the level asked for; the answer is what an OpenAI chat request body carries for it: `reasoning_effort` and `chat_template_kwargs` (only the keys set; `{}` when nothing). A level the model does not offer is read as the start level. LLeMbas: services/generation.py; CLI: provider/effort.ts.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"about": "A model with the switch and one effort: a Thinking switch, on at its effort.",
|
||||||
|
"op": "levels",
|
||||||
|
"model": {
|
||||||
|
"thinking_switch": true,
|
||||||
|
"efforts": [
|
||||||
|
"medium"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"levels": [
|
||||||
|
"off",
|
||||||
|
"medium"
|
||||||
|
],
|
||||||
|
"control": "switch",
|
||||||
|
"start": "medium"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The same effort without the switch: one level, no control at all.",
|
||||||
|
"op": "levels",
|
||||||
|
"model": {
|
||||||
|
"efforts": [
|
||||||
|
"medium"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"levels": [
|
||||||
|
"medium"
|
||||||
|
],
|
||||||
|
"control": "none",
|
||||||
|
"start": "medium"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The switch and no efforts: off, or the model's own default — still a switch.",
|
||||||
|
"op": "levels",
|
||||||
|
"model": {
|
||||||
|
"thinking_switch": true,
|
||||||
|
"efforts": []
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"levels": [
|
||||||
|
"off",
|
||||||
|
"default"
|
||||||
|
],
|
||||||
|
"control": "switch",
|
||||||
|
"start": "default"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Neither: one level, the model's own; nothing is shown and nothing sent.",
|
||||||
|
"op": "levels",
|
||||||
|
"model": {
|
||||||
|
"efforts": []
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"levels": [
|
||||||
|
"default"
|
||||||
|
],
|
||||||
|
"control": "none",
|
||||||
|
"start": "default"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The switch and three efforts: a slider, off first; medium is where it starts when nothing is said.",
|
||||||
|
"op": "levels",
|
||||||
|
"model": {
|
||||||
|
"thinking_switch": true,
|
||||||
|
"efforts": [
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"levels": [
|
||||||
|
"off",
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high"
|
||||||
|
],
|
||||||
|
"control": "slider",
|
||||||
|
"start": "medium"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Three efforts without the switch: a slider without off.",
|
||||||
|
"op": "levels",
|
||||||
|
"model": {
|
||||||
|
"efforts": [
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"levels": [
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high"
|
||||||
|
],
|
||||||
|
"control": "slider",
|
||||||
|
"start": "medium"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The model's own start level, when it offers it.",
|
||||||
|
"op": "levels",
|
||||||
|
"model": {
|
||||||
|
"thinking_switch": true,
|
||||||
|
"efforts": [
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high"
|
||||||
|
],
|
||||||
|
"effort": "high"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"levels": [
|
||||||
|
"off",
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high"
|
||||||
|
],
|
||||||
|
"control": "slider",
|
||||||
|
"start": "high"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A start of off, with the switch: the conversation starts with thinking off.",
|
||||||
|
"op": "levels",
|
||||||
|
"model": {
|
||||||
|
"thinking_switch": true,
|
||||||
|
"efforts": [
|
||||||
|
"medium"
|
||||||
|
],
|
||||||
|
"effort": "off"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"levels": [
|
||||||
|
"off",
|
||||||
|
"medium"
|
||||||
|
],
|
||||||
|
"control": "switch",
|
||||||
|
"start": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A start of off without the switch is not a level the model offers: the start rule applies (no medium: the first effort).",
|
||||||
|
"op": "levels",
|
||||||
|
"model": {
|
||||||
|
"efforts": [
|
||||||
|
"low",
|
||||||
|
"high"
|
||||||
|
],
|
||||||
|
"effort": "off"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"levels": [
|
||||||
|
"low",
|
||||||
|
"high"
|
||||||
|
],
|
||||||
|
"control": "slider",
|
||||||
|
"start": "low"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "thinking_switch false is the same as absent.",
|
||||||
|
"op": "levels",
|
||||||
|
"model": {
|
||||||
|
"thinking_switch": false,
|
||||||
|
"efforts": [
|
||||||
|
"high",
|
||||||
|
"xhigh"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"levels": [
|
||||||
|
"high",
|
||||||
|
"xhigh"
|
||||||
|
],
|
||||||
|
"control": "slider",
|
||||||
|
"start": "high"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Two efforts and no switch: two levels, neither off — a slider, not a switch.",
|
||||||
|
"op": "levels",
|
||||||
|
"model": {
|
||||||
|
"efforts": [
|
||||||
|
"low",
|
||||||
|
"medium"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"levels": [
|
||||||
|
"low",
|
||||||
|
"medium"
|
||||||
|
],
|
||||||
|
"control": "slider",
|
||||||
|
"start": "medium"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "off sends enable_thinking false and no effort anywhere.",
|
||||||
|
"op": "request",
|
||||||
|
"model": {
|
||||||
|
"thinking_switch": true,
|
||||||
|
"efforts": [
|
||||||
|
"medium"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"level": "off",
|
||||||
|
"expect": {
|
||||||
|
"chat_template_kwargs": {
|
||||||
|
"enable_thinking": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "An effort is sent where effort_style puts it (both, by default), and enable_thinking is not sent.",
|
||||||
|
"op": "request",
|
||||||
|
"model": {
|
||||||
|
"thinking_switch": true,
|
||||||
|
"efforts": [
|
||||||
|
"medium"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"level": "medium",
|
||||||
|
"expect": {
|
||||||
|
"reasoning_effort": "medium",
|
||||||
|
"chat_template_kwargs": {
|
||||||
|
"reasoning_effort": "medium"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "effort_style top: the effort at the top level only.",
|
||||||
|
"op": "request",
|
||||||
|
"model": {
|
||||||
|
"thinking_switch": true,
|
||||||
|
"efforts": [
|
||||||
|
"low",
|
||||||
|
"medium"
|
||||||
|
],
|
||||||
|
"effort_style": "top"
|
||||||
|
},
|
||||||
|
"level": "low",
|
||||||
|
"expect": {
|
||||||
|
"reasoning_effort": "low"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "effort_style kwargs: inside chat_template_kwargs only.",
|
||||||
|
"op": "request",
|
||||||
|
"model": {
|
||||||
|
"efforts": [
|
||||||
|
"low",
|
||||||
|
"medium"
|
||||||
|
],
|
||||||
|
"effort_style": "kwargs"
|
||||||
|
},
|
||||||
|
"level": "medium",
|
||||||
|
"expect": {
|
||||||
|
"chat_template_kwargs": {
|
||||||
|
"reasoning_effort": "medium"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "default sends nothing: the model's own.",
|
||||||
|
"op": "request",
|
||||||
|
"model": {
|
||||||
|
"thinking_switch": true,
|
||||||
|
"efforts": []
|
||||||
|
},
|
||||||
|
"level": "default",
|
||||||
|
"expect": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "off, effort_style top: still only enable_thinking, in chat_template_kwargs (the only place a template reads it).",
|
||||||
|
"op": "request",
|
||||||
|
"model": {
|
||||||
|
"thinking_switch": true,
|
||||||
|
"efforts": [
|
||||||
|
"medium"
|
||||||
|
],
|
||||||
|
"effort_style": "top"
|
||||||
|
},
|
||||||
|
"level": "off",
|
||||||
|
"expect": {
|
||||||
|
"chat_template_kwargs": {
|
||||||
|
"enable_thinking": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "off asked of a model without the switch is read as its start level.",
|
||||||
|
"op": "request",
|
||||||
|
"model": {
|
||||||
|
"efforts": [
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"level": "off",
|
||||||
|
"expect": {
|
||||||
|
"reasoning_effort": "medium",
|
||||||
|
"chat_template_kwargs": {
|
||||||
|
"reasoning_effort": "medium"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A level the model does not take is read as its start level too.",
|
||||||
|
"op": "request",
|
||||||
|
"model": {
|
||||||
|
"thinking_switch": true,
|
||||||
|
"efforts": [
|
||||||
|
"medium"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"level": "high",
|
||||||
|
"expect": {
|
||||||
|
"reasoning_effort": "medium",
|
||||||
|
"chat_template_kwargs": {
|
||||||
|
"reasoning_effort": "medium"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
{
|
||||||
|
"about": "Spec 3.5.0 (acp.md *Monitor mode*, `_lembas/monitor`): the watch command on a device. `hash`: `exit` and `output` (the bytes as UTF-8) → the answer's `hash`. `judge`: a command line in the schedule's mode, with the default rules (and any extra global rules given), the project root /project and nothing named on disk, as conformance/permission.json judges it → `run` (the mode allows it without asking), or `{refused}` with `asks` (the mode would ask: scheduled.texts.monitor_asks) or `denied` (scheduled.texts.monitor_denied). The device's own; the server keeps only the hash.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"about": "Exit 0 and a line of output.",
|
||||||
|
"kind": "hash",
|
||||||
|
"exit": 0,
|
||||||
|
"output": "ok\n",
|
||||||
|
"expect": "sha256:9427ae13c91f98a3af2213b34fb59e4bb5aa9e6101a1f7407f526dde3571e6aa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The same output with another exit status is a change.",
|
||||||
|
"kind": "hash",
|
||||||
|
"exit": 1,
|
||||||
|
"output": "ok\n",
|
||||||
|
"expect": "sha256:21e0bbd37d04094ef29f22d0212bb0f4bedda1b1fab38d51f9b8670b5cf07df4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "No output.",
|
||||||
|
"kind": "hash",
|
||||||
|
"exit": 0,
|
||||||
|
"output": "",
|
||||||
|
"expect": "sha256:c7757c0896cbfe6182d8ea2bda4a8bf94addc428980eedab8609c57ca7ff1763"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A trailing newline counts: nothing is normalised.",
|
||||||
|
"kind": "hash",
|
||||||
|
"exit": 0,
|
||||||
|
"output": "ok",
|
||||||
|
"expect": "sha256:b9b8913ecae1ba8190ef1a22cc57afef08ad3933ab4b13a32f8cdccb07bb527b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "UTF-8 output, hashed as its bytes.",
|
||||||
|
"kind": "hash",
|
||||||
|
"exit": 0,
|
||||||
|
"output": "lembas · ✓\n",
|
||||||
|
"expect": "sha256:3d1c38d3b96ee10d71faac35eb2933ba13b68d6325872fdda3e459c9fbd17d98"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A read-only command in Edit runs.",
|
||||||
|
"kind": "judge",
|
||||||
|
"line": "git status",
|
||||||
|
"mode": "edit",
|
||||||
|
"expect": "run"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A command Edit would ask about is refused: a watch has nobody to ask.",
|
||||||
|
"kind": "judge",
|
||||||
|
"line": "rm -rf build",
|
||||||
|
"mode": "edit",
|
||||||
|
"expect": {
|
||||||
|
"refused": "asks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "In Auto it runs.",
|
||||||
|
"kind": "judge",
|
||||||
|
"line": "rm -rf build",
|
||||||
|
"mode": "auto",
|
||||||
|
"expect": "run"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The hardline is a floor in every mode.",
|
||||||
|
"kind": "judge",
|
||||||
|
"line": "rm -rf /",
|
||||||
|
"mode": "auto",
|
||||||
|
"expect": {
|
||||||
|
"refused": "denied"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A line that pipes a download into a shell asks in Manual: refused.",
|
||||||
|
"kind": "judge",
|
||||||
|
"line": "curl -s https://example.org/feed | sh",
|
||||||
|
"mode": "manual",
|
||||||
|
"expect": {
|
||||||
|
"refused": "asks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A rule the person wrote allows it.",
|
||||||
|
"kind": "judge",
|
||||||
|
"line": "make check",
|
||||||
|
"mode": "edit",
|
||||||
|
"rules": {
|
||||||
|
"bash": {
|
||||||
|
"make check": "allow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"expect": "run"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A rule the person wrote denies it, even in Auto.",
|
||||||
|
"kind": "judge",
|
||||||
|
"line": "make deploy",
|
||||||
|
"mode": "auto",
|
||||||
|
"rules": {
|
||||||
|
"bash": {
|
||||||
|
"make deploy": "deny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"refused": "denied"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Reading a secret asks: refused.",
|
||||||
|
"kind": "judge",
|
||||||
|
"line": "cat .env",
|
||||||
|
"mode": "edit",
|
||||||
|
"expect": {
|
||||||
|
"refused": "asks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "An ordinary listing runs in Manual.",
|
||||||
|
"kind": "judge",
|
||||||
|
"line": "ls -la",
|
||||||
|
"mode": "manual",
|
||||||
|
"expect": "run"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"about": "Where an agent session works, as the model is told it and as its tools read a path (context.md §11, spec 3.2.0). `op: env` — `root` is the project root and `started` the directory the session was started in (both absolute); the answer is the environment block's lines about them, in order, from loop.json context.env — none naming an absolute path. `op: resolve` — `path` as a tool call gives it; the answer is the absolute path it means: relative paths against the project root, whatever `started` is, `.` and `..` by their text (filesystem.md, Paths); an absolute path as it is. LLeMbas: not run — a chat's paths are the server's tree's; CLI: prompt/assemble.ts, tool/tool.ts.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"about": "Started at the root: the project's name and nothing else.",
|
||||||
|
"op": "env",
|
||||||
|
"root": "/home/ada/src/app",
|
||||||
|
"started": "/home/ada/src/app",
|
||||||
|
"expect": [
|
||||||
|
"Project: app (paths are relative to its root)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Started below the root: that directory, relative to the root.",
|
||||||
|
"op": "env",
|
||||||
|
"root": "/home/ada/src/app",
|
||||||
|
"started": "/home/ada/src/app/packages/web",
|
||||||
|
"expect": [
|
||||||
|
"Project: app (paths are relative to its root)",
|
||||||
|
"Started in: packages/web (relative to the project root)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A trailing slash changes nothing.",
|
||||||
|
"op": "env",
|
||||||
|
"root": "/srv/work/site/",
|
||||||
|
"started": "/srv/work/site",
|
||||||
|
"expect": [
|
||||||
|
"Project: site (paths are relative to its root)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A relative path is resolved against the project root.",
|
||||||
|
"op": "resolve",
|
||||||
|
"root": "/home/ada/src/app",
|
||||||
|
"started": "/home/ada/src/app",
|
||||||
|
"path": "src/main.ts",
|
||||||
|
"expect": "/home/ada/src/app/src/main.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "…also when the session started below the root: one base for every relative path.",
|
||||||
|
"op": "resolve",
|
||||||
|
"root": "/home/ada/src/app",
|
||||||
|
"started": "/home/ada/src/app/packages/web",
|
||||||
|
"path": "src/main.ts",
|
||||||
|
"expect": "/home/ada/src/app/src/main.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The directory the session started in is reached by its path from the root.",
|
||||||
|
"op": "resolve",
|
||||||
|
"root": "/home/ada/src/app",
|
||||||
|
"started": "/home/ada/src/app/packages/web",
|
||||||
|
"path": "packages/web/index.ts",
|
||||||
|
"expect": "/home/ada/src/app/packages/web/index.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "An absolute path is still accepted, as it is.",
|
||||||
|
"op": "resolve",
|
||||||
|
"root": "/home/ada/src/app",
|
||||||
|
"started": "/home/ada/src/app/packages/web",
|
||||||
|
"path": "/etc/hosts",
|
||||||
|
"expect": "/etc/hosts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": ". is the root.",
|
||||||
|
"op": "resolve",
|
||||||
|
"root": "/home/ada/src/app",
|
||||||
|
"started": "/home/ada/src/app/packages/web",
|
||||||
|
"path": ".",
|
||||||
|
"expect": "/home/ada/src/app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": ".. is resolved by its text (whether it may be reached is the permission rules' business).",
|
||||||
|
"op": "resolve",
|
||||||
|
"root": "/home/ada/src/app",
|
||||||
|
"started": "/home/ada/src/app",
|
||||||
|
"path": "../lib/x.ts",
|
||||||
|
"expect": "/home/ada/src/lib/x.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "./ and repeated slashes are resolved by their text.",
|
||||||
|
"op": "resolve",
|
||||||
|
"root": "/home/ada/src/app",
|
||||||
|
"started": "/home/ada/src/app",
|
||||||
|
"path": "./docs//guide.md",
|
||||||
|
"expect": "/home/ada/src/app/docs/guide.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
{
|
||||||
|
"about": "Progress (progress.md, spec 3.4.0): the text and the line every client draws for a progress object. `progress`: the object; `now`: the client's clock (Unix ms). Expect `text` — the phase's text from loop.json progress.texts, its fields filled (numbers written plainly: integers as they are, a rate with one decimal) — and `line` — `{{text}} · {{elapsed}}` with elapsed = now − since in whole seconds (5s, 1m 05s, 1h 02m), or the text alone for stopped and failed. retrying's `in` is counted down: in − floor((now − since) / 1000), never below 0. LLeMbas: services/progress.py; CLI: session/progress.ts.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"about": "Queued, nothing more known.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "queued",
|
||||||
|
"since": 1762790400000
|
||||||
|
},
|
||||||
|
"now": 1762790405000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Queued",
|
||||||
|
"line": "Queued · 5s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Queued behind two turns.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "queued",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"ahead": 2
|
||||||
|
},
|
||||||
|
"now": 1762790465000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Queued: 2 ahead",
|
||||||
|
"line": "Queued: 2 ahead · 1m 05s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A one-request server answering another request.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_model",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"model": "Qwen 27B",
|
||||||
|
"reason": "busy"
|
||||||
|
},
|
||||||
|
"now": 1762790412400,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for Qwen 27B: it serves one request at a time and is answering another",
|
||||||
|
"line": "Waiting for Qwen 27B: it serves one request at a time and is answering another · 12s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Every slot taken.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_model",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"model": "coder",
|
||||||
|
"reason": "slots"
|
||||||
|
},
|
||||||
|
"now": 1762790401000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for coder: all its slots are in use",
|
||||||
|
"line": "Waiting for coder: all its slots are in use · 1s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Another model on the one-model server.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_model",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"model": "coder",
|
||||||
|
"reason": "group"
|
||||||
|
},
|
||||||
|
"now": 1762790400000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for coder: its server holds another model now",
|
||||||
|
"line": "Waiting for coder: its server holds another model now · 0s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Loading.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "loading_model",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"model": "Gemma 12B"
|
||||||
|
},
|
||||||
|
"now": 1762794125000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Loading Gemma 12B",
|
||||||
|
"line": "Loading Gemma 12B · 1h 02m"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Reading the prompt: the percentage is floored.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "reading_prompt",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"done": 1999,
|
||||||
|
"total": 3000
|
||||||
|
},
|
||||||
|
"now": 1762790402000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Reading the prompt: 1999 / 3000 tokens (66%)",
|
||||||
|
"line": "Reading the prompt: 1999 / 3000 tokens (66%) · 2s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Reading the prompt with no counts from the server.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "reading_prompt",
|
||||||
|
"since": 1762790400000
|
||||||
|
},
|
||||||
|
"now": 1762790402000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Reading the prompt",
|
||||||
|
"line": "Reading the prompt · 2s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Thinking.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "thinking",
|
||||||
|
"since": 1762790400000
|
||||||
|
},
|
||||||
|
"now": 1762790459999,
|
||||||
|
"expect": {
|
||||||
|
"text": "Thinking",
|
||||||
|
"line": "Thinking · 59s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Writing, the rate with one decimal.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "writing",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"rate": 12.46
|
||||||
|
},
|
||||||
|
"now": 1762790404000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Writing: 12.5 tok/s",
|
||||||
|
"line": "Writing: 12.5 tok/s · 4s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Writing before a rate is measured.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "writing",
|
||||||
|
"since": 1762790400000
|
||||||
|
},
|
||||||
|
"now": 1762790400400,
|
||||||
|
"expect": {
|
||||||
|
"text": "Writing",
|
||||||
|
"line": "Writing · 0s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A tool with what it does.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "running_tool",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"tool": "bash",
|
||||||
|
"what": "bun test"
|
||||||
|
},
|
||||||
|
"now": 1762790461000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Running bash: bun test",
|
||||||
|
"line": "Running bash: bun test · 1m 01s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A tool without a title.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "running_tool",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"tool": "recall"
|
||||||
|
},
|
||||||
|
"now": 1762790401000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Running recall",
|
||||||
|
"line": "Running recall · 1s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "An approval, naming the tool.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_person",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"what": "approval",
|
||||||
|
"tool": "bash"
|
||||||
|
},
|
||||||
|
"now": 1762790430000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for you: allow bash?",
|
||||||
|
"line": "Waiting for you: allow bash? · 30s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "An approval with no tool named.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_person",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"what": "approval"
|
||||||
|
},
|
||||||
|
"now": 1762790400000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for you: an approval",
|
||||||
|
"line": "Waiting for you: an approval · 0s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A question.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_person",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"what": "question"
|
||||||
|
},
|
||||||
|
"now": 1762790403000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for you: a question",
|
||||||
|
"line": "Waiting for you: a question · 3s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The plan.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_person",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"what": "plan"
|
||||||
|
},
|
||||||
|
"now": 1762790403000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for you: the plan",
|
||||||
|
"line": "Waiting for you: the plan · 3s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Subagents.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_subagents",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"running": 3
|
||||||
|
},
|
||||||
|
"now": 1762790490000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for subagents: 3 running",
|
||||||
|
"line": "Waiting for subagents: 3 running · 1m 30s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Compacting.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "compacting",
|
||||||
|
"since": 1762790400000
|
||||||
|
},
|
||||||
|
"now": 1762790407000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Compacting the conversation",
|
||||||
|
"line": "Compacting the conversation · 7s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Retrying: `in` counts down from since.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "retrying",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"reason": "502 Bad Gateway",
|
||||||
|
"in": 10,
|
||||||
|
"attempt": 2
|
||||||
|
},
|
||||||
|
"now": 1762790403000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Retrying in 7 s: 502 Bad Gateway",
|
||||||
|
"line": "Retrying in 7 s: 502 Bad Gateway · 3s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Retrying: never below 0.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "retrying",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"reason": "overloaded",
|
||||||
|
"in": 2
|
||||||
|
},
|
||||||
|
"now": 1762790405000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Retrying in 0 s: overloaded",
|
||||||
|
"line": "Retrying in 0 s: overloaded · 5s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Stopped draws no elapsed time.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "stopped",
|
||||||
|
"since": 1762790400000
|
||||||
|
},
|
||||||
|
"now": 1762790405000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Stopped",
|
||||||
|
"line": "Stopped"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Stopped by the person.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "stopped",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"reason": "person"
|
||||||
|
},
|
||||||
|
"now": 1762790405000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Stopped: by you",
|
||||||
|
"line": "Stopped: by you"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Stopped at the step limit.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "stopped",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"reason": "steps"
|
||||||
|
},
|
||||||
|
"now": 1762790400000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Stopped: step limit",
|
||||||
|
"line": "Stopped: step limit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Failed.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "failed",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"reason": "connection refused"
|
||||||
|
},
|
||||||
|
"now": 1762790405000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Failed: connection refused",
|
||||||
|
"line": "Failed: connection refused"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
{
|
||||||
|
"about": "What a request sends back of the model's reasoning (context.md §8, spec 3.2.0). `messages` is the history a request is built from, in order: `role` user, assistant or tool; `content`; an assistant message's stored `reasoning` (absent: it came with none); a user message's `turn` — false for a delivery at a step boundary (a job's note, a team message, words added to a running turn), which does not start a turn; absent or true for one that does (the person's, or a machine message starting a turn of its own). The answer is the messages as the OpenAI chat dialect sends them — `role`, `content`, and `reasoning_content` on an assistant message after the last user message that started a turn, when its reasoning is not empty (tool calls and ids are left out of the cases: they are sent as always). The Anthropic dialect sends the same messages' thinking blocks. LLeMbas: services/llm/openai_client.py, services/generation.py; CLI: provider/openai-chat.ts, session/engine.ts.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"about": "One turn, three steps: every reply of the turn carries its reasoning back.",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Fix the failing test."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"reasoning": "The test name points at the parser."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": "[#0001 src/parse.ts lines 1-40 of 40 sha 1c9e04ab]\n…"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"reasoning": "Line 12 drops the last token."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": "[#0002 src/parse.ts sha 7d0a91c3]\n@@ -12 +12 @@"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expect": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Fix the failing test."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"reasoning_content": "The test name points at the parser."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": "[#0001 src/parse.ts lines 1-40 of 40 sha 1c9e04ab]\n…"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"reasoning_content": "Line 12 drops the last token."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": "[#0002 src/parse.ts sha 7d0a91c3]\n@@ -12 +12 @@"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A second turn: the first turn's replies go without their reasoning, the current one's with it.",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Where is the config loaded?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"reasoning": "Search for load."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": "[#0001] src/config/load.ts:40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "In src/config/load.ts.",
|
||||||
|
"reasoning": "Found it at line 40."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "And who calls it?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"reasoning": "Grep for loadConfig."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": "[#0002] src/app.ts:120"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expect": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Where is the config loaded?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": "[#0001] src/config/load.ts:40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "In src/config/load.ts."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "And who calls it?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"reasoning_content": "Grep for loadConfig."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": "[#0002] src/app.ts:120"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A job's note delivered at a step boundary does not start a turn: the turn's earlier replies keep their reasoning.",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Run the tests in the background and fix the lint."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"reasoning": "Start the tests, then lint."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": "[#0001] job1 started"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "[A background job you started has finished:]\njob1 (npm test) exited with 0 after 5s.",
|
||||||
|
"turn": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"reasoning": "Tests pass; now the lint."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": "[#0002] 0 problems"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expect": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Run the tests in the background and fix the lint."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"reasoning_content": "Start the tests, then lint."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": "[#0001] job1 started"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "[A background job you started has finished:]\njob1 (npm test) exited with 0 after 5s."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"reasoning_content": "Tests pass; now the lint."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": "[#0002] 0 problems"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A machine message that starts a turn of its own (a team report to an idle lead) does: the replies before it go without.",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Split the work."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Started alice.",
|
||||||
|
"reasoning": "Add one member."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "[Team report from alice to lead:]\nDone.",
|
||||||
|
"turn": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"reasoning": "Review alice's change."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": "[#0001 src/a.ts lines 1-10 of 10 sha 52b6e3a0]\n…"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expect": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Split the work."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Started alice."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "[Team report from alice to lead:]\nDone."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"reasoning_content": "Review alice's change."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": "[#0001 src/a.ts lines 1-10 of 10 sha 52b6e3a0]\n…"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A reply that came with no reasoning, or an empty one, carries no field.",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hello"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"reasoning": "Greet."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": "[#0001] ok"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hi."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Again"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hi again.",
|
||||||
|
"reasoning": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expect": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hello"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": "[#0001] ok"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hi."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Again"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hi again."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The last message the person wrote starts the turn even when nothing has answered it yet: every earlier reply goes without.",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "One"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Done.",
|
||||||
|
"reasoning": "Easy."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Two"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expect": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "One"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Done."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Two"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
"about": "Spec 3.5.0 (acp.md *Scheduled work on a linked computer*): what a device makes of a scheduled turn. `accept`: the device's Accept scheduled work (remote.scheduled; false means remote work is off as well, the same refusal); `mode`: the schedule's (absent: none chosen); `max_mode`: remote.max_mode; `approval_wait_s`: the schedule's wait (absent: none). → `{mode, wait_s}` — the session's mode and how long an approval waits — or `{refused}` with the `data.reason` of the -32602 (`scheduled_off`, `plan`), whose message is loop.json scheduled.texts.off / .plan. Written from the rules; the device's other refusals (folder, trust) are the agent chat's.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"about": "Off by default: refused, whatever else is asked.",
|
||||||
|
"accept": false,
|
||||||
|
"mode": "edit",
|
||||||
|
"max_mode": "edit",
|
||||||
|
"expect": {
|
||||||
|
"refused": "scheduled_off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "No mode chosen: Edit.",
|
||||||
|
"accept": true,
|
||||||
|
"max_mode": "edit",
|
||||||
|
"expect": {
|
||||||
|
"mode": "edit",
|
||||||
|
"wait_s": 1800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "No mode chosen, the device allows Auto: still Edit — Auto only when the schedule says so.",
|
||||||
|
"accept": true,
|
||||||
|
"max_mode": "auto",
|
||||||
|
"expect": {
|
||||||
|
"mode": "edit",
|
||||||
|
"wait_s": 1800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Auto chosen and allowed.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "auto",
|
||||||
|
"max_mode": "auto",
|
||||||
|
"expect": {
|
||||||
|
"mode": "auto",
|
||||||
|
"wait_s": 1800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Auto chosen, the device allows Edit at most: lowered.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "auto",
|
||||||
|
"max_mode": "edit",
|
||||||
|
"expect": {
|
||||||
|
"mode": "edit",
|
||||||
|
"wait_s": 1800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Edit chosen, the device allows Manual at most: lowered.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "edit",
|
||||||
|
"max_mode": "manual",
|
||||||
|
"expect": {
|
||||||
|
"mode": "manual",
|
||||||
|
"wait_s": 1800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Manual chosen.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "manual",
|
||||||
|
"max_mode": "auto",
|
||||||
|
"expect": {
|
||||||
|
"mode": "manual",
|
||||||
|
"wait_s": 1800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Plan is refused: nobody is there to approve a plan.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "plan",
|
||||||
|
"max_mode": "auto",
|
||||||
|
"expect": {
|
||||||
|
"refused": "plan"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "An unknown mode is read as the default, never as anything wider.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "unrestricted",
|
||||||
|
"max_mode": "auto",
|
||||||
|
"expect": {
|
||||||
|
"mode": "edit",
|
||||||
|
"wait_s": 1800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The device allows Plan at most: every scheduled turn is lowered to it.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "edit",
|
||||||
|
"max_mode": "plan",
|
||||||
|
"expect": {
|
||||||
|
"mode": "plan",
|
||||||
|
"wait_s": 1800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The wait, in seconds, as the schedule says.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "edit",
|
||||||
|
"max_mode": "edit",
|
||||||
|
"approval_wait_s": 600,
|
||||||
|
"expect": {
|
||||||
|
"mode": "edit",
|
||||||
|
"wait_s": 600
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Below the floor: one minute.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "edit",
|
||||||
|
"max_mode": "edit",
|
||||||
|
"approval_wait_s": 5,
|
||||||
|
"expect": {
|
||||||
|
"mode": "edit",
|
||||||
|
"wait_s": 60
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Above the ceiling: a day.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "edit",
|
||||||
|
"max_mode": "edit",
|
||||||
|
"approval_wait_s": 999999,
|
||||||
|
"expect": {
|
||||||
|
"mode": "edit",
|
||||||
|
"wait_s": 86400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A fraction is cut to whole seconds.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "edit",
|
||||||
|
"max_mode": "edit",
|
||||||
|
"approval_wait_s": 90.7,
|
||||||
|
"expect": {
|
||||||
|
"mode": "edit",
|
||||||
|
"wait_s": 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"about": "Spec 3.5.0: whether a scheduled run's reply is `silent` — loop.json scheduled.sentinel on its first or last non-empty line, compared after trimming, any case, every trailing full stop forgiven — and the reply with every sentinel line taken out (`stripped`, the text a preview shows), trimmed. The server's rule for its own runs, and the device's for a scheduled turn.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"about": "The sentinel alone.",
|
||||||
|
"text": "[SILENT]",
|
||||||
|
"expect": {
|
||||||
|
"silent": true,
|
||||||
|
"stripped": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Lower case, with a full stop.",
|
||||||
|
"text": "[silent].",
|
||||||
|
"expect": {
|
||||||
|
"silent": true,
|
||||||
|
"stripped": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "On the last line, after a sentence.",
|
||||||
|
"text": "Nothing new since yesterday.\n[SILENT]",
|
||||||
|
"expect": {
|
||||||
|
"silent": true,
|
||||||
|
"stripped": "Nothing new since yesterday."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "On the first line, before a sentence.",
|
||||||
|
"text": "[SILENT]\nChecked the feed: nothing.",
|
||||||
|
"expect": {
|
||||||
|
"silent": true,
|
||||||
|
"stripped": "Checked the feed: nothing."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "In the middle: not silent (only the first or last line counts), and the line still goes from the preview.",
|
||||||
|
"text": "Two new posts.\n[SILENT]\nBoth about waybread.",
|
||||||
|
"expect": {
|
||||||
|
"silent": false,
|
||||||
|
"stripped": "Two new posts.\nBoth about waybread."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Inside a sentence: not silent.",
|
||||||
|
"text": "I would say [SILENT] but there is news: the build broke.",
|
||||||
|
"expect": {
|
||||||
|
"silent": false,
|
||||||
|
"stripped": "I would say [SILENT] but there is news: the build broke."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Blank lines around it are not lines.",
|
||||||
|
"text": "\n\n [SILENT] \n\n",
|
||||||
|
"expect": {
|
||||||
|
"silent": true,
|
||||||
|
"stripped": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A space before the full stop.",
|
||||||
|
"text": "All quiet.\n[SILENT] .",
|
||||||
|
"expect": {
|
||||||
|
"silent": true,
|
||||||
|
"stripped": "All quiet."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Without brackets: not the sentinel.",
|
||||||
|
"text": "SILENT",
|
||||||
|
"expect": {
|
||||||
|
"silent": false,
|
||||||
|
"stripped": "SILENT"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Empty: not silent.",
|
||||||
|
"text": "",
|
||||||
|
"expect": {
|
||||||
|
"silent": false,
|
||||||
|
"stripped": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "An ordinary reply.",
|
||||||
|
"text": "The backup finished at 03:12; 4.2 GB.",
|
||||||
|
"expect": {
|
||||||
|
"silent": false,
|
||||||
|
"stripped": "The backup finished at 03:12; 4.2 GB."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Two full stops are forgiven too (every trailing one is).",
|
||||||
|
"text": "[SILENT]..",
|
||||||
|
"expect": {
|
||||||
|
"silent": true,
|
||||||
|
"stripped": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+58
-6
@@ -1,6 +1,6 @@
|
|||||||
# Context hygiene
|
# Context hygiene
|
||||||
|
|
||||||
Spec 3.1.0. What a session does to keep tool results from filling the model's context, and (§10, from
|
Spec 3.2.0. What a session does to keep tool results from filling the model's context, and (§10, from
|
||||||
3.1.0) what it keeps still so the model's server can reuse what it has already read — the same in
|
3.1.0) what it keeps still so the model's server can reuse what it has already read — the same in
|
||||||
a chat and in an agent session, in both projects. The texts and numbers are `loop.json` `context`;
|
a chat and in an agent session, in both projects. The texts and numbers are `loop.json` `context`;
|
||||||
the cases are `conformance/context.json`; what the model is told is `prompts/blocks/context.md`.
|
the cases are `conformance/context.json`; what the model is told is `prompts/blocks/context.md`.
|
||||||
@@ -12,7 +12,8 @@ rewritten.** Changing earlier context invalidates the prompt cache from that poi
|
|||||||
server processes everything after it again, and a cloud API charges full input price for it again —
|
server processes everything after it again, and a cloud API charges full input price for it again —
|
||||||
on a long context that costs more than the measure saves. So no earlier result is ever shortened,
|
on a long context that costs more than the measure saves. So no earlier result is ever shortened,
|
||||||
replaced by a stub or removed afterwards. The one deliberate break is compaction, which already
|
replaced by a stub or removed afterwards. The one deliberate break is compaction, which already
|
||||||
replaces the history with a summary.
|
replaces the history with a summary. (From 3.2.0 a turn's reasoning is sent back during the turn and
|
||||||
|
not after it, §8: the common chat templates render the history the same either way.)
|
||||||
|
|
||||||
## 1. Every result has an id
|
## 1. Every result has an id
|
||||||
|
|
||||||
@@ -215,16 +216,67 @@ model): a date that changed at midnight sent a 16,912-token session from 99 % ca
|
|||||||
What a server does with a matching prefix is its own business; a harness that keeps these rules has
|
What a server does with a matching prefix is its own business; a harness that keeps these rules has
|
||||||
done what it can.
|
done what it can.
|
||||||
|
|
||||||
## 8. Earlier reasoning
|
## 8. Reasoning goes back to the model during its turn
|
||||||
|
|
||||||
Left to the model's own chat template: the harness sends the history as it does, and neither strips
|
From 3.2.0 (it was left to the chat template before). A model that thinks before each tool call
|
||||||
reasoning a template keeps nor adds what it drops.
|
works on from what it thought: sent its own reasoning back, it carries on; sent its calls without it,
|
||||||
|
it reasons its way to them again, step after step. Measured on a local model: a step re-read about a
|
||||||
|
fifth fewer tokens with its reasoning sent back.
|
||||||
|
|
||||||
|
- **The current turn** is every message after the last one that started a turn: a message the
|
||||||
|
person wrote, or a machine message that starts a turn of its own (a job's or a team's note to an
|
||||||
|
idle session, `loop.json` `jobs`). What is delivered at a step boundary while a turn runs — a
|
||||||
|
job's note, a team message, words the person adds — does not start a turn, and a tool result
|
||||||
|
never does.
|
||||||
|
- **Every assistant message of the current turn carries the reasoning it came with**, as the
|
||||||
|
model gave it, in full: in the OpenAI chat dialect as `reasoning_content` on the assistant message
|
||||||
|
(beside its `content` and `tool_calls`), and in the Anthropic dialect as its thinking blocks with
|
||||||
|
their signatures, unchanged and first (which that API already requires for tool use). Reasoning
|
||||||
|
read from inline tags (`<think>`; `quirks.json` `reasoning-fields`) goes back the same way, as
|
||||||
|
`reasoning_content`, and never as text in `content`. A message that came with no reasoning
|
||||||
|
carries none — no empty field.
|
||||||
|
- **Assistant messages of earlier turns carry none.** The common templates (Qwen3.x, Nemotron's
|
||||||
|
`truncate_history_thinking`, gpt-oss) drop it there themselves, so what the server reads of the
|
||||||
|
history is the same bytes either way and nothing is re-read; with a template that would keep it,
|
||||||
|
the turn boundary re-reads at most the previous turn. A compaction's summary starts the history
|
||||||
|
afresh: nothing before it carries reasoning.
|
||||||
|
- **What is stored is unchanged**: a session keeps each message's reasoning as before; this rule
|
||||||
|
is only about what a request carries (`conformance/reasoning.json`).
|
||||||
|
- A subagent, a team member and the main agent each have their own turns: a member's turn starts
|
||||||
|
with its brief or with a message that woke it.
|
||||||
|
|
||||||
|
## 11. No absolute paths in the prompt
|
||||||
|
|
||||||
|
From 3.2.0. An absolute path costs tokens on every request, says where a machine keeps its files,
|
||||||
|
and is copied by the model into calls where a relative one would do. So in an agent session (a
|
||||||
|
device's disk):
|
||||||
|
|
||||||
|
- **The system prompt, every note and the harness's own task messages (`tasks/init.md`) name no
|
||||||
|
absolute path of the machine.** The environment
|
||||||
|
block names the project by its root's last path component and says paths are relative to its
|
||||||
|
root (`loop.json` `context.env`): `Project: app (paths are relative to its root)`. Where the
|
||||||
|
session was started in a directory below the root, one more line names that directory relative
|
||||||
|
to the root: `Started in: packages/web (relative to the project root)`. There is no
|
||||||
|
`Working directory:` or `Project root:` line with an absolute path any more.
|
||||||
|
- **A file in the project is named relative to its root** (an instruction file, `docs/AGENTS.md`); a
|
||||||
|
file under the person's home directory outside the project as `~/…` (the global instructions,
|
||||||
|
`~/.config/lembas/AGENTS.md`). Only a file outside both is named by its absolute path.
|
||||||
|
- **Relative paths are resolved against the project root** — by every file tool (`read`, `write`,
|
||||||
|
`edit`, `multiedit`, `apply_patch`, `glob`, `grep`, `list`, `view_image`, `show`) and as the
|
||||||
|
directory `bash` starts in (its `cwd` argument, relative, likewise). Where the person started
|
||||||
|
below the root is a fact told to the model, not a second base; it still ends the instruction
|
||||||
|
chain (`filesystem.md`). **Absolute paths are still accepted** everywhere, as before, and answers
|
||||||
|
still name paths as `filesystem.md` says.
|
||||||
|
- A chat on the server is not a machine's disk: its paths are the server's tree's, short and the
|
||||||
|
same for every instance (`/home/<name>`, `blocks/workspace.md`). Its prompt names nothing longer.
|
||||||
|
|
||||||
|
`conformance/paths.json`: the environment lines, and how a path is resolved.
|
||||||
|
|
||||||
## 9. What the model is told
|
## 9. What the model is told
|
||||||
|
|
||||||
`prompts/blocks/context.md`, in every session that has `recall`.
|
`prompts/blocks/context.md`, in every session that has `recall`.
|
||||||
|
|
||||||
## Not in 3.1.0
|
## Not in 3.2.0
|
||||||
|
|
||||||
Eliding old content in batches behind a threshold, and measuring quality and tokens with and without
|
Eliding old content in batches behind a threshold, and measuring quality and tokens with and without
|
||||||
these measures, are left for a later spec. Every measure above ships with its unit and conformance
|
these measures, are left for a later spec. Every measure above ships with its unit and conformance
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ one access level for the whole working directory, decided by the CLI's permissio
|
|||||||
|
|
||||||
- **Absolute** paths begin with `/`, the root of the tree: the organisation's root on the server,
|
- **Absolute** paths begin with `/`, the root of the tree: the organisation's root on the server,
|
||||||
the file system's root on a device.
|
the file system's root on a device.
|
||||||
- **Relative** paths resolve against the session's **start directory** (its working directory).
|
- **Relative** paths resolve against the session's **start directory** (its working directory) —
|
||||||
|
on a device, from spec 3.2.0, against the project root (*Node kinds*, below).
|
||||||
- `.` and `..` are resolved by their text, before anything is looked up; `..` above `/` stays at
|
- `.` and `..` are resolved by their text, before anything is looked up; `..` above `/` stays at
|
||||||
`/`. Repeated `/` count as one, and a trailing `/` changes nothing. On the server there are no
|
`/`. Repeated `/` count as one, and a trailing `/` changes nothing. On the server there are no
|
||||||
links: a path is what it says. On a device, a link is followed, and where the CLI's rules look at
|
links: a path is what it says. On a device, a link is followed, and where the CLI's rules look at
|
||||||
@@ -42,7 +43,11 @@ one access level for the whole working directory, decided by the CLI's permissio
|
|||||||
|
|
||||||
A session has a **boundary** (what it may see at all: the root, a directory, one repository) and a
|
A session has a **boundary** (what it may see at all: the root, a directory, one repository) and a
|
||||||
**start directory** inside it. On a device the boundary is the project root (the directory the
|
**start directory** inside it. On a device the boundary is the project root (the directory the
|
||||||
CLI's trust is given to) and the start directory is where the session started.
|
CLI's trust is given to) and the start directory is where the session started. From spec 3.2.0 a
|
||||||
|
device resolves relative paths against the **project root**, not the start directory (`context.md`
|
||||||
|
§11): the model is told the project and, when it started below the root, that directory relative to
|
||||||
|
the root — one base for every relative path, whatever the directory. The start directory still
|
||||||
|
ends the instruction chain (below).
|
||||||
|
|
||||||
## Access levels
|
## Access levels
|
||||||
|
|
||||||
|
|||||||
+363
-3
@@ -34,7 +34,7 @@
|
|||||||
"about": "Calls in one step run together, at most 4; a call that waits on the user (ask_user, plan_submit) runs alone; two calls on one file run in order. A tool's error is its result, never the end of the prompt.",
|
"about": "Calls in one step run together, at most 4; a call that waits on the user (ask_user, plan_submit) runs alone; two calls on one file run in order. A tool's error is its result, never the end of the prompt.",
|
||||||
"max": 4
|
"max": 4
|
||||||
},
|
},
|
||||||
"capacity": "A subagent (task) is not started on a model whose server cannot hold it beside the prompt's own model — or beside the model of a subagent already running: another model of a connection that holds one model at a time, or the same model when it serves one request at a time (models/schema.json capacity).",
|
"capacity": "A subagent (task) is not started on a model whose server cannot hold it beside the prompt's own model — or beside the model of a subagent already running: another model of a connection that holds one model at a time, or the same model when it already serves as many of this session's requests as it can at once (models/schema.json capacity: `parallel`, `single_session` being parallel 1). Spec 3.3.0: which model a subagent may run on at all, and the refusals for the session's own one-model server and a one-request model, are subagents.md and `subagents` below, checked first. A team's members are not refused for a full model: they wait for a slot (team.md, Capacity).",
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"about": "Background work owned by a session: a command (bash `background: true`, or one still running when its timeout passed) or a subagent (task `background: true`). One id sequence per session (job1, job2, …), one list (job_list), one way to read (job_output), to stop (job_stop) and, for a subagent, to message (job_send); a subagent's own tools see only the jobs it started. A job that ends wakes the session that owns it: while a prompt runs, as a user message at the next step boundary; otherwise as a new turn of its own, marked `machine` so a UI shows it as the harness's, not the person's. A session that is not open keeps the note until it is resumed. Every such message begins with its mark, said rather than hidden: a model refuses an unmarked one as an injection (conformance/jobs.json gives the texts).",
|
"about": "Background work owned by a session: a command (bash `background: true`, or one still running when its timeout passed) or a subagent (task `background: true`). One id sequence per session (job1, job2, …), one list (job_list), one way to read (job_output), to stop (job_stop) and, for a subagent, to message (job_send); a subagent's own tools see only the jobs it started. A job that ends wakes the session that owns it: while a prompt runs, as a user message at the next step boundary; otherwise as a new turn of its own, marked `machine` so a UI shows it as the harness's, not the person's. A session that is not open keeps the note until it is resumed. Every such message begins with its mark, said rather than hidden: a model refuses an unmarked one as an injection (conformance/jobs.json gives the texts).",
|
||||||
"marks": {
|
"marks": {
|
||||||
@@ -61,6 +61,209 @@
|
|||||||
},
|
},
|
||||||
"lifecycle": "A subagent's own background commands stop when it ends, and its report says so. A session's jobs stop when the session is deleted. A front end asked to exit while jobs run asks the person first; one with nobody to ask waits for the jobs the prompt started, then stops the rest."
|
"lifecycle": "A subagent's own background commands stop when it ends, and its report says so. A session's jobs stop when the session is deleted. A front end asked to exit while jobs run asks the person first; one with nobody to ask waits for the jobs the prompt started, then stops the rest."
|
||||||
},
|
},
|
||||||
|
"subagents": {
|
||||||
|
"about": "Spec 3.3.0 (subagents.md): which model a subagent — a task subagent or a team member — runs on, and when subagents are offered. The session's own model unless the person named another in this session; no setting chooses one. The texts are said word for word, checked in this order: not_named, same_group, one_request. conformance/subagents.json.",
|
||||||
|
"texts": {
|
||||||
|
"not_named": "Use another model for subagents only when the person names it.",
|
||||||
|
"same_group": "That model is on the same one-model server as this chat's model; name a model on another server.",
|
||||||
|
"one_request": "This model serves one request at a time: name another model, on another server, for subagents."
|
||||||
|
},
|
||||||
|
"named": {
|
||||||
|
"about": "A model is named when its served id, its id at its connection or its display name occurs as a whole word, without case, in a message the person typed in this session (never model text, tool output, files, notes, marked messages or reports; a compacted message still counts). Before the name: not a letter, digit, _, -, /, . or :. After it: not a letter, digit, _, - or /, nor a . or : followed by a letter or digit. White space in a display name matches any run of white space.",
|
||||||
|
"before": "[\\p{L}\\p{N}_\\-/.:]",
|
||||||
|
"after": "[\\p{L}\\p{N}_\\-/]|[.:][\\p{L}\\p{N}]"
|
||||||
|
},
|
||||||
|
"setting": {
|
||||||
|
"about": "How the agents {min, max} setting (team.md §2) is shown to a person: one range from 1 to the cap with two handles, `min` and `max` naming them; `label` beside it, `hint` (or `short` where one line has no room) saying what the numbers count — the session's own agent included, so 1 is that agent alone and Max 4 is it and up to 3 subagents; and `own` beside it when the session's model cannot serve subagents itself (subagents.md §4).",
|
||||||
|
"label": "Subagents",
|
||||||
|
"min": "Min",
|
||||||
|
"max": "Max",
|
||||||
|
"hint": "The count includes this agent: 1 is this agent alone, with no subagents; Max 4 is this agent and up to 3 subagents.",
|
||||||
|
"short": "1 is this agent alone",
|
||||||
|
"own": "This model serves one request at a time: subagents need another model, on another server, named in the chat."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"team": {
|
||||||
|
"about": "Teams (spec 3.2.0, team.md; which model a member runs on and when a team is offered: spec 3.3.0, subagents.md; the main agent, the tree, permissions and a subagent's life: spec 3.4.0, subagents.md §5–§13). The texts are said word for word; {{size}} is the team's size after the call (team.md §3), {{names}} names joined with \", \" in tree order. conformance/team.json, conformance/delegation.json.",
|
||||||
|
"agents": {
|
||||||
|
"about": "The chat setting agents {min, max}: the agents a team may have, the lead included. 1 ≤ min ≤ max ≤ cap. min and max are a new chat's defaults; cap is the instance's (LLeMbas) or the global configuration's (the CLI).",
|
||||||
|
"min": 1,
|
||||||
|
"max": 4,
|
||||||
|
"cap": 32
|
||||||
|
},
|
||||||
|
"names": {
|
||||||
|
"about": "A subagent's name: unique among the subagents that have not ended; `lead` is the main agent's, `all` addresses everyone, `parent` (3.4.0) the sender's parent.",
|
||||||
|
"pattern": "^[a-z][a-z0-9-]{0,31}$",
|
||||||
|
"lead": "lead",
|
||||||
|
"reserved": [
|
||||||
|
"all",
|
||||||
|
"lead",
|
||||||
|
"parent"
|
||||||
|
],
|
||||||
|
"parent": "parent",
|
||||||
|
"id_prefix": "s"
|
||||||
|
},
|
||||||
|
"marks": {
|
||||||
|
"message": "[Team message from {{from}} to {{to}}:]",
|
||||||
|
"report": "[Team report from {{from}} to {{to}}:]",
|
||||||
|
"assignment": "[Team assignment from {{from}} to {{to}}:]",
|
||||||
|
"request": "[Team request {{id}} from {{from}} to {{to}}:]",
|
||||||
|
"decision": "[Team decision on {{id}} from {{from}} to {{to}}:]",
|
||||||
|
"context_full": "[Team notice to {{to}}: your context is full]"
|
||||||
|
},
|
||||||
|
"unavailable": {
|
||||||
|
"about": "What a team tool answers while its requirement `team` is not met (team.md §2). one_agent is also each team tool's own `unavailable`, and what task answers while a team is offered, max is 1 and the lead has no member. one_request: no model can serve as a subagent any more (subagents.md §4) — the session's model's capacity changed after the prompt was made.",
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"work": {
|
||||||
|
"about": "What a manager may not do itself (team.md §6): every call whose risk class is write or execute — an MCP tool's is read when it says readOnlyHint, else execute — but `job_stop`, and a `bash` call that the default rules alone allow in manual mode (reading commands). The tool list is unchanged; these answer texts.manager.",
|
||||||
|
"write": [
|
||||||
|
"apply_patch",
|
||||||
|
"edit",
|
||||||
|
"mkdir",
|
||||||
|
"multiedit",
|
||||||
|
"mv",
|
||||||
|
"rm",
|
||||||
|
"skill_manage",
|
||||||
|
"write"
|
||||||
|
],
|
||||||
|
"execute": [
|
||||||
|
"bash",
|
||||||
|
"branch",
|
||||||
|
"commit",
|
||||||
|
"merge",
|
||||||
|
"pr",
|
||||||
|
"python",
|
||||||
|
"release",
|
||||||
|
"tag"
|
||||||
|
],
|
||||||
|
"kept": [
|
||||||
|
"job_stop"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"read_max": 50,
|
||||||
|
"texts": {
|
||||||
|
"added": "Added {{name}} ({{role}}): it is working. Its report comes to you when it finishes. The team is {{size}} of {{max}} agents.",
|
||||||
|
"added_waiting": "Added {{name}} ({{role}}): it waits for a free slot — the model serves {{parallel}} requests at once and all are busy — and starts when one frees. Its report comes to you when it finishes. The team is {{size}} of {{max}} agents.",
|
||||||
|
"added_under": "Added {{name}} ({{role}}) under {{parent}}: it is working. Its report goes to {{parent}} when it finishes. The team is {{size}} of {{max}} agents.",
|
||||||
|
"added_waiting_under": "Added {{name}} ({{role}}) under {{parent}}: it waits for a free slot — the model serves {{parallel}} requests at once and all are busy — and starts when one frees. Its report goes to {{parent}} when it finishes. The team is {{size}} of {{max}} agents.",
|
||||||
|
"sent": "Sent to {{to}}.",
|
||||||
|
"stopped": "Stopped {{names}}. The team is {{size}} of {{max}} agents.",
|
||||||
|
"manager": "You lead a team: give this to a member (team_add or team_send).",
|
||||||
|
"full": "The team is full: it has as many agents as the person allows ({{max}}). Give the work to a member already on it (team_send), or stop one (team_stop) first.",
|
||||||
|
"bad_name": "{{name}} cannot be a name: use lowercase letters, digits and hyphens, beginning with a letter, at most 32 characters.",
|
||||||
|
"all_name": "all addresses the whole team; it cannot be a name.",
|
||||||
|
"taken": "There is already an agent called {{name}} in this team. Choose another name.",
|
||||||
|
"no_goal": "Say the team's goal in `goal`, in one line, with your first member: every member is told it.",
|
||||||
|
"unknown": "No agent called {{name}} in this team. The team: {{names}}.",
|
||||||
|
"self": "That is you: send it to someone else.",
|
||||||
|
"nobody": "Nobody else is on the team yet: add a member with team_add.",
|
||||||
|
"not_yours": "{{name}} is not under you: you can stop only the members you lead, directly or through your members.",
|
||||||
|
"nobody_to_stop": "No member is under you: there is nobody to stop.",
|
||||||
|
"no_report": "(no report)",
|
||||||
|
"ended": "[ended: {{reason}}]",
|
||||||
|
"only_main": "Only the main agent adds, assigns, replaces and stops subagents: ask it (team_send to lead).",
|
||||||
|
"not_manager": "{{name}} is not a sub-manager: place a subagent under the main agent (lead) or under one added with lead: true.",
|
||||||
|
"wider": "A subagent's mode cannot be looser than its parent's ({{parent_mode}}).",
|
||||||
|
"request_not_narrower": "Request options go with a mode stricter than the parent's: without one there is nothing to ask for.",
|
||||||
|
"request_kind": "{{kind}} is not a request option: use read, write or execute.",
|
||||||
|
"requestable": "Your mode does not allow this, but the main agent may: ask for it with team_request (the tool, the pattern, why), then try again once it allows.",
|
||||||
|
"not_delegated": "Your mode does not allow this, and you may not ask for it: tell your parent what you need.",
|
||||||
|
"not_requestable": "You may not ask for {{tool}}: your request options are {{kinds}}.",
|
||||||
|
"requested": "Asked the main agent ({{id}}): you are told its decision. Carry on with what you can meanwhile.",
|
||||||
|
"no_request": "No open request {{id}}.",
|
||||||
|
"decided": "{{decision}} {{id}} for {{name}}.",
|
||||||
|
"allowed": "Allowed",
|
||||||
|
"refused": "Refused",
|
||||||
|
"decision_allowed": "Allowed: {{tool}} {{pattern}}. Try it again now.",
|
||||||
|
"decision_refused": "Refused: {{tool}} {{pattern}}.",
|
||||||
|
"no_parent": "You are the main agent: you have no parent.",
|
||||||
|
"finished": "Reported to {{to}}. Your task is done: stop here.",
|
||||||
|
"has_ended": "{{name}} has ended: replace it (team_replace) or add another.",
|
||||||
|
"cycle": "{{name}} cannot go under {{parent}}: that is under it.",
|
||||||
|
"assigned": "Assigned to {{name}}: it is told now.",
|
||||||
|
"replaced": "Replaced {{name}}: {{id}} takes its place, briefed with its task and findings. The team is {{size}} of {{max}} agents.",
|
||||||
|
"replace_brief": "{{task}}\n\nYou take over from {{name}} ({{id}}), which ended. What it found:\n{{findings}}",
|
||||||
|
"no_findings": "(no findings)",
|
||||||
|
"noted": "Noted (finding {{n}}).",
|
||||||
|
"not_yours_findings": "You may read only your own findings and those of the subagents under you.",
|
||||||
|
"context_full": "Save what you found with team_findings, then tell your parent with team_send that you are gone and what is left undone. This is your last turn: at most {{steps}} steps.",
|
||||||
|
"gone": "{{name}} ({{id}}) ran out of context and has ended. Its findings: team_findings with of {{name}}.",
|
||||||
|
"request_body": "{{tool}} {{pattern}} — {{why}}"
|
||||||
|
},
|
||||||
|
"read": {
|
||||||
|
"about": "team_read's answer: the tree, then the room. Agents in tree order (an agent, then its members in the order they were added, depth first), indented two spaces per level below the lead. `last`: `active now` while working, else how long ago its last turn ended (durations as jobs write them: \"5s ago\", \"1m 05s ago\"). A message's text: its first line after the colon, every further line indented two spaces. Messages above `since` the caller may see, oldest first, at most read_max; `more` when there are others after the last shown. Spec 3.4.0: the row adds the id, the mode (with request options) and the context use; open slots follow the tree, ended subagents after them.",
|
||||||
|
"tree": "Team ({{size}} of {{max}} agents):",
|
||||||
|
"agent": "{{indent}}- {{name}} ({{id}}) — {{role}} · {{state}} · {{model}} · {{mode}} · {{context}} · {{last}}",
|
||||||
|
"lead_role": "the main agent",
|
||||||
|
"states": {
|
||||||
|
"working": "working",
|
||||||
|
"waiting": "waiting for a slot",
|
||||||
|
"finished": "finished",
|
||||||
|
"idle": "idle",
|
||||||
|
"paused": "paused: context full",
|
||||||
|
"done": "done",
|
||||||
|
"out_of_context": "out of context",
|
||||||
|
"stopped": "stopped"
|
||||||
|
},
|
||||||
|
"active": "active now",
|
||||||
|
"room": "Room since #{{since}}:",
|
||||||
|
"message": "#{{n}} {{from}} → {{to}}: {{text}}",
|
||||||
|
"report": "#{{n}} {{from}} → {{to}} (report): {{text}}",
|
||||||
|
"none": "No messages since #{{since}}.",
|
||||||
|
"more": "More after #{{last}}: team_read with since {{last}}.",
|
||||||
|
"slots": "Open slots: {{n}} — fill them with team_add.",
|
||||||
|
"ended": "Ended:",
|
||||||
|
"ended_agent": "- {{name}} ({{id}}) — {{role}} · {{state}} · {{findings}} findings",
|
||||||
|
"context": "{{percent}}% context",
|
||||||
|
"request": "+ {{kinds}}"
|
||||||
|
},
|
||||||
|
"main_only": {
|
||||||
|
"about": "Spec 3.4.0 (subagents.md §6, §8): the tools only the main agent has; a subagent's call to one, or to task, answers texts.only_main.",
|
||||||
|
"tools": [
|
||||||
|
"team_add",
|
||||||
|
"team_assign",
|
||||||
|
"team_decide",
|
||||||
|
"team_replace",
|
||||||
|
"team_stop"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"about": "Spec 3.4.0 (subagents.md §9): a subagent does not compact. When its context reaches pause_at of its model's window it is paused and given one final turn of at most final_steps steps, whose user message is the context_full mark and texts.context_full.",
|
||||||
|
"pause_at": 0.85,
|
||||||
|
"final_steps": 8
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"about": "Spec 3.4.0 (subagents.md §7): the kinds a request option may name — risk classes — and the ids of requests, r1, r2, … per session.",
|
||||||
|
"kinds": [
|
||||||
|
"read",
|
||||||
|
"write",
|
||||||
|
"execute"
|
||||||
|
],
|
||||||
|
"id_prefix": "r"
|
||||||
|
},
|
||||||
|
"whoami": {
|
||||||
|
"about": "Spec 3.4.0 (subagents.md §8): team_whoami's answer, one line each; `request` only with request options, `parent` only for a subagent. {{percent}} is floor(used × 100 / window).",
|
||||||
|
"lines": {
|
||||||
|
"who": "You are {{name}} ({{id}}): {{role}}.",
|
||||||
|
"parent": "Your parent: {{parent}}.",
|
||||||
|
"task": "Your task: {{task}}",
|
||||||
|
"mode": "Your mode: {{mode}}.",
|
||||||
|
"request": "You may ask the main agent for: {{kinds}} (team_request).",
|
||||||
|
"model": "Your model: {{model}}.",
|
||||||
|
"limits": "Limits: the team is {{size}} of {{max}} agents; at most {{steps}} steps a turn; {{spawn}}",
|
||||||
|
"spawn_yes": "you add subagents.",
|
||||||
|
"spawn_no": "only the main agent adds subagents.",
|
||||||
|
"context": "Context: {{used}} of {{window}} tokens ({{percent}}%)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"findings": {
|
||||||
|
"about": "Spec 3.4.0: team_findings read's answer — the header, then one line per finding, oldest first; none: texts.no_findings after the header.",
|
||||||
|
"header": "Findings of {{name}} ({{id}}):",
|
||||||
|
"item": "{{n}}. {{text}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"context": {
|
"context": {
|
||||||
"about": "Context hygiene (spec 3.0.0, the prefix from 3.1.0; context.md): every measure acts when a result is inserted, and nothing already sent to the model is ever rewritten — changing earlier context throws away the prompt cache from that point. conformance/context.json gives the texts.",
|
"about": "Context hygiene (spec 3.0.0, the prefix from 3.1.0; context.md): every measure acts when a result is inserted, and nothing already sent to the model is ever rewritten — changing earlier context throws away the prompt cache from that point. conformance/context.json gives the texts.",
|
||||||
"ids": {
|
"ids": {
|
||||||
@@ -105,9 +308,14 @@
|
|||||||
"line": "Read a file again for its content; recall shows a result by its id.",
|
"line": "Read a file again for its content; recall shows a result by its id.",
|
||||||
"rows": 50
|
"rows": 50
|
||||||
},
|
},
|
||||||
"reasoning": "Earlier turns' reasoning is left to the model's chat template: the harness sends the history as it does, neither stripping reasoning the template keeps nor adding what it drops.",
|
"reasoning": "Spec 3.2.0 (context.md §8): every assistant message of the current turn — the messages after the last one that started a turn (the person's, or a machine message that starts a turn of its own; a delivery at a step boundary does not) — carries the reasoning it came with (OpenAI chat: `reasoning_content`; Anthropic: its thinking blocks, unchanged); assistant messages of earlier turns carry none. What is stored keeps it. conformance/reasoning.json.",
|
||||||
|
"env": {
|
||||||
|
"about": "Spec 3.2.0 (context.md §11): the environment block's lines about where the session works, in an agent session. `project` always, `name` being the project root's last path component; `started` only when the session started in a directory below the root, `path` being that directory relative to the root. No absolute path. conformance/paths.json.",
|
||||||
|
"project": "Project: {{name}} (paths are relative to its root)",
|
||||||
|
"started": "Started in: {{path}} (relative to the project root)"
|
||||||
|
},
|
||||||
"note": {
|
"note": {
|
||||||
"about": "A note on the next user message (context.md §10, rule 3): what changed since the system prompt was made, after the person's words. Lines in this order, each only when it applies. `plan_ended` is the mode block for a session that leaves plan mode for a mode with no text of its own (a chat). `plan_own` is the plan block for a session that does not resend earlier tool results (a chat): the model's own plan, as its tools left it. `personal` is the {{path}} of the instructions block for the person's own instructions.",
|
"about": "A note on the next user message (context.md §10, rule 3): what changed since the system prompt was made, after the person's words. Lines in this order, each only when it applies. `plan_ended` is the mode block for a session that leaves plan mode for a mode with no text of its own (a chat). `plan_own` is the plan block for a session that does not resend earlier tool results (a chat): the model's own plan, as its tools left it. `personal` is the {{path}} of the instructions block for the person's own instructions. Spec 3.2.0, where a team is offered (team.md §10): `team` — the size setting, on the first user message and after it changes, followed in the same block by `team_lead` when min ≥ 2 ({{members}} = min − 1), or `team_one` in its place when max is 1; `team_stopped` — members the person stopped, a block of its own. Spec 3.3.0 (subagents.md §4): `team_own` after `team` (and after `team_lead`) in the same block when the session's model cannot serve subagents itself — it serves one request at a time — and another server's model could.",
|
||||||
"open": "<session-note>",
|
"open": "<session-note>",
|
||||||
"close": "</session-note>",
|
"close": "</session-note>",
|
||||||
"date": "Today is {{date}}.",
|
"date": "Today is {{date}}.",
|
||||||
@@ -120,8 +328,160 @@
|
|||||||
"personal": "the person's own instructions",
|
"personal": "the person's own instructions",
|
||||||
"personality": "The person chose a different personality:\n{{personality}}",
|
"personality": "The person chose a different personality:\n{{personality}}",
|
||||||
"mcp": "Instructions from an MCP server that connected since the session began:\n{{instructions}}",
|
"mcp": "Instructions from an MCP server that connected since the session began:\n{{instructions}}",
|
||||||
|
"team": "The team may have {{min}} to {{max}} agents, you included.",
|
||||||
|
"team_lead": "Work through subagents from the start: you lead, and they do the work. The person opened {{members}} slots for them: fill them with team_add.",
|
||||||
|
"team_one": "The person allows one agent: work alone; the team tools and task are unavailable.",
|
||||||
|
"team_own": "This model serves one request at a time, so subagents cannot run on it: they need another model, on another server, that the person names in this chat.",
|
||||||
|
"team_stopped": "The person stopped {{names}}; they have left the team.",
|
||||||
"empty": "(none)"
|
"empty": "(none)"
|
||||||
},
|
},
|
||||||
"mcp_wait_ms": 3000
|
"mcp_wait_ms": 3000
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"about": "Spec 3.4.0 (progress.md): one fixed set of phases every client shows the same way. `texts` are the shared i18n keys progress.<phase> (a variant: progress.<phase>.<variant>); `line` is drawn for every phase but stopped and failed; elapsed in whole seconds (5s, 1m 05s, 1h 02m). conformance/progress.json.",
|
||||||
|
"phases": [
|
||||||
|
"queued",
|
||||||
|
"waiting_for_model",
|
||||||
|
"loading_model",
|
||||||
|
"reading_prompt",
|
||||||
|
"thinking",
|
||||||
|
"writing",
|
||||||
|
"running_tool",
|
||||||
|
"waiting_for_person",
|
||||||
|
"waiting_for_subagents",
|
||||||
|
"compacting",
|
||||||
|
"retrying",
|
||||||
|
"stopped",
|
||||||
|
"failed"
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"queued": {
|
||||||
|
"ahead": "int?"
|
||||||
|
},
|
||||||
|
"waiting_for_model": {
|
||||||
|
"model": "string",
|
||||||
|
"reason": "busy|slots|group"
|
||||||
|
},
|
||||||
|
"loading_model": {
|
||||||
|
"model": "string"
|
||||||
|
},
|
||||||
|
"reading_prompt": {
|
||||||
|
"done": "int?",
|
||||||
|
"total": "int?"
|
||||||
|
},
|
||||||
|
"thinking": {},
|
||||||
|
"writing": {
|
||||||
|
"rate": "number?"
|
||||||
|
},
|
||||||
|
"running_tool": {
|
||||||
|
"tool": "string",
|
||||||
|
"what": "string?"
|
||||||
|
},
|
||||||
|
"waiting_for_person": {
|
||||||
|
"what": "approval|question|plan",
|
||||||
|
"tool": "string?"
|
||||||
|
},
|
||||||
|
"waiting_for_subagents": {
|
||||||
|
"running": "int"
|
||||||
|
},
|
||||||
|
"compacting": {},
|
||||||
|
"retrying": {
|
||||||
|
"reason": "string",
|
||||||
|
"in": "int",
|
||||||
|
"attempt": "int?"
|
||||||
|
},
|
||||||
|
"stopped": {
|
||||||
|
"reason": "person|steps|budget?"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"reason": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"texts": {
|
||||||
|
"queued": "Queued",
|
||||||
|
"queued.ahead": "Queued: {{ahead}} ahead",
|
||||||
|
"waiting_for_model": "Waiting for {{model}}: {{reason}}",
|
||||||
|
"loading_model": "Loading {{model}}",
|
||||||
|
"reading_prompt": "Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)",
|
||||||
|
"reading_prompt.bare": "Reading the prompt",
|
||||||
|
"thinking": "Thinking",
|
||||||
|
"writing": "Writing: {{rate}} tok/s",
|
||||||
|
"writing.bare": "Writing",
|
||||||
|
"running_tool": "Running {{tool}}: {{what}}",
|
||||||
|
"running_tool.bare": "Running {{tool}}",
|
||||||
|
"waiting_for_person": "Waiting for you: {{what}}",
|
||||||
|
"waiting_for_subagents": "Waiting for subagents: {{running}} running",
|
||||||
|
"compacting": "Compacting the conversation",
|
||||||
|
"retrying": "Retrying in {{in}} s: {{reason}}",
|
||||||
|
"stopped": "Stopped",
|
||||||
|
"stopped.reason": "Stopped: {{reason}}",
|
||||||
|
"failed": "Failed: {{reason}}"
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"busy": "it serves one request at a time and is answering another",
|
||||||
|
"slots": "all its slots are in use",
|
||||||
|
"group": "its server holds another model now"
|
||||||
|
},
|
||||||
|
"what": {
|
||||||
|
"approval": "allow {{tool}}?",
|
||||||
|
"approval.bare": "an approval",
|
||||||
|
"question": "a question",
|
||||||
|
"plan": "the plan"
|
||||||
|
},
|
||||||
|
"stopped_reason": {
|
||||||
|
"person": "by you",
|
||||||
|
"steps": "step limit",
|
||||||
|
"budget": "budget"
|
||||||
|
},
|
||||||
|
"line": "{{text}} · {{elapsed}}",
|
||||||
|
"no_elapsed": [
|
||||||
|
"stopped",
|
||||||
|
"failed"
|
||||||
|
],
|
||||||
|
"what_max": 80,
|
||||||
|
"pace_per_second": 4
|
||||||
|
},
|
||||||
|
"scheduled": {
|
||||||
|
"about": "Spec 3.5.0 (acp.md *Scheduled work on a linked computer*): a schedule's run as an agent turn on a linked computer, and the watch command of monitor mode. `setting` is how the device's switch is shown; `modes` the modes a schedule may choose (`default_mode` when it chose none), lowered to remote.max_mode; `approval_wait_seconds` how long an approval in a scheduled turn waits (the schedule's wait, clamped); `sentinel` the word a reply says when there is nothing to tell; `monitor` the watch command's limits. conformance/scheduled.json, silent.json, monitor.json.",
|
||||||
|
"setting": {
|
||||||
|
"key": "remote.scheduled",
|
||||||
|
"label": "Accept scheduled work",
|
||||||
|
"hint": "Let your schedules run here as agent turns, in the folders remote work may use. Off by default."
|
||||||
|
},
|
||||||
|
"modes": [
|
||||||
|
"manual",
|
||||||
|
"edit",
|
||||||
|
"auto"
|
||||||
|
],
|
||||||
|
"default_mode": "edit",
|
||||||
|
"approval_wait_seconds": {
|
||||||
|
"default": 1800,
|
||||||
|
"min": 60,
|
||||||
|
"max": 86400
|
||||||
|
},
|
||||||
|
"sentinel": "[SILENT]",
|
||||||
|
"statuses": [
|
||||||
|
"ok",
|
||||||
|
"silent",
|
||||||
|
"failed",
|
||||||
|
"cancelled"
|
||||||
|
],
|
||||||
|
"monitor": {
|
||||||
|
"timeout_seconds": {
|
||||||
|
"default": 60,
|
||||||
|
"min": 1,
|
||||||
|
"max": 600
|
||||||
|
},
|
||||||
|
"excerpt_chars": 2000,
|
||||||
|
"output_max_bytes": 1048576
|
||||||
|
},
|
||||||
|
"texts": {
|
||||||
|
"off": "This computer does not accept scheduled work. Turn on \"Accept scheduled work\" on it (lembas service scheduled on).",
|
||||||
|
"plan": "A scheduled run cannot use plan mode: nobody is there to approve a plan. Choose Manual, Edit or Auto.",
|
||||||
|
"denied": "Nobody answered this approval within {{minutes}} minutes, so it counts as denied.",
|
||||||
|
"monitor_asks": "The watch command would need approval in {{mode}} mode ({{reason}}), and a watch has nobody to ask. Allow the command in the project's permission rules, or choose a wider mode for the schedule.",
|
||||||
|
"monitor_denied": "The watch command is refused here: {{reason}}.",
|
||||||
|
"no_reply": "The run ended without a reply."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,22 +12,28 @@
|
|||||||
"max_output": { "type": "integer", "minimum": 1, "description": "The most tokens one reply may have." },
|
"max_output": { "type": "integer", "minimum": 1, "description": "The most tokens one reply may have." },
|
||||||
"temperature": { "type": "number", "minimum": 0, "maximum": 2, "description": "The sampling temperature the model is set up with. LLeMbas: the administrator's, which it also applies to a request that does not set one." },
|
"temperature": { "type": "number", "minimum": 0, "maximum": 2, "description": "The sampling temperature the model is set up with. LLeMbas: the administrator's, which it also applies to a request that does not set one." },
|
||||||
"top_p": { "type": "number", "minimum": 0, "maximum": 1, "description": "Nucleus sampling, as temperature." },
|
"top_p": { "type": "number", "minimum": 0, "maximum": 1, "description": "Nucleus sampling, as temperature." },
|
||||||
"efforts": { "type": "array", "items": { "enum": ["minimal", "low", "medium", "high", "xhigh", "max"] }, "description": "The reasoning efforts the model takes, in offering order. Narrowed when a server refuses one (quirks.json effort-refused)." },
|
"efforts": { "type": "array", "items": { "enum": ["minimal", "low", "medium", "high", "xhigh", "max"] }, "description": "The reasoning efforts the model takes, in offering order. Narrowed when a server refuses one (quirks.json effort-refused). Absent: each project's fallback (the CLI's: low, medium, high); [] — the model takes none, and its one effort level is `default`." },
|
||||||
"effort": { "enum": ["minimal", "low", "medium", "high", "xhigh", "max", "off"], "description": "The effort a new conversation starts with; off sends none." },
|
"thinking_switch": { "type": "boolean", "description": "Spec 3.2.0: the model's server turns thinking off for a request that sends `chat_template_kwargs.enable_thinking: false` (Qwen3.x and Nemotron 3.x templates on llama.cpp, vLLM, SGLang). Only then is the level `off` offered. Absent: false." },
|
||||||
|
"effort": { "enum": ["minimal", "low", "medium", "high", "xhigh", "max", "off", "default"], "description": "The level a new conversation starts with, when it is one of the levels the model offers (see `levels`); otherwise `medium` when the model takes it, else its first effort, else `default`. `off` — thinking switched off — exists only with thinking_switch; `default` — no effort sent, the model's own — only for a model that takes no efforts." },
|
||||||
"effort_style": { "enum": ["top", "kwargs", "both"], "description": "OpenAI Chat dialect: where the effort goes — top-level `reasoning_effort`, `chat_template_kwargs.reasoning_effort` (all llama.cpp reads), or both (the default)." },
|
"effort_style": { "enum": ["top", "kwargs", "both"], "description": "OpenAI Chat dialect: where the effort goes — top-level `reasoning_effort`, `chat_template_kwargs.reasoning_effort` (all llama.cpp reads), or both (the default)." },
|
||||||
"vision": { "type": "boolean", "description": "Takes images. Never sent images otherwise: most servers refuse the whole request." },
|
"vision": { "type": "boolean", "description": "Takes images. Never sent images otherwise: most servers refuse the whole request." },
|
||||||
"tools": { "type": "boolean", "description": "Takes tool calls. false: it can only talk; never sent a tools array." },
|
"tools": { "type": "boolean", "description": "Takes tool calls. false: it can only talk; never sent a tools array." },
|
||||||
"notes": { "type": "string", "maxLength": 300, "description": "What the model is good at, in a line: other models are told about it in the roster, for handing work over." },
|
"notes": { "type": "string", "maxLength": 300, "description": "What the model is good at, in a line: other models are told about it in the roster, for handing work over." },
|
||||||
"capacity": {
|
"capacity": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "How much the model's server can do at once, so a helper or a subagent on it is not started where it would evict or wait for the model already working.",
|
"description": "How much the model's server can do at once, so a subagent on it is not started where it would evict or wait for the model already working (subagents.md §3).",
|
||||||
"properties": {
|
"properties": {
|
||||||
"group": { "type": "string", "description": "Models that share one server holding one model at a time (llama-swap): starting another model of the group unloads this one. The CLI: a model's `group`, or its connection when the connection sets one_model_at_a_time. LLeMbas: the connection's name when it sets one_model_at_a_time, which is what /v1/models says." },
|
"group": { "type": "string", "description": "Models that share one server holding one model at a time (llama-swap): starting another model of the group unloads this one, so a subagent on another model of the session's model's group is refused (spec 3.3.0, subagents.md §3). The CLI: a model's `group`, or its connection when the connection sets one_model_at_a_time. LLeMbas: the connection's name when it sets one_model_at_a_time, which is what /v1/models says." },
|
||||||
"single_session": { "type": "boolean", "description": "The model serves one request at a time: a second one waits behind the first." }
|
"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. The session's model is its own subagents' model only when it serves at least 2; a one-request session needs a named model on another server (spec 3.3.0, subagents.md §3, §4)." }
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"levels": {
|
||||||
|
"about": "Spec 3.2.0. The effort levels a chat offers for a model, in order: `off` when thinking_switch is true, then the model's `efforts` — or, when it takes none, the one level `default`. What each sends (OpenAI chat): `off` — `chat_template_kwargs.enable_thinking: false` and no `reasoning_effort` anywhere; `default` — neither; an effort — `reasoning_effort` where effort_style puts it, and no `enable_thinking`. The Anthropic dialect: `off` sends no `thinking`. A level the model does not offer (an `off` for a model without the switch, from an older client or a stored chat) is read as the start level (`effort`'s rule). How it is shown: one level — no effort control at all; two, one of them `off` — a Thinking on/off switch, on being the other level; more — a slider or a list, in this order. conformance/levels.json.",
|
||||||
|
"control": ["none", "switch", "slider"]
|
||||||
|
},
|
||||||
"required": ["id"],
|
"required": ["id"],
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,17 @@
|
|||||||
"job_stop": "allow",
|
"job_stop": "allow",
|
||||||
"job_send": "allow",
|
"job_send": "allow",
|
||||||
"report": "allow",
|
"report": "allow",
|
||||||
|
"team_add": "allow",
|
||||||
|
"team_send": "allow",
|
||||||
|
"team_read": "allow",
|
||||||
|
"team_stop": "allow",
|
||||||
|
"team_whoami": "allow",
|
||||||
|
"team_finish": "allow",
|
||||||
|
"team_findings": "allow",
|
||||||
|
"team_request": "allow",
|
||||||
|
"team_decide": "allow",
|
||||||
|
"team_assign": "allow",
|
||||||
|
"team_replace": "allow",
|
||||||
"web_search": "allow",
|
"web_search": "allow",
|
||||||
"memory": "allow",
|
"memory": "allow",
|
||||||
"session_search": "allow",
|
"session_search": "allow",
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Progress
|
||||||
|
|
||||||
|
Spec 3.4.0. **One way to say what a session is doing**, the same in every client — the web UI,
|
||||||
|
Desktop, the CLI, Android — and for every agent in it, the main agent and each subagent alike.
|
||||||
|
Before 3.4.0 each client invented its own subset from a free `status` text; that text stays, for
|
||||||
|
compatibility, and beside it goes a structured **progress** object with one of a fixed set of
|
||||||
|
**phases**. The texts are `loop.json` `progress`; the cases `conformance/progress.json`.
|
||||||
|
|
||||||
|
## 1. The object
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "phase": "running_tool", "since": 1762790400000, "tool": "bash", "what": "bun test" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`phase`** — one of the thirteen names below, never another. A session doing nothing has no
|
||||||
|
progress: `null`.
|
||||||
|
- **`since`** — when this phase began, Unix time in **milliseconds** (an integer). A change of the
|
||||||
|
phase's data (tokens read, the rate) keeps `since`; a new phase, or the same phase for another
|
||||||
|
thing (another tool call, another model), starts a new one.
|
||||||
|
- The phase's **data**, as listed: every field named here, with these names and these types. A
|
||||||
|
field that is not known is left out (never `null`, never a guess), and the text says less.
|
||||||
|
|
||||||
|
## 2. The phases
|
||||||
|
|
||||||
|
In the order a turn usually meets them. `{{x}}` in a text is the field `x`; the texts are
|
||||||
|
`loop.json` `progress.texts`, word for word, and they are the shared i18n keys
|
||||||
|
`progress.<phase>` (and `progress.<phase>.<variant>`, `progress.reason.<reason>`,
|
||||||
|
`progress.what.<what>`) in every client's translations.
|
||||||
|
|
||||||
|
| Phase | Data | Text (`progress.texts`) |
|
||||||
|
|---|---|---|
|
||||||
|
| `queued` | `ahead?: int` — turns before it | `Queued` · with `ahead`: `Queued: {{ahead}} ahead` |
|
||||||
|
| `waiting_for_model` | `model: string` (its display name, else its id); `reason: "busy" \| "slots" \| "group"` | `Waiting for {{model}}: {{reason}}` |
|
||||||
|
| `loading_model` | `model: string` | `Loading {{model}}` |
|
||||||
|
| `reading_prompt` | `done: int`, `total: int` — prompt tokens processed of all | `Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)` · without `total`: `Reading the prompt` |
|
||||||
|
| `thinking` | — | `Thinking` |
|
||||||
|
| `writing` | `rate?: number` — tokens a second, as measured over this reply | `Writing: {{rate}} tok/s` · without `rate`: `Writing` |
|
||||||
|
| `running_tool` | `tool: string` (the tool's name); `what?: string` — the call's short title (the command, the path, the query), one line, at most 80 characters | `Running {{tool}}: {{what}}` · without `what`: `Running {{tool}}` |
|
||||||
|
| `waiting_for_person` | `what: "approval" \| "question" \| "plan"`; `tool?: string` (approval: the tool asked about) | `Waiting for you: {{what}}` |
|
||||||
|
| `waiting_for_subagents` | `running: int` — subagents working or waiting now | `Waiting for subagents: {{running}} running` |
|
||||||
|
| `compacting` | — | `Compacting the conversation` |
|
||||||
|
| `retrying` | `reason: string` — the server's error, one line; `in: int` — seconds until the retry, counted down by the client from `since`; `attempt?: int` | `Retrying in {{in}} s: {{reason}}` |
|
||||||
|
| `stopped` | `reason?: "person" \| "steps" \| "budget"` | `Stopped` · with `reason`: `Stopped: {{reason}}` |
|
||||||
|
| `failed` | `reason: string` — the error, one line | `Failed: {{reason}}` |
|
||||||
|
|
||||||
|
The fixed words a field stands for:
|
||||||
|
|
||||||
|
- `reason` of `waiting_for_model` (`progress.reason`): `busy` — *it serves one request at a time
|
||||||
|
and is answering another*; `slots` — *all its slots are in use*; `group` — *its server holds
|
||||||
|
another model now*.
|
||||||
|
- `what` of `waiting_for_person` (`progress.what`): `approval` — *allow {{tool}}?* (*an approval*
|
||||||
|
without `tool`); `question` — *a question*; `plan` — *the plan*.
|
||||||
|
- `reason` of `stopped` (`progress.stopped_reason`): `person` — *by you*; `steps` — *step limit*;
|
||||||
|
`budget` — *budget*.
|
||||||
|
- `percent` is `floor(done × 100 / total)`; numbers are written as integers, `rate` with one
|
||||||
|
decimal (`12.5`), in the client's own number format.
|
||||||
|
|
||||||
|
## 3. The line a client draws
|
||||||
|
|
||||||
|
**`{{text}} · {{elapsed}}`** (`progress.line`) for every phase but `stopped` and `failed`, which
|
||||||
|
draw their text alone. `elapsed` is now − `since`, written in whole seconds as a ticking clock writes durations
|
||||||
|
(`5s`, `1m 05s`, `1h 02m`), and kept moving by the client at least once a second while the
|
||||||
|
phase lasts. `retrying`'s `in` is likewise counted down from `since` (never below 0).
|
||||||
|
|
||||||
|
Every client draws the same line for the same object — the same words, from the same keys — and
|
||||||
|
its own look around it (a spinner, a colour, a bar for `reading_prompt`). Where a line has no room
|
||||||
|
(a phone's notification, a tree row) the text alone, without `· elapsed`, is enough; nothing else
|
||||||
|
is shortened.
|
||||||
|
|
||||||
|
## 4. When each phase holds
|
||||||
|
|
||||||
|
What decides the phase, so that two implementations agree on the same moment:
|
||||||
|
|
||||||
|
- `queued` — a prompt waits behind a turn that runs (the device's turn queue; the server's per
|
||||||
|
chat). `waiting_for_model` — the turn may start but its model may not take it yet: a one-request
|
||||||
|
server answering another request (`busy`), every slot of the model held (`slots`; `team.md` §8),
|
||||||
|
another model of its one-model server in use (`group`). `loading_model` — only where the
|
||||||
|
implementation is told the model is being loaded (the server sees it; a device that cannot tell
|
||||||
|
skips it).
|
||||||
|
- `reading_prompt` — the request is sent and no output has come; with `done`/`total` from the
|
||||||
|
server's prompt-progress frames (`quirks.json` `prompt-progress`), without them where none come.
|
||||||
|
- `thinking` — reasoning streams; `writing` — reply text (or a tool call's arguments) streams.
|
||||||
|
- `running_tool` — from a tool call's start to its end; with calls in parallel, the one that
|
||||||
|
started first. `waiting_for_person` — an approval card, a question or a plan to approve is open
|
||||||
|
for this agent. `waiting_for_subagents` — the agent's turn waits on subagents: a `task` call in
|
||||||
|
the foreground, or the main agent with nothing to do but its subagents' reports (`subagents.md`
|
||||||
|
§9).
|
||||||
|
- `compacting` — a compaction runs. `retrying` — a failed request will be sent again after a wait.
|
||||||
|
- `stopped` / `failed` — the turn ended other than by finishing: stopped by the person, a step limit
|
||||||
|
or a budget; failed with an error. They are the last progress of that turn and stay until the
|
||||||
|
next one begins; a turn that finished normally ends with `null`.
|
||||||
|
|
||||||
|
## 5. Where it is sent
|
||||||
|
|
||||||
|
- **`/api/v2` (LLeMbas)**: every frame of a chat's stream that carries the `status` text carries
|
||||||
|
`progress` beside it — the object, or `null` — and a frame is sent when the progress changes:
|
||||||
|
a new phase at once, a data-only change (`done`, `rate`) at most four times a second. `status`
|
||||||
|
stays as it was, its text unchanged, for clients that do not read `progress`.
|
||||||
|
- **The link (`acp.md`, capability `progress`)**: the `phase` event `{ progress, agent? }` —
|
||||||
|
`agent` the subagent's id (`subagents.md` §5) when the progress is a subagent's, absent for the
|
||||||
|
main agent's — under the same pacing; `progress` in `_lembas/session/status`, so a client
|
||||||
|
reattaching draws it at once. The device sends it; the server passes it on in `/api/v2` as its
|
||||||
|
own.
|
||||||
|
- **The subagent tree** (`subagents.md` §12): every agent carries its current `progress`.
|
||||||
|
|
||||||
|
A client that reads `progress` draws it and ignores `status`; one that does not, draws `status` as
|
||||||
|
before.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
kept by the agent, and how git is used there is the user's answer, asked once and written down. -->
|
kept by the agent, and how git is used there is the user's answer, asked once and written down. -->
|
||||||
## AGENTS.md
|
## AGENTS.md
|
||||||
This project has no AGENTS.md yet. It is the file every later session here starts from, so making it is part of the work:
|
This project has no AGENTS.md yet. It is the file every later session here starts from, so making it is part of the work:
|
||||||
- Make it at the first natural pause in real work, once you know what the project is — not before you have done anything, and not in plan mode. It goes at the project root: {{root}}/AGENTS.md.
|
- Make it at the first natural pause in real work, once you know what the project is — not before you have done anything, and not in plan mode. It goes at the project root: `AGENTS.md`.
|
||||||
- Before you write it, ask the user how git is to be used here, with ask_user, in one call: {{git_question}} Mark the options you would recommend.
|
- Before you write it, ask the user how git is to be used here, with ask_user, in one call: {{git_question}} Mark the options you would recommend.
|
||||||
- Write their answer under a "## Git" heading. Above it, say briefly what the project is, how to build, run and test it, and the conventions that matter.
|
- Write their answer under a "## Git" heading. Above it, say briefly what the project is, how to build, run and test it, and the conventions that matter.
|
||||||
- If the user does not want the file, do not bring it up again this session.
|
- If the user does not want the file, do not bring it up again this session.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- LLeMbas CLI: the subagent's standing, said once at the end of its system prompt in place of
|
<!-- LLeMbas CLI: the subagent's standing, said once at the end of its system prompt in place of
|
||||||
the mode text; LLeMbas's helper says the same. The variables are what differs per run. -->
|
the mode text; LLeMbas's subagent says the same. The variables are what differs per run. -->
|
||||||
You are the "{{name}}" subagent. You work for the main agent, not for the user directly, on one task it gave you; you cannot ask the user anything. {{instructions}}
|
You are the "{{name}}" subagent. You work for the main agent, not for the user directly, on one task it gave you; you cannot ask the user anything. {{instructions}}
|
||||||
|
|
||||||
Your final reply is your report to the main agent — make it complete on its own: the result, then the evidence (file:line), then anything you could not settle.{{report}}{{read_only}}
|
Your final reply is your report to the main agent — make it complete on its own: the result, then the evidence (file:line), then anything you could not settle.{{report}}{{read_only}}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!-- LLeMbas CLI and LLeMbas, spec 3.2.0 (team.md), 3.4.0 (subagents.md §5–§13): how the main agent
|
||||||
|
leads its subagents, in the system prompt whenever the team tools are offered. Constant: the
|
||||||
|
size the person allows comes in a session note, never here. -->
|
||||||
|
# Leading subagents
|
||||||
|
You are the main agent of this session. You can add subagents with team_add: agents that work in the background, each on the part of the work it owns, while you lead. Only you add, assign, replace and stop them. A small job you may do yourself. Once you have a subagent you manage and no longer do the work: tools that change things are refused to you, and reading stays yours, for review.
|
||||||
|
- Plan first: what the goal is, what the parts are, and what each one needs from the others. Split by area of ownership, not by step, so that one subagent owns a thing from start to finish.
|
||||||
|
- Name each subagent after what it owns and give it a position (`role`). Brief it so that it can work without you: what to do, what done means, what to leave alone, and what to report.
|
||||||
|
- Keep the tree flat while it is small — up to six or eight subagents, each reporting to you. Beyond that, add a sub-manager for each area (`lead: true`) and place that area's subagents under it (`parent`); it directs them and reports to you, and you still add and stop every one.
|
||||||
|
- A subagent's mode is yours by default and never looser. Give a stricter one where the person wants less done unasked; with it you may give `request` options, and you decide its requests with team_decide — allow only what the task needs.
|
||||||
|
- Keep within the size the person allows, which a session note tells you; when it says slots are open, fill them. Stop a subagent whose part is done or no longer needed, to make room.
|
||||||
|
- Coordinate through the room: team_send to the subagent who owns a thing, `all` only for what everyone needs. team_read shows the tree and the room when you need to look; nothing from it reaches you unasked except reports, requests and messages addressed to you.
|
||||||
|
- A subagent whose context fills saves its findings and ends. Read them with team_findings, then replace it (team_replace) if its part is not done.
|
||||||
|
- Step in when a subagent is stuck, when two disagree, or when the plan has to change: decide, and tell those it concerns (team_assign for new work).
|
||||||
|
- Review every report against what you asked for — read the work itself where it matters — and send back what is not done. Then give your own answer to the person.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- LLeMbas CLI and LLeMbas, spec 3.2.0 (team.md), 3.4.0 (subagents.md §13): a subagent's standing,
|
||||||
|
said once at the end of its system prompt in place of blocks/subagent.md. The variables are what
|
||||||
|
differs per subagent; each stands on a line of its own, so no punctuation of theirs matters. -->
|
||||||
|
You are "{{name}}" ({{id}}), a subagent of this session, and you report to "{{manager}}".
|
||||||
|
Your position: {{role}}
|
||||||
|
The team's goal: {{goal}}
|
||||||
|
|
||||||
|
{{instructions}}
|
||||||
|
|
||||||
|
You work for your parent on the part you own, and you cannot ask the person anything: ask your parent with team_send to `parent`. Only the main agent (`lead`) adds, assigns and stops subagents. Other subagents own other parts. Message the one who owns a thing when you need it from them, and `all` only for what everyone needs; messages to you arrive as you work. team_read shows the tree and the room; team_whoami shows who you are, your mode and how full your context is.
|
||||||
|
|
||||||
|
Your context is limited and you do not compact: write what you find with team_findings as you go. When your context fills you are told, and you have one last turn to save your findings and tell your parent.
|
||||||
|
|
||||||
|
When your task is done, call team_finish with your report to "{{manager}}" — make it complete on its own: the result, then the evidence (file:line), then anything you could not settle. A final reply without it is also your report, and a message may then wake you again.{{read_only}}
|
||||||
|
|
||||||
|
{{lead}}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<!-- LLeMbas CLI and LLeMbas, spec 3.4.0 (subagents.md §5, §13): what a sub-manager is told, in
|
||||||
|
place of the whole of blocks/team_lead.md that a member with `lead: true` got in 3.2.0. -->
|
||||||
|
# Directing an area
|
||||||
|
You are a sub-manager: the main agent places subagents under you, and you direct them. You do not add, assign, replace or stop subagents — ask the main agent with team_send to `lead` when your area needs one more, another task for one, or one stopped. You manage and no longer do the work: tools that change things are refused to you, and reading stays yours, for review.
|
||||||
|
- Split your area's work among the subagents under you, tell each what it owns with team_send, and keep them from overlapping.
|
||||||
|
- Their reports come to you. Review each against what you asked — read the work itself where it matters — and send back what is not done.
|
||||||
|
- Read the findings of a subagent under you with team_findings, especially of one that ran out of context; tell the main agent whether it needs replacing.
|
||||||
|
- When your area is done, call team_finish with your report to the main agent.
|
||||||
@@ -240,6 +240,34 @@
|
|||||||
"show": "when the session has a screen (`show` is offered): the sentence saying to open a file the user should see with show — a page, a chart, a document you made — rather than pasting it; otherwise empty",
|
"show": "when the session has a screen (`show` is offered): the sentence saying to open a file the user should see with show — a page, a chart, a document you made — rather than pasting it; otherwise empty",
|
||||||
"python": "when `python` is offered: the sentence saying quick calculations and file work can be done in Python in the user's browser, and that it cannot run when no browser page is open; otherwise empty"
|
"python": "when `python` is offered: the sentence saying quick calculations and file work can be done in Python in the user's browser, and that it cannot run when no browser page is open; otherwise empty"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"blocks/team_lead.md": {
|
||||||
|
"scope": "shared",
|
||||||
|
"llembas": [],
|
||||||
|
"llembas_text": "identical",
|
||||||
|
"why": "spec 3.2.0: LLeMbas reads it from the vendored spec (harness_spec.prompt), not from a fragment"
|
||||||
|
},
|
||||||
|
"blocks/team_member.md": {
|
||||||
|
"scope": "shared",
|
||||||
|
"llembas": [],
|
||||||
|
"llembas_text": "identical",
|
||||||
|
"why": "spec 3.2.0, 3.4.0: LLeMbas reads it from the vendored spec (harness_spec.prompt), not from a fragment",
|
||||||
|
"variables": {
|
||||||
|
"name": "the member's name in the team",
|
||||||
|
"role": "its position, one line (team_add's role)",
|
||||||
|
"manager": "its parent: lead, or the sub-manager it was placed under",
|
||||||
|
"goal": "the team's goal as it was when the member was added: the `goal` its team_add gave, else its manager's (team.md §4)",
|
||||||
|
"instructions": "its agent's own instructions (prompts/agents/<name>.md for a built-in one), as in blocks/subagent.md",
|
||||||
|
"read_only": "in plan mode: the sentence saying it can read and search but change nothing (as in blocks/subagent.md); otherwise empty",
|
||||||
|
"lead": "for a sub-manager (added with `lead: true`): the whole of blocks/team_submanager.md (spec 3.4.0; before, blocks/team_lead.md); otherwise empty",
|
||||||
|
"id": "the subagent's id (s1, s2, …; spec 3.4.0)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"blocks/team_submanager.md": {
|
||||||
|
"scope": "shared",
|
||||||
|
"llembas": [],
|
||||||
|
"llembas_text": "identical",
|
||||||
|
"why": "spec 3.4.0: LLeMbas reads it from the vendored spec (harness_spec.prompt), not from a fragment"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- Source: OpenCode command/template/initialize.txt (MIT, © 2025 opencode), shortened, and made to
|
<!-- Source: OpenCode command/template/initialize.txt (MIT, © 2025 opencode), shortened, and made to
|
||||||
ask about git the way blocks/agents-missing.md does (LLeMbas CLI's /init). -->
|
ask about git the way blocks/agents-missing.md does (LLeMbas CLI's /init). -->
|
||||||
Create or update `AGENTS.md` at the project root, {{root}}/AGENTS.md: the file every later session here starts from. Every line should answer "would an agent likely get this wrong without being told?" — if not, leave it out.
|
Create or update `AGENTS.md` at the project root: the file every later session here starts from. Every line should answer "would an agent likely get this wrong without being told?" — if not, leave it out.
|
||||||
|
|
||||||
{{focus}}
|
{{focus}}
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -2,16 +2,17 @@
|
|||||||
"about": "What OpenAI-compatible servers really do, and how both harnesses cope. Each quirk is detected, handled, and — where marked — learned, so the next request does not trip it again. The CLI keeps what it learned in ~/.local/state/lembas/learned.json; LLeMbas on the model row or in process memory.",
|
"about": "What OpenAI-compatible servers really do, and how both harnesses cope. Each quirk is detected, handled, and — where marked — learned, so the next request does not trip it again. The CLI keeps what it learned in ~/.local/state/lembas/learned.json; LLeMbas on the model row or in process memory.",
|
||||||
"quirks": [
|
"quirks": [
|
||||||
{ "id": "stream-options-refused", "servers": "older llama.cpp, some proxies", "symptom": "400 or 422 to a request carrying stream_options", "handling": "retry once without it, before anything was streamed", "learned": true },
|
{ "id": "stream-options-refused", "servers": "older llama.cpp, some proxies", "symptom": "400 or 422 to a request carrying stream_options", "handling": "retry once without it, before anything was streamed", "learned": true },
|
||||||
{ "id": "effort-refused", "servers": "llama.cpp chat templates (Qwen3.8 refuses high)", "symptom": "an error whose text names the effort and says unexpected or supported (conformance/effort.json)", "handling": "retry once without an effort, before anything was streamed; narrow the model's efforts to those the message advertises", "learned": true },
|
{ "id": "effort-refused", "servers": "llama.cpp chat templates (Qwen3.8 refuses high)", "symptom": "an error whose text names the effort and says unexpected or supported (conformance/effort.json)", "handling": "retry once without an effort (and without enable_thinking), before anything was streamed; narrow the model's efforts to those the message advertises. A narrowed list keeps the level off when the model has thinking_switch (models/schema.json levels)", "learned": true },
|
||||||
{ "id": "effort-placement", "servers": "llama.cpp", "symptom": "a top-level reasoning_effort is silently dropped", "handling": "send it top-level and as chat_template_kwargs.reasoning_effort (effort_style both)", "learned": false },
|
{ "id": "effort-placement", "servers": "llama.cpp", "symptom": "a top-level reasoning_effort is silently dropped", "handling": "send it top-level and as chat_template_kwargs.reasoning_effort (effort_style both)", "learned": false },
|
||||||
{ "id": "prompt-progress", "servers": "llama.cpp with return_progress", "symptom": "prompt_progress {total, cache, processed, time_ms} frames before the reply", "handling": "ask for it, show how far the prompt has been read; a 400 to the field is learned and it is not asked again", "learned": true },
|
{ "id": "prompt-progress", "servers": "llama.cpp with return_progress", "symptom": "prompt_progress {total, cache, processed, time_ms} frames before the reply", "handling": "ask for it, show how far the prompt has been read; a 400 to the field is learned and it is not asked again", "learned": true },
|
||||||
{ "id": "reasoning-fields", "servers": "llama.cpp, vLLM, DeepSeek, OpenRouter", "symptom": "reasoning arrives as reasoning_content, reasoning, or inline <think>/<thinking>/<reasoning> tags (conformance/think.json)", "handling": "all three read as reasoning; reasoning is never sent back as context", "learned": false },
|
{ "id": "reasoning-fields", "servers": "llama.cpp, vLLM, DeepSeek, OpenRouter", "symptom": "reasoning arrives as reasoning_content, reasoning, or inline <think>/<thinking>/<reasoning> tags (conformance/think.json)", "handling": "all three read as reasoning. From spec 3.2.0 an assistant message of the current turn carries its reasoning back as reasoning_content, and earlier turns' carry none (context.md §8); inline tags are never sent back as text", "learned": false },
|
||||||
{ "id": "error-in-200-stream", "servers": "llama.cpp", "symptom": "an error object inside a 200 event stream, sometimes as an unterminated last line", "handling": "read as the error it is, not as an empty reply", "learned": false },
|
{ "id": "error-in-200-stream", "servers": "llama.cpp", "symptom": "an error object inside a 200 event stream, sometimes as an unterminated last line", "handling": "read as the error it is, not as an empty reply", "learned": false },
|
||||||
{ "id": "tool-call-without-index", "servers": "some llama.cpp builds", "symptom": "tool-call fragments with no index", "handling": "fragments without an index continue the last call", "learned": false },
|
{ "id": "tool-call-without-index", "servers": "some llama.cpp builds", "symptom": "tool-call fragments with no index", "handling": "fragments without an index continue the last call", "learned": false },
|
||||||
{ "id": "swap-banner", "servers": "llama-swap", "symptom": "a loading banner streamed before the reply while the model loads", "handling": "not shown as the reply", "learned": false },
|
{ "id": "swap-banner", "servers": "llama-swap", "symptom": "a loading banner streamed before the reply while the model loads", "handling": "not shown as the reply", "learned": false },
|
||||||
{ "id": "model-unloaded", "servers": "llama-swap", "symptom": "a 'model unloaded' frame mid-reply, when another client loaded a different model", "handling": "treated as a server error: retried once", "learned": false },
|
{ "id": "model-unloaded", "servers": "llama-swap", "symptom": "a 'model unloaded' frame mid-reply, when another client loaded a different model", "handling": "treated as a server error: retried once", "learned": false },
|
||||||
{ "id": "zero-usage", "servers": "several", "symptom": "a usage block of all zeros", "handling": "ignored; the count is estimated (marked ~) instead", "learned": false },
|
{ "id": "zero-usage", "servers": "several", "symptom": "a usage block of all zeros", "handling": "ignored; the count is estimated (marked ~) instead", "learned": false },
|
||||||
{ "id": "no-context-length", "servers": "llama-swap /v1/models, most OpenAI-compatible servers", "symptom": "/models lists ids without a context length", "handling": "ask llama-server's /props (through llama-swap's /upstream/<model>), Ollama's /api/show, else unknown — never assume one", "learned": true },
|
{ "id": "no-context-length", "servers": "llama-swap /v1/models, most OpenAI-compatible servers", "symptom": "/models lists ids without a context length", "handling": "ask llama-server's /props (through llama-swap's /upstream/<model>), Ollama's /api/show, else unknown — never assume one", "learned": true },
|
||||||
|
{ "id": "thinking-default", "servers": "llama.cpp, vLLM and SGLang serving Qwen3.x and Nemotron 3.x", "symptom": "a request that sends no effort still thinks, at the effort the server was started with — no effort is not no thinking", "handling": "thinking is switched off only by chat_template_kwargs.enable_thinking: false, on a model marked thinking_switch (models/schema.json levels); a request's own reasoning_effort overrides the server's default, and a value the template does not know is effort-refused", "learned": false },
|
||||||
{ "id": "max-completion-tokens", "servers": "OpenAI reasoning models", "symptom": "max_tokens refused", "handling": "send max_completion_tokens where configured", "learned": false }
|
{ "id": "max-completion-tokens", "servers": "OpenAI reasoning models", "symptom": "max_tokens refused", "handling": "send max_completion_tokens where configured", "learned": false }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
# Subagents
|
||||||
|
|
||||||
|
Spec 3.3.0; §5–§13 spec 3.4.0. **Which model a subagent runs on, and when subagents are offered at all.** A
|
||||||
|
**subagent** is any agent a session's agent starts to work for it: a `task` subagent (in the
|
||||||
|
foreground or as a background job) and a team's member (`team.md`) alike. The same rules in a chat
|
||||||
|
on the server and in an agent session on a device, in both projects. The texts are `loop.json`
|
||||||
|
`subagents`; the cases `conformance/subagents.json`.
|
||||||
|
|
||||||
|
## 1. The word
|
||||||
|
|
||||||
|
**Subagent** is the word in every text a person or a model reads: the UI (the web UI, Desktop,
|
||||||
|
Android, the CLI, the admin pages), the prompt texts and the tool texts. A team's members are
|
||||||
|
subagents too. The word *helper* is retired. Tool names, their former names (`aliases`) and
|
||||||
|
stored identifiers do not change.
|
||||||
|
|
||||||
|
## 2. The model a subagent runs on
|
||||||
|
|
||||||
|
**The session's own model**, always — unless **the person has named another model** in this
|
||||||
|
session. Nothing else chooses one: there is **no setting** for a subagent's model anywhere — not
|
||||||
|
in a chat, not in a person's or an instance's settings, not in the configuration, and an agent
|
||||||
|
definition's own `model` (the CLI's agent files) is not used for a subagent either.
|
||||||
|
|
||||||
|
- **Named** means: in a message **the person typed** in this session, the model's served id, its
|
||||||
|
id at its connection, or its display name occurs as a whole word, letters compared without case.
|
||||||
|
Only the person's own words count — never the model's text, a tool's output, a file, a note, a
|
||||||
|
job's or a team's marked message, or a subagent's report. A message that was compacted away
|
||||||
|
still counts: what the person named stays named for the whole session.
|
||||||
|
- **A whole word**: the character before the name is not a letter, a digit, `_`, `-`, `/`, `.` or
|
||||||
|
`:`; the character after it is not a letter, a digit, `_`, `-` or `/`, nor a `.` or `:` followed
|
||||||
|
by a letter or a digit. So `coder-30b.` at the end of a sentence names `coder-30b`, and
|
||||||
|
`coder-30b-instruct` does not. Runs of white space in a display name match any run of white
|
||||||
|
space.
|
||||||
|
- The `model` parameter of `task` and of `team_add` takes a model the person may use, by the same
|
||||||
|
names. When it is **not the session's model and the person has not named it**, the call is
|
||||||
|
refused, word for word, with `subagents.texts.not_named`:
|
||||||
|
|
||||||
|
> Use another model for subagents only when the person names it.
|
||||||
|
|
||||||
|
The session's own model given as `model` is the default spelled out, and is never refused for
|
||||||
|
not being named.
|
||||||
|
- A team's members are of the lead's session: what the person named there counts for every
|
||||||
|
`team_add` in the team, a sub-manager's included. A member's default is the session's model, not
|
||||||
|
its manager's.
|
||||||
|
|
||||||
|
## 3. Capacity: the logical restrictions, and no others
|
||||||
|
|
||||||
|
What a model's server can do at once is `models/schema.json` `capacity`: `parallel` (`single_session`
|
||||||
|
being `parallel: 1`; neither said — no limit), and `group`, the server that holds one model at a
|
||||||
|
time. With **S** the session's model and **M** the subagent's, checked in this order, after
|
||||||
|
`not_named`:
|
||||||
|
|
||||||
|
1. **M is another model on S's one-model server** (both have the same `group`): refused with
|
||||||
|
`subagents.texts.same_group` — starting it would unload the lead's model:
|
||||||
|
|
||||||
|
> That model is on the same one-model server as this chat's model; name a model on another server.
|
||||||
|
|
||||||
|
2. **M is S, and S serves one request at a time** (`parallel` 1): refused with
|
||||||
|
`subagents.texts.one_request` — the subagent would take the only slot from the lead:
|
||||||
|
|
||||||
|
> This model serves one request at a time: name another model, on another server, for subagents.
|
||||||
|
|
||||||
|
There is **no fallback**: nothing else is picked for the person, ever. A model the person named,
|
||||||
|
on another server, is the way out — and a model that serves one request at a time is fine there.
|
||||||
|
|
||||||
|
These apply to `task` and to `team_add` alike, and before anything is started. Beyond them nothing
|
||||||
|
is refused for being the wrong model: a member past the free slots waits for one (`team.md` §8),
|
||||||
|
and `task` keeps its rule for a model that is already full (`loop.json` `capacity`).
|
||||||
|
|
||||||
|
## 4. When subagents are offered
|
||||||
|
|
||||||
|
The size setting `agents {min, max}` (`team.md` §2) is shown as **Subagents** — `subagents.setting`:
|
||||||
|
the label, and **Min** and **Max** for `min` and `max` (defaults 1 and 4, at most `cap` 32, kept
|
||||||
|
with the chat). **The numbers count the session's own agent**: Min 1 / Max 1 is the agent alone,
|
||||||
|
with no subagents; Max 4 is the agent and up to three subagents (`subagents.setting.hint`). A UI
|
||||||
|
shows the setting as **one range from 1 to `cap` with two handles, Min and Max** (Min never past
|
||||||
|
Max): a slider in a graphical UI, two numbers on one line in a terminal, each moved on its own. It, the team tools and `blocks/team_lead.md` are **offered** when both hold:
|
||||||
|
|
||||||
|
1. Subagents are enabled for the session (`team.md` §2, rule 1).
|
||||||
|
2. **At least one model could serve as a subagent**: S itself, when it serves two or more requests
|
||||||
|
at once (`parallel ≥ 2`, or no limit); or **any other model the person may use that is not on
|
||||||
|
S's one-model server** (no `group`, or another one) — whatever its own `parallel`.
|
||||||
|
|
||||||
|
So a session on a one-request model is offered subagents when another server has a model, and
|
||||||
|
**its first note says so** (`context.note.team_own`, after `team`): the session's model cannot
|
||||||
|
serve subagents itself, and they need a model the person names. A UI showing the setting says the
|
||||||
|
same beside it (`subagents.setting.own`). Nothing is offered when neither holds — the same as
|
||||||
|
before 3.3.0 for a single model.
|
||||||
|
|
||||||
|
What counts as **usable** is each project's: LLeMbas — the models the person may use in the chat's
|
||||||
|
picker; the CLI — the models of the server it is logged in to.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Subagents v2 — the main agent and its tree
|
||||||
|
|
||||||
|
Spec 3.4.0. **How subagents are organised, what each may do, and how each ends.** The owner's model:
|
||||||
|
one permanent **main agent** per session, which alone makes subagents and places them in a tree;
|
||||||
|
permissions are inherited and never widened; a subagent is temporary and ends with its task or its
|
||||||
|
context. Sections 5–13 **supersede `team.md` where they say something different** — who adds a
|
||||||
|
subagent (§6), who stops one (§10), the states (§9), what a member is told (§13) — and keep the
|
||||||
|
rest of it: the size setting and its counting (§2–§3), the room and who receives what (§5),
|
||||||
|
delivery and waking (§7), a manager's refusals (§6 there), capacity and waiting for a slot (§8).
|
||||||
|
The tool names `team_*` stay (nothing stored or trained breaks); `task` and `report` are unchanged
|
||||||
|
for a one-off subagent. The texts are `loop.json` `team` (3.4.0 additions marked there); the cases
|
||||||
|
`conformance/team.json` (the tree, the tools, the lifecycle) and `conformance/delegation.json`
|
||||||
|
(permissions).
|
||||||
|
|
||||||
|
## 5. Words
|
||||||
|
|
||||||
|
- **The main agent** is the session's own agent: the chat's agent on LLeMbas, the session's in the
|
||||||
|
CLI. It is **permanent**: it compacts and never ends. In the room and in every tool it is called
|
||||||
|
`lead` (the name is kept from 3.2.0); a person reads it as *the main agent*.
|
||||||
|
- **A subagent** is an agent the main agent added (`team_add`). It has:
|
||||||
|
- **`id`** — `s1`, `s2`, … in the order the session's subagents were added, never reused in the
|
||||||
|
session (a replacement gets a new one);
|
||||||
|
- **`name`** — the main agent's choice (`team.names.pattern`), unique among the subagents that
|
||||||
|
have not ended, `lead`, `all` and `parent` reserved;
|
||||||
|
- **`position`** — its designation, one line, given by the main agent (`team_add`'s `role`:
|
||||||
|
*the API's tests*, *reviews the parser*);
|
||||||
|
- **`parent`** — the main agent, or a **sub-manager** the main agent placed it under;
|
||||||
|
- **`task`** — its brief, as given (and as replaced by `team_assign`);
|
||||||
|
- **`mode`** and **`request`** — its permission mode and its request options (§7);
|
||||||
|
- **`model`** — `subagents.md` §2–§3, unchanged.
|
||||||
|
- **A sub-manager** is a subagent added with `lead: true`. It **directs** the subagents the main
|
||||||
|
agent places under it — they report to it, it messages them, it reviews their work — and it does
|
||||||
|
**not** add, assign, replace or stop subagents: only the main agent does (§6). Like every manager
|
||||||
|
it does not do the work itself (`team.md` §6, unchanged).
|
||||||
|
- **The tree**: the main agent at the root, every subagent under its parent, in the order added.
|
||||||
|
**Under** an agent and **its subtree** keep their `team.md` meaning.
|
||||||
|
|
||||||
|
## 6. Only the main agent makes subagents
|
||||||
|
|
||||||
|
- **`team_add`, `team_assign`, `team_replace`, `team_stop` and `team_decide`** are the main agent's.
|
||||||
|
A subagent's call to any of them is refused, word for word, with `team.texts.only_main`:
|
||||||
|
|
||||||
|
> Only the main agent adds, assigns, replaces and stops subagents: ask it (team_send to lead).
|
||||||
|
|
||||||
|
A subagent's tool list never has `task` (a one-off subagent is the main agent's too), and a call
|
||||||
|
to it by a former name is refused with the same text.
|
||||||
|
- **`team_add`** takes, beside 3.2.0's `name`, `role` (the position), `task`, `goal`, `lead`,
|
||||||
|
`agent`, `model`: **`parent`** — the name of a sub-manager to place it under (default: the main
|
||||||
|
agent; refused with `team.texts.not_manager` for a subagent that is not a sub-manager, and
|
||||||
|
`unknown` for no such name), **`mode`** — its mode (default: its parent's; §7), **`request`** —
|
||||||
|
its request options (§7). Its refusals, in order: `unavailable`, `only_main`, then 3.2.0's (`all_name`,
|
||||||
|
`bad_name`, `taken`, `no_goal`, `full`), then `unknown` and `not_manager` for `parent`, then the mode's (`wider`, `request_not_narrower`,
|
||||||
|
`request_kind`), then the model's (§2–§3). `parent` and the reserved names are refused as
|
||||||
|
`taken`.
|
||||||
|
- **The person** can tell the main agent to spawn — it is the main agent's call, as any — or set
|
||||||
|
**Min above 1**: then **`min − 1` slots** exist from the start of the main agent's reply (the
|
||||||
|
first one after the setting was told, `team.md` §10). A **slot** is a place in the tree for a
|
||||||
|
subagent not added yet: it is shown (§12) and `team_read` lists it, it is **not** an agent and
|
||||||
|
does not count toward the size, and each subagent added fills one. Open slots are
|
||||||
|
`max(0, min − 1 − subagents that have not ended)`. The main agent is told to use them in its
|
||||||
|
note (`context.note.team_lead`, which 3.4.0 words as slots), and is a manager from the start
|
||||||
|
(`team.md` §6).
|
||||||
|
- **Min/Max** (3.3.0) are unchanged: they count the main agent; 1 is the main agent alone. The
|
||||||
|
model rules (§2–§3) are unchanged.
|
||||||
|
|
||||||
|
## 7. Permissions: inherited, never widened
|
||||||
|
|
||||||
|
**A subagent's mode is its parent's or stricter** — `permission/modes.json` `order`, loosest first:
|
||||||
|
`auto`, `edit`, `manual`, `plan`. The main agent's mode is the session's.
|
||||||
|
|
||||||
|
- `team_add`'s and `team_assign`'s `mode`, looser than the parent's, is refused, word for word,
|
||||||
|
with `team.texts.wider`:
|
||||||
|
|
||||||
|
> A subagent's mode cannot be looser than its parent's ({{parent_mode}}).
|
||||||
|
|
||||||
|
When the parent's own mode becomes stricter during the session (the person switches the main
|
||||||
|
agent to manual), every subagent looser than its parent is made as strict as it, down the tree,
|
||||||
|
at once; a looser switch changes no subagent.
|
||||||
|
- **Request options** (`request`: a list of the risk classes `read`, `write`, `execute`) may be
|
||||||
|
given **only with a mode stricter than the parent's** — otherwise refused with
|
||||||
|
`team.texts.request_not_narrower` ("Request options go with a mode stricter than the parent's:
|
||||||
|
without one there is nothing to ask for."); an unknown kind with `team.texts.request_kind`. They
|
||||||
|
let the subagent **ask the main agent** for a call of that class that its own mode does not
|
||||||
|
allow; **the main agent is the check**.
|
||||||
|
|
||||||
|
**Who decides a subagent's call.** The hardline and the deny rules first, as for any call. Then:
|
||||||
|
|
||||||
|
1. The call is evaluated in the **subagent's mode**, with the session's rules and approvals and
|
||||||
|
the subagent's own grants (below). **Allow** — it runs. **Deny** (a rule, the hardline, plan
|
||||||
|
mode's refusal) — refused, as always.
|
||||||
|
2. **Ask** — the call is evaluated in the **main agent's mode** (the session's rules and
|
||||||
|
approvals, no grants):
|
||||||
|
- **Allow** — the main agent could allow it: the subagent was narrowed. If the call's risk class
|
||||||
|
is in its `request`, refused with `team.texts.requestable` — it asks with `team_request`, the
|
||||||
|
main agent decides with `team_decide`, and once allowed the same call runs. Otherwise refused
|
||||||
|
with `team.texts.not_delegated`:
|
||||||
|
|
||||||
|
> Your mode does not allow this, and you may not ask for it: tell your parent what you need.
|
||||||
|
|
||||||
|
- **Ask** — **nobody in the tree can allow it: it goes to the person**, as an approval card that
|
||||||
|
names the subagent (its name and position), the same card and the same answers as the main
|
||||||
|
agent's own; unattended, refused as `modes.json` `unattended` says. So in an all-Edit tree a
|
||||||
|
subagent's `bash` reaches the person — not the main agent, which could not allow it either.
|
||||||
|
- **Deny** — refused with that reason.
|
||||||
|
|
||||||
|
A sub-manager is never asked: it may not allow more than the main agent, and the main agent is
|
||||||
|
the check.
|
||||||
|
|
||||||
|
**`team_request`** (a subagent with request options; its tool list has it only then): `tool`, the
|
||||||
|
permission `pattern` it needs (as a rule writes it: `git push *`, a path) and `why`. Refused with
|
||||||
|
`team.texts.not_requestable` when the tool's risk class is not in its options. Posted to the main
|
||||||
|
agent as a request, under its own mark (`team.marks.request`), the body `team.texts.request_body`,
|
||||||
|
with an id (`r1`, `r2`, … per session); the answer `team.texts.requested` says it waits for the main agent's decision.
|
||||||
|
|
||||||
|
**`team_decide`** (the main agent): `id`, `allow` (a boolean), `note`. Allowed — the subagent gets
|
||||||
|
a **grant**: the rule `{ permission: <tool's key>, pattern, action: allow }` for itself alone, for
|
||||||
|
the rest of its life; **a grant only ever applies where the main agent's own mode would allow the
|
||||||
|
call** (step 2's evaluation, done again at the call), so it can never widen past the main agent.
|
||||||
|
Either way the subagent is told, under `team.marks.decision`: `team.texts.decision_allowed` or
|
||||||
|
`decision_refused`, then the note after a blank line. The answer to the main agent is
|
||||||
|
`team.texts.decided` (`decision` being `allowed` or `refused`). Refused:
|
||||||
|
`team.texts.no_request` for an id that is not an open request.
|
||||||
|
|
||||||
|
## 8. What each agent has
|
||||||
|
|
||||||
|
| Tools | Main agent | Sub-manager | Subagent |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `team_whoami`, `team_read`, `team_send`, `team_findings` | ✓ | ✓ | ✓ |
|
||||||
|
| `team_finish` | — | ✓ | ✓ |
|
||||||
|
| `team_request` | — | with request options | with request options |
|
||||||
|
| `team_add`, `team_assign`, `team_replace`, `team_stop`, `team_decide` | ✓ | — | — |
|
||||||
|
|
||||||
|
Fixed when the prompt is made (the main agent's) or when the subagent is added (`context.md` §10,
|
||||||
|
rule 4). What each does:
|
||||||
|
|
||||||
|
- **`team_whoami`** — who the caller is: `team.whoami`, one line each, in its order — id, name, position, parent, task, mode and
|
||||||
|
request options, model, the limits (the size and Max, the steps a turn may take, whether it may
|
||||||
|
add subagents) and its **context use** (tokens in its context now, of its model's window, and the
|
||||||
|
percentage). For the main agent: the same, its position *the main agent*, without the parent and task lines.
|
||||||
|
- **`team_read`** — the roster: the tree (every agent and open slot, §12's fields) and the room,
|
||||||
|
as in 3.2.0 (`team.read`; 3.4.0's row adds the id, the mode and the context use).
|
||||||
|
- **`team_send`** — a message `to` names, `all`, or **`parent`** (3.4.0): the caller's parent
|
||||||
|
(refused for the main agent with `team.texts.no_parent`). Otherwise 3.2.0.
|
||||||
|
- **`team_finish`** — `report`: the subagent's task is done. The report goes to its parent (the
|
||||||
|
report mark, as a final reply's), and the subagent **ends** (§9): its turn stops after this call,
|
||||||
|
it leaves the size, and its findings and transcript stay.
|
||||||
|
- **`team_findings`** — `write` (`text`): a finding kept for the caller, numbered from 1; `read`
|
||||||
|
(`of`: a name or an id, default the caller): the findings of the caller, or of an agent in its
|
||||||
|
subtree (the main agent: anybody's), ended ones included — as `team.findings` writes them. A
|
||||||
|
name that has been used more than once means its latest holder; an id is exact. Otherwise refused with
|
||||||
|
`team.texts.not_yours_findings`. The person reads every agent's (§12).
|
||||||
|
- **`team_assign`** — `name`, `task`, and optionally `role` (a new position), `parent` (another
|
||||||
|
sub-manager, or `lead`), `mode` (§7): a new task for a subagent that has not ended. Its task
|
||||||
|
becomes this one; it is told under `team.marks.assignment`, and a finished one is woken. Refused:
|
||||||
|
`unknown`, `team.texts.ended` for one that has ended, `not_manager`, `wider`; a move under its
|
||||||
|
own subtree with `team.texts.cycle`.
|
||||||
|
- **`team_replace`** — `name`, and optionally `task`: the subagent ends (stopped, if it has not
|
||||||
|
already), and a new one takes its place — the same name, position, parent, mode, request options
|
||||||
|
and model, a new id — briefed with `task` (default: the old task) followed by the old one's
|
||||||
|
findings (`team.texts.replace_brief`). Its subagents move under the new one. Refused: `unknown`.
|
||||||
|
- **`team_stop`** — the main agent's only (3.2.0 let a manager stop its subtree); otherwise 3.2.0.
|
||||||
|
- **`team_decide`** — §7.
|
||||||
|
|
||||||
|
## 9. A subagent's life
|
||||||
|
|
||||||
|
States (`team.read.states`): **`working`** (a turn runs), **`waiting`** (it has work and waits for
|
||||||
|
a slot, `team.md` §8), **`finished`** (its last turn ended with a final reply, which was its report;
|
||||||
|
a message wakes it — 3.2.0), **`paused`** (its context is full; its last turn runs), and the ends:
|
||||||
|
**`done`** (`team_finish`), **`out_of_context`** (its last turn after `paused` ended), **`stopped`**
|
||||||
|
(the main agent's `team_stop`, a replacement, or the person). The main agent is `working`,
|
||||||
|
`waiting` or `idle`.
|
||||||
|
|
||||||
|
**An ended subagent** — `done`, `out_of_context`, `stopped` — leaves the size and its slot frees;
|
||||||
|
its name may be used again; it keeps its id, and the tree keeps it (§12) with its findings and
|
||||||
|
transcript for the rest of the session. Messages to it by name are refused as `unknown`.
|
||||||
|
The subagents under a sub-manager that ended by itself (`done`, `out_of_context`) move under its
|
||||||
|
parent until the main agent replaces it, and then back under the replacement; a stopped one's
|
||||||
|
subtree is stopped with it (§10).
|
||||||
|
|
||||||
|
**When its context fills.** A subagent does not compact. When its context reaches
|
||||||
|
`team.context.pause_at` of its model's window (default 0.85, the fraction compaction would start
|
||||||
|
at), after the step that took it there:
|
||||||
|
|
||||||
|
1. it is **`paused`**: nothing new reaches it — a message to it is posted in the room and not
|
||||||
|
delivered; whoever replaces it reads it with `team_read`;
|
||||||
|
2. it is given **one final turn**, at most `team.context.final_steps` steps (8), whose user message
|
||||||
|
is `team.texts.context_full`, under its own mark: save what it found with `team_findings`, and
|
||||||
|
tell its parent with `team_send` that it is gone and what is left;
|
||||||
|
3. when that turn ends it is **`out_of_context`**, and its parent is sent `team.texts.gone` — then,
|
||||||
|
after a blank line, that turn's final reply if it gave one — as its report (the report mark): the name, that it ran out of context, and how to read its
|
||||||
|
findings. **The parent decides** — the main agent replaces it (`team_replace`) or uses the
|
||||||
|
findings; a sub-manager tells the main agent.
|
||||||
|
|
||||||
|
**The main agent is permanent**: it compacts as every session does (`context.md` §7) and never
|
||||||
|
ends; deleting the session ends every subagent.
|
||||||
|
|
||||||
|
**Waiting for subagents.** The main agent's turn may end while subagents work; their reports wake
|
||||||
|
it (3.2.0 §7). Its progress is `waiting_for_subagents` (`progress.md`) while its turn waits on
|
||||||
|
them — a foreground `task` — and the session shows the subagents' own phases in the tree.
|
||||||
|
|
||||||
|
## 10. Stopping
|
||||||
|
|
||||||
|
Only the main agent stops (`team_stop`, 3.2.0's refusals but `not_yours`, which no longer arises),
|
||||||
|
and **the person** (§12) — said to the main agent as a note (`context.note.team_stopped`). A
|
||||||
|
stopped subagent's subtree is stopped with it, unless the main agent moved it first. A sub-manager
|
||||||
|
that wants a subagent of its own stopped asks the main agent.
|
||||||
|
|
||||||
|
## 11. Transcripts and findings
|
||||||
|
|
||||||
|
Every subagent's conversation is kept — its brief, what it wrote, every tool call with its output —
|
||||||
|
for as long as the session: **viewable read-only by the person, live while it runs and after it
|
||||||
|
ended**. Nobody types into it; the person speaks to a subagent only through the main agent (or by
|
||||||
|
stopping it). Findings (§8) are kept the same way, readable by its parent, the main agent, and the
|
||||||
|
person.
|
||||||
|
|
||||||
|
## 12. What clients show
|
||||||
|
|
||||||
|
**The tree** — the main agent, every subagent under its parent, open slots, and ended subagents
|
||||||
|
(after the live ones, dimmed, with how each ended):
|
||||||
|
|
||||||
|
| Field | Shown as |
|
||||||
|
|---|---|
|
||||||
|
| `name`, `id`, `position` | `parser` (`s2`) — *reviews the parser* |
|
||||||
|
| `state` | `team.read.states`, its word |
|
||||||
|
| `model` | display name |
|
||||||
|
| `mode` | the mode's name, and request options as `+ write` etc. |
|
||||||
|
| `context` | `{used, window}` → `42%` (a bar where there is room) |
|
||||||
|
| `progress` | its current phase line (`progress.md` §3) |
|
||||||
|
| messages | the room's messages to and from it: how many unread by the person, the last one's first line |
|
||||||
|
|
||||||
|
- **Web UI, Desktop and the CLI** show the whole tree, the room, each subagent's findings and its
|
||||||
|
context (the CLI: `/context <name>`), and open any subagent's transcript read-only, live and after
|
||||||
|
it ended; the person can stop any subagent there.
|
||||||
|
- **Android** shows a reduced list — name, position, state, progress line, context % — and opens a
|
||||||
|
transcript read-only.
|
||||||
|
|
||||||
|
**The data**, the same in `/api/v2` (LLeMbas: a chat's stream carries the tree as it changes) and on
|
||||||
|
the link (`acp.md`, capability `tree`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "min": 1, "max": 4, "size": 2, "open_slots": 0,
|
||||||
|
"agents": [
|
||||||
|
{ "id": "lead", "name": "lead", "position": "the main agent", "parent": null, "state": "working",
|
||||||
|
"model": "qwen-27b", "mode": "edit", "request": [], "manager": true,
|
||||||
|
"context": { "used": 41200, "window": 131072 }, "progress": { "phase": "thinking", "since": 1762790400000 } },
|
||||||
|
{ "id": "s1", "name": "parser", "position": "reviews the parser", "parent": "lead", "state": "working",
|
||||||
|
"model": "qwen-27b", "mode": "manual", "request": ["write"], "manager": false, "task": "…",
|
||||||
|
"context": { "used": 9100, "window": 131072 }, "progress": { "phase": "running_tool", "since": 1762790401000, "tool": "read", "what": "src/parse.ts" },
|
||||||
|
"findings": 2, "messages": 3 }
|
||||||
|
],
|
||||||
|
"ended": [ { "id": "s0", "name": "scout", "position": "…", "parent": "lead", "state": "done", "ended": 1762790300000, "findings": 1 } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13. What each is told
|
||||||
|
|
||||||
|
- The main agent: `blocks/team_lead.md` (3.4.0: it alone adds; slots; positions; modes and request
|
||||||
|
options; replacing a subagent that ran out of context) and the notes of `team.md` §10.
|
||||||
|
- A subagent: `blocks/team_member.md` (3.4.0: its id and position, `team_whoami`, `team_findings`,
|
||||||
|
`team_finish`, that its context is limited and it will be told when it fills); a sub-manager,
|
||||||
|
`blocks/team_submanager.md` in place of the whole lead block it got in 3.2.0.
|
||||||
|
|
||||||
|
## 14. In an agent chat
|
||||||
|
|
||||||
|
The main agent of an agent chat is the CLI on the linked device: it starts and runs the
|
||||||
|
subagents there, under every rule above. The server carries the chat's Subagents setting down with
|
||||||
|
every turn and the tree, approvals, findings and transcripts up, and offers no subagents of its own
|
||||||
|
to an agent chat. The protocol: `acp.md`, *Subagents in an agent chat* (capabilities `agents` and
|
||||||
|
`tree`).
|
||||||
|
|
||||||
+263
@@ -0,0 +1,263 @@
|
|||||||
|
# Teams
|
||||||
|
|
||||||
|
Spec 3.2.0; the model a member runs on and when a team is offered, 3.3.0 (`subagents.md`); the main agent, the tree, permissions and a subagent's life, 3.4.0 (`subagents.md` §5–§13). **Subagents that work together.** A session's agent — the **lead** — may add
|
||||||
|
**members**, background agents that each own a part of the work, and lead them as a manager: it
|
||||||
|
plans, splits the work, coordinates and reviews, and the members do it. Members talk to each other
|
||||||
|
and to their managers in the team's **room**. A member may itself lead an area with members of its
|
||||||
|
own, so a team grows from a few agents side by side into a small company with one goal. The same in
|
||||||
|
a chat on the server and in an agent session on a device, in both projects.
|
||||||
|
|
||||||
|
The tools are `tools/team_add.json`, `team_send.json`, `team_read.json`, `team_stop.json`; the texts
|
||||||
|
and numbers `loop.json` `team` (and the size notes in `context.note`); the prompt texts
|
||||||
|
`prompts/blocks/team_lead.md` and `team_member.md`; the cases `conformance/team.json`.
|
||||||
|
|
||||||
|
> **Spec 3.4.0 supersedes parts of this page** (`subagents.md` §5–§13, *Subagents v2*): the lead is
|
||||||
|
> **the main agent**, and **only it adds, assigns, replaces and stops** — a member with `lead: true`
|
||||||
|
> is a **sub-manager** that directs the members placed under it (`team_add`'s `parent`) and does not
|
||||||
|
> add or stop (§4, §9 below); members have a mode no looser than their parent's and may ask the main
|
||||||
|
> agent for more (`subagents.md` §7); a member ends with `team_finish` or when its context fills
|
||||||
|
> (`subagents.md` §9); the room carries assignments, requests and decisions besides messages and
|
||||||
|
> reports; the tree goes over the link (§11). Everything else here stands as written: the size
|
||||||
|
> setting and its counting, the room and who receives what, delivery and waking, a manager's
|
||||||
|
> refusals, capacity and waiting for a slot. "Member" here and "subagent" there are the same thing.
|
||||||
|
|
||||||
|
## 1. Words
|
||||||
|
|
||||||
|
- **Agent**: the lead or a member. Every agent has a **name**: the lead is `lead`; a member the name
|
||||||
|
it was added under. Names are unique in the team.
|
||||||
|
- **Member**: an agent added with `team_add`, by the lead or by a member that leads. Its **manager**
|
||||||
|
is the agent that added it. The members an agent added, and theirs, down the tree, are **under**
|
||||||
|
it; with the agent itself they are its **subtree**.
|
||||||
|
- **Manager**: an agent with at least one member, and the lead from the start when the team's `min`
|
||||||
|
is 2 or more (§2). A manager does not do the work (§6).
|
||||||
|
- **Team**: the lead and every member, one per lead session; **its size** is how many agents it has
|
||||||
|
now (§3).
|
||||||
|
- **Room**: the team's messages, numbered from 1 in the order they were posted, each with `n`, `from`
|
||||||
|
(a name), `to` (as addressed: names, or `all`), its recipients (resolved when it was posted), the
|
||||||
|
text, the time, and whether it is a report.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
outside is refused where it is set, with what the bounds are, never clamped silently; a stored
|
||||||
|
setting the cap was lowered under is read as `max = min(max, cap)`, `min = min(min, max)`.
|
||||||
|
- Defaults for a new chat: `min` 1, `max` 4 (`team.agents`) — the lead and up to three members. LLeMbas
|
||||||
|
keeps a per-person default for new chats, seeded with those; the CLI reads them from its
|
||||||
|
configuration.
|
||||||
|
- The setting is kept with the chat. A change during a session is told to the lead as a note on its
|
||||||
|
next user message (`context.md` §10; `context.note.team`, below), never by changing the system
|
||||||
|
prompt.
|
||||||
|
|
||||||
|
**A team is offered** — the setting shown, the team tools listed, `blocks/team_lead.md` in the
|
||||||
|
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. **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 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 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 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.
|
||||||
|
- **`min = 1`, `max ≥ 2`**: the lead decides; a small job it may do alone. Once it has a member it is
|
||||||
|
a manager.
|
||||||
|
|
||||||
|
## 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` 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` 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.
|
||||||
|
|
||||||
|
## 4. Members
|
||||||
|
|
||||||
|
- `team_add` starts the member **at once, in the background**, under the caller: the existing job
|
||||||
|
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). **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
|
||||||
|
final reply: posted in the room to the manager, delivered with the message mark.
|
||||||
|
- **Its system prompt** is built once, when it is added: what a subagent of that agent gets, with
|
||||||
|
`blocks/team_member.md` in place of `blocks/subagent.md` — its name, role, manager, the team's
|
||||||
|
goal, the agent's own instructions, and for one with `lead: true` the whole of
|
||||||
|
`blocks/team_lead.md`. Its first user message is its brief (`task`), as given.
|
||||||
|
- **The team's goal** is what a member is told the team is for: the `goal` the caller gave in
|
||||||
|
`team_add`, else the caller's own goal. The lead's own goal is the last `goal` it gave; **the
|
||||||
|
lead's first `team_add` must give one** (`team.texts.no_goal`). A member already working keeps the
|
||||||
|
goal it was told; a new goal reaches it only as a message.
|
||||||
|
- **`team_add`'s refusals are checked in this order**, the first that applies answering:
|
||||||
|
`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; 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),
|
||||||
|
`finished` (its last turn ended; it can be woken). The lead is `working`, `waiting` or `idle`.
|
||||||
|
- A member's background commands stop when its turn ends (`loop.json` `jobs.lifecycle`). Deleting
|
||||||
|
the lead's session stops the whole team.
|
||||||
|
|
||||||
|
## 5. The room: who receives what
|
||||||
|
|
||||||
|
- **`team_send`** posts one message: `to` a list of names, or `["all"]` (a lone string is read as a
|
||||||
|
one-name list). Names are deduplicated, kept in the order given. `all` anywhere in the list means
|
||||||
|
all, and the other names in it are ignored.
|
||||||
|
- **Recipients**: for named agents, exactly those named; for `all`, every agent of the team but the
|
||||||
|
sender — the lead and every member, whoever manages them (the room is one channel for the whole
|
||||||
|
company). Resolved when the message is posted: an agent added later does not get it.
|
||||||
|
- **Refused, and nothing posted**, checked in this order: `all` when nobody else is on the team
|
||||||
|
(`nobody`); a name that is not on the team (`unknown`, the first such name in the order given);
|
||||||
|
the sender's own name among them (`self`). The answer when
|
||||||
|
posted: `team.texts.sent`, `to` as addressed.
|
||||||
|
- **What an agent receives** is only what is addressed to it, by name or as `all`, and — a manager
|
||||||
|
— **the reports of its own members**. A manager is not sent its members' messages to each other;
|
||||||
|
it reads them with `team_read`, on demand, so nothing floods its context.
|
||||||
|
- **A report**: a member's final reply, when its turn ends, is posted to its manager (`report`
|
||||||
|
true) and delivered to it. An empty reply reads `team.texts.no_report`; a turn that ended
|
||||||
|
otherwise than by the model stopping adds `team.texts.ended` (`steps`, `budget`, `error — <text>`),
|
||||||
|
after a blank line. A sub-manager's final reply is likewise its report to its manager.
|
||||||
|
- **The person's** messages go to the lead as its user messages, not into the room; the lead's
|
||||||
|
answers to the person are not room messages either. The person sees the whole room and the tree
|
||||||
|
in the UI (LLeMbas: the Team panel; the CLI: `/team`), and can stop a member there — said to the
|
||||||
|
lead as a note (`context.note.team_stopped`).
|
||||||
|
- **`team_read`** answers the tree — every agent, the whole team, in tree order: name, role, its
|
||||||
|
manager, state, model, last activity — and the room's messages numbered above `since` (default 0)
|
||||||
|
that the caller may see, oldest first, at most `team.read_max` (50), and how to read on. **Who may
|
||||||
|
see a message**: the lead every one; any other agent those whose sender or one of whose recipients
|
||||||
|
is in its subtree — for a member that leads nobody, what it sent and what it received.
|
||||||
|
|
||||||
|
## 6. A manager does not do the work
|
||||||
|
|
||||||
|
A manager's **work** calls are refused, before the permission check — nothing is asked — with
|
||||||
|
`team.texts.manager`, word for word:
|
||||||
|
|
||||||
|
> You lead a team: give this to a member (team_add or team_send).
|
||||||
|
|
||||||
|
**Work** is a call whose risk class (`risk`; an MCP tool's: `read` when it says `readOnlyHint`, else
|
||||||
|
`execute`) is `write` or `execute`, with two exceptions that stay a manager's:
|
||||||
|
|
||||||
|
- `job_stop` — managing what runs is not work;
|
||||||
|
- a `bash` call **the default rules alone allow in manual mode** (`permission/defaults.json`, the
|
||||||
|
`permission` conformance area's evaluation with no rules of the person's): reading commands —
|
||||||
|
`git status`, `git diff`, `cat`, `ls`, `grep` — for review.
|
||||||
|
|
||||||
|
So with the spec's tools: **refused** — `write`, `edit`, `multiedit`, `apply_patch`, `mkdir`, `mv`,
|
||||||
|
`rm`, `skill_manage` (write); `bash` beyond reading, `python`, `commit`, `branch`, `pr`, `merge`,
|
||||||
|
`tag`, `release` (execute); an MCP tool that does not say it only reads. **Kept** — every `read`
|
||||||
|
tool (`read`, `glob`, `grep`, `list`, `search`, `recall`, `web_search`, `web_fetch`, …), every
|
||||||
|
`interact` tool (`ask_user` for the lead, `plan_*`, `task`, `memory`, `show`, `job_send`, `report`,
|
||||||
|
the team tools, …) and `job_stop`. `team.work` lists the same, and a new tool is placed by its risk
|
||||||
|
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 subagent is delegation, and counts
|
||||||
|
toward the size (§3).
|
||||||
|
|
||||||
|
## 7. Delivery, and waking
|
||||||
|
|
||||||
|
A message or a report reaches its recipient **as a marked message** — the jobs mechanism
|
||||||
|
(`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 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:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Team message from parser to lead:]
|
||||||
|
The grammar is done; the tests are next.
|
||||||
|
```
|
||||||
|
|
||||||
|
- message: `[Team message from {{from}} to {{to}}:]` — `to` as addressed: the names joined with
|
||||||
|
`, `, or `all`; the same for every recipient.
|
||||||
|
- report: `[Team report from {{from}} to {{to}}:]` — `to` the manager.
|
||||||
|
|
||||||
|
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` 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 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 subagent holds a slot.
|
||||||
|
|
||||||
|
## 9. Stopping
|
||||||
|
|
||||||
|
`team_stop` stops one member **and its whole subtree**, or with `all` every member under the caller:
|
||||||
|
their turns stop where they are, their background commands with them, messages waiting for them
|
||||||
|
are dropped, and they **leave the team** — the size drops, their slots free, their names may be
|
||||||
|
used again. No report comes back from a stopped member. Refused, in this order: `all` with nobody
|
||||||
|
under the caller (`nobody_to_stop`); a name not on the team (`unknown`); the caller itself or an
|
||||||
|
agent not under it (`not_yours`). The answer, `stopped`, names the stopped in tree order.
|
||||||
|
|
||||||
|
## 10. What the lead is told
|
||||||
|
|
||||||
|
- `blocks/team_lead.md` — constant, no numbers — is in the system prompt whenever the team tools
|
||||||
|
are listed: how to lead.
|
||||||
|
- The size is in a **note** (`context.md` §10, `context.note`): on the first user message of the
|
||||||
|
session, and on the next one after the setting changed — `team` (`min`, `max`), with `team_lead`
|
||||||
|
after it when `min ≥ 2` (`members` = `min − 1`); when `max` is 1, `team_one` instead. One block.
|
||||||
|
A member the person stopped is its own block, `team_stopped`, with the names. Members are told
|
||||||
|
nothing of the size: `team_add`'s answer says how big the team is.
|
||||||
|
|
||||||
|
## 11. Over the link
|
||||||
|
|
||||||
|
*3.4.0: the tree and the room are carried — `acp.md` capability `tree`; what follows is how a device
|
||||||
|
without it is still shown.*
|
||||||
|
|
||||||
|
|
||||||
|
An agent chat's team runs on the device, as its jobs do. Spec 3.2.0 does not carry the room over
|
||||||
|
ACP yet: a device with the `jobs` capability reports each member as a subagent job (`job` events,
|
||||||
|
`description` being `<name>: <role>`), so the client's job strip shows them, and the room is shown
|
||||||
|
on the device (`/team`). Carrying the room is a later spec's.
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"subagent_run"
|
"subagent_run"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"description": "Hand a self-contained piece of work to a subagent, which works on its own and returns only its final report — keeping the searching and reading out of your context.\n\n- Use it for broad research (\"where is X handled, and what calls it?\") or a separable job. For one file you already know, just read it.\n- `prompt` must stand on its own: the subagent sees nothing of this conversation. Say what to find or do, and what to report back.\n- `background: true` returns at once with a job id and you carry on; the report comes to you as a message when it ends. Meanwhile job_output shows its progress, job_send gives it a message, job_stop stops it. Without it, the call waits for the report.\n- Independent tasks can run in the same turn. Subagents that change files at the same time should each have `worktree: true`, so they work on separate branches.\n- Agents: {{agents}}",
|
"description": "Hand a self-contained piece of work to a subagent, which works on its own and returns only its final report — keeping the searching and reading out of your context.\n\n- Use it for broad research (\"where is X handled, and what calls it?\") or a separable job. For one file you already know, just read it.\n- `prompt` must stand on its own: the subagent sees nothing of this conversation. Say what to find or do, and what to report back.\n- `background: true` returns at once with a job id and you carry on; the report comes to you as a message when it ends. Meanwhile job_output shows its progress, job_send gives it a message, job_stop stops it. Without it, the call waits for the report.\n- Independent tasks can run in the same turn. Subagents that change files at the same time should each have `worktree: true`, so they work on separate branches.\n- It runs on your model. Another model only when the person named it in this conversation; nothing else chooses one.\n- Agents: {{agents}}",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"description": "Run it on another configured model (connection/model, from the list in your instructions) — a second opinion, or a model better at this kind of work. Default: the agent's own model, else yours.",
|
"description": "Run it on another model — only one the person named in this conversation (its id or name, as in the list in your instructions). Default: your model.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"name": "team_add",
|
||||||
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
|
"risk": "interact",
|
||||||
|
"exclusive": false,
|
||||||
|
"block": "task",
|
||||||
|
"purpose": false,
|
||||||
|
"requires": [
|
||||||
|
"team"
|
||||||
|
],
|
||||||
|
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
|
||||||
|
"aliases": {
|
||||||
|
"cli": [],
|
||||||
|
"llembas": []
|
||||||
|
},
|
||||||
|
"description": "Add a subagent and start it at once, in the background, on its part of the work. Only you, the main agent, add subagents. You lead: subagents do the work while you plan, coordinate and review — once you have one, tools that change things are refused to you.\n\n- `name` is how everyone addresses it: lowercase letters, digits and hyphens, after what it owns (`parser`, `api-tests`). `role` is its position, one line: what it owns. `task` is its brief and must stand on its own — it sees nothing of this conversation: what to do, when it is done, and what to report.\n- `goal` is the team's goal in a line; your first subagent needs it, and every subagent is told it.\n- `lead: true` makes it a sub-manager: it directs the subagents you place under it (`parent`), reviews their reports and reports to you; it does not add or stop subagents itself.\n- `mode` is its permission mode: its parent's by default, never looser. With a stricter mode you may give `request` options — kinds of action (read, write, execute) it may ask you for with team_request; you decide with team_decide.\n- The person sets how many agents the team may have, you included; past that this is refused. When the model is already serving as many requests as it can at once, the subagent waits for a slot and starts when one frees.\n- It reports with team_finish (or its final reply). Meanwhile team_send messages it, team_read shows the tree and the room, team_assign gives it new work, team_replace replaces it, team_stop stops it.\n- Agents: {{agents}}",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9-]{0,31}$",
|
||||||
|
"description": "Its name in the team, after what it owns"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Its position, one line: what it owns"
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Its brief, self-contained"
|
||||||
|
},
|
||||||
|
"goal": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The team's goal in one line: needed with your first member; a new one is told to members added from now on"
|
||||||
|
},
|
||||||
|
"lead": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "A sub-manager: directs the subagents placed under it (default: false)"
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Which agent it is (default: general)"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Run it on another model — only one the person named in this conversation (its id or name, as in the list in your instructions). Default: the session's model."
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The sub-manager to place it under (default: you, lead)"
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"auto",
|
||||||
|
"edit",
|
||||||
|
"manual",
|
||||||
|
"plan"
|
||||||
|
],
|
||||||
|
"description": "Its permission mode: its parent's or stricter (default: its parent's)"
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"read",
|
||||||
|
"write",
|
||||||
|
"execute"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "With a stricter mode only: kinds of action it may ask you for (team_request)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"role",
|
||||||
|
"task"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"variables": {
|
||||||
|
"agents": "the agents a member may be: `name — description`, separated by \"; \" (as task's)"
|
||||||
|
},
|
||||||
|
"offered": "main"
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "team_assign",
|
||||||
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
|
"risk": "interact",
|
||||||
|
"exclusive": false,
|
||||||
|
"block": "tool",
|
||||||
|
"purpose": false,
|
||||||
|
"requires": [
|
||||||
|
"team"
|
||||||
|
],
|
||||||
|
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
|
||||||
|
"aliases": {
|
||||||
|
"cli": [],
|
||||||
|
"llembas": []
|
||||||
|
},
|
||||||
|
"offered": "main",
|
||||||
|
"description": "Give a subagent that has not ended a new task: it is told at its next step, or woken. Optionally a new position (`role`), another sub-manager to work under (`parent`, or lead), or a stricter `mode`.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"auto",
|
||||||
|
"edit",
|
||||||
|
"manual",
|
||||||
|
"plan"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"task"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "team_decide",
|
||||||
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
|
"risk": "interact",
|
||||||
|
"exclusive": false,
|
||||||
|
"block": "tool",
|
||||||
|
"purpose": false,
|
||||||
|
"requires": [
|
||||||
|
"team"
|
||||||
|
],
|
||||||
|
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
|
||||||
|
"aliases": {
|
||||||
|
"cli": [],
|
||||||
|
"llembas": []
|
||||||
|
},
|
||||||
|
"offered": "main",
|
||||||
|
"description": "Decide a subagent's request (team_request): allow it or refuse it, with a note it is told. You may allow only what your own mode would allow you; anything beyond that goes to the person when the subagent tries.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"allow": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"note": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"allow"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "team_findings",
|
||||||
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
|
"risk": "interact",
|
||||||
|
"exclusive": false,
|
||||||
|
"block": "tool",
|
||||||
|
"purpose": false,
|
||||||
|
"requires": [
|
||||||
|
"team"
|
||||||
|
],
|
||||||
|
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
|
||||||
|
"aliases": {
|
||||||
|
"cli": [],
|
||||||
|
"llembas": []
|
||||||
|
},
|
||||||
|
"description": "Findings kept beyond your context: `write` adds one (a fact, a result, where something is) to yours; `read` shows yours, or with `of` those of a subagent under you (the main agent: anyone's), ended ones included. Write as you go: when your context fills you have one turn left, and your findings are what the next agent starts from.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"write",
|
||||||
|
"read"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "write: the finding"
|
||||||
|
},
|
||||||
|
"of": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "read: a name or an id (default: yours)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"action"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "team_finish",
|
||||||
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
|
"risk": "interact",
|
||||||
|
"exclusive": false,
|
||||||
|
"block": "tool",
|
||||||
|
"purpose": false,
|
||||||
|
"requires": [
|
||||||
|
"team"
|
||||||
|
],
|
||||||
|
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
|
||||||
|
"aliases": {
|
||||||
|
"cli": [],
|
||||||
|
"llembas": []
|
||||||
|
},
|
||||||
|
"offered": "subagent",
|
||||||
|
"description": "Your task is done: send your report to your parent and end. Make the report complete on its own — the result, then the evidence (file:line), then anything you could not settle. Save anything worth keeping beyond the report with team_findings first. Nothing runs after this call.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"report": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"report"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "team_read",
|
||||||
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
|
"risk": "read",
|
||||||
|
"exclusive": false,
|
||||||
|
"block": "tool",
|
||||||
|
"purpose": false,
|
||||||
|
"requires": [
|
||||||
|
"team"
|
||||||
|
],
|
||||||
|
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
|
||||||
|
"aliases": {
|
||||||
|
"cli": [],
|
||||||
|
"llembas": []
|
||||||
|
},
|
||||||
|
"description": "The tree and its room: every agent (name, id, position, parent, state, model, mode, context use, last activity), open slots, subagents that have ended, and the room's messages after `since` that are yours to see — those you sent or received and those of the subagents under you; the main agent sees them all. Messages are numbered: pass the last number you saw as `since` to read only what is new.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"since": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "Show messages after this number (default: 0, from the start)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "team_replace",
|
||||||
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
|
"risk": "interact",
|
||||||
|
"exclusive": false,
|
||||||
|
"block": "task",
|
||||||
|
"purpose": false,
|
||||||
|
"requires": [
|
||||||
|
"team"
|
||||||
|
],
|
||||||
|
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
|
||||||
|
"aliases": {
|
||||||
|
"cli": [],
|
||||||
|
"llembas": []
|
||||||
|
},
|
||||||
|
"offered": "main",
|
||||||
|
"description": "Replace a subagent — one that ran out of context, or one that is stuck: it ends (stopped, if it has not already), and a new one with the same name, position, parent, mode and model takes its place, briefed with `task` (default: the old task) and the old one's findings. The subagents under it move under the new one.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "team_request",
|
||||||
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
|
"risk": "interact",
|
||||||
|
"exclusive": false,
|
||||||
|
"block": "tool",
|
||||||
|
"purpose": false,
|
||||||
|
"requires": [
|
||||||
|
"team"
|
||||||
|
],
|
||||||
|
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
|
||||||
|
"aliases": {
|
||||||
|
"cli": [],
|
||||||
|
"llembas": []
|
||||||
|
},
|
||||||
|
"offered": "subagent",
|
||||||
|
"description": "Ask the main agent to allow a call your mode does not, of a kind your request options name. Say the tool, the permission pattern you need (a command such as `git push *`, or a path) and why. Its decision comes to you as a message; once allowed, make the call again.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tool": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"pattern": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"why": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"tool",
|
||||||
|
"pattern",
|
||||||
|
"why"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "team_send",
|
||||||
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
|
"risk": "interact",
|
||||||
|
"exclusive": false,
|
||||||
|
"block": "tool",
|
||||||
|
"purpose": false,
|
||||||
|
"requires": [
|
||||||
|
"team"
|
||||||
|
],
|
||||||
|
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
|
||||||
|
"aliases": {
|
||||||
|
"cli": [],
|
||||||
|
"llembas": []
|
||||||
|
},
|
||||||
|
"description": "Send a message through the team's room: to one or more agents by name, to `parent` (the agent you report to), or to `all`. It reaches each at its next step, or wakes one that has finished, its context intact. Message the agent who owns a thing; use `all` only for what everyone needs. The main agent is `lead`.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"to": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 1,
|
||||||
|
"description": "Names, [\"parent\"], or [\"all\"]"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"to",
|
||||||
|
"message"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "team_stop",
|
||||||
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
|
"risk": "interact",
|
||||||
|
"exclusive": false,
|
||||||
|
"block": "tool",
|
||||||
|
"purpose": false,
|
||||||
|
"requires": [
|
||||||
|
"team"
|
||||||
|
],
|
||||||
|
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
|
||||||
|
"aliases": {
|
||||||
|
"cli": [],
|
||||||
|
"llembas": []
|
||||||
|
},
|
||||||
|
"description": "Stop a subagent, and the subagents under it, where they are: their work stops and they end, which makes room for others. Only you, the main agent, stop subagents; `all` stops every one. Nothing they had not reported comes back — ask for a report with team_send first, or read their findings with team_findings.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A subagent's name, or all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"offered": "main"
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "team_whoami",
|
||||||
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
|
"risk": "read",
|
||||||
|
"exclusive": false,
|
||||||
|
"block": "tool",
|
||||||
|
"purpose": false,
|
||||||
|
"requires": [
|
||||||
|
"team"
|
||||||
|
],
|
||||||
|
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
|
||||||
|
"aliases": {
|
||||||
|
"cli": [],
|
||||||
|
"llembas": []
|
||||||
|
},
|
||||||
|
"description": "Who you are on the team: your id, name, position, parent, task, permission mode and request options, model, limits, and how full your context is.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -324,5 +324,5 @@ say ""
|
|||||||
say "Done. Open a new shell (or: source your rc file), then run: lembas"
|
say "Done. Open a new shell (or: source your rc file), then run: lembas"
|
||||||
[ -n "$extra" ] && say "Aliases: $(printf '%s' "$extra" | sed -n "s/^alias \([a-z]*\)=.*/\1/p" | paste -sd' ')"
|
[ -n "$extra" ] && say "Aliases: $(printf '%s' "$extra" | sed -n "s/^alias \([a-z]*\)=.*/\1/p" | paste -sd' ')"
|
||||||
if [ "$starter" -eq 1 ] && [ -z "$(sed -n 's/^model: *//p' "$CONFIG/config.yaml")" ]; then
|
if [ "$starter" -eq 1 ] && [ -z "$(sed -n 's/^model: *//p' "$CONFIG/config.yaml")" ]; then
|
||||||
say "Next: add a connection to $CONFIG/connections.yaml and set model: in config.yaml."
|
say "Next: lembas login <your LLeMbas server address>"
|
||||||
fi
|
fi
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lembas-cli",
|
"name": "lembas-cli",
|
||||||
"version": "2.0.0-beta.2",
|
"version": "2.0.0-beta.7",
|
||||||
"description": "LLeMbas CLI: a terminal coding agent and project manager for a LLeMbas server — OpenTUI, memory, skills, MCP, git-native.",
|
"description": "LLeMbas CLI: a terminal coding agent and project manager for a LLeMbas server — OpenTUI, memory, skills, MCP, git-native.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -56,6 +56,10 @@
|
|||||||
"description": "false: leave the mouse to the terminal — its own selection and Ctrl+Shift+C work without Shift, and the wheel no longer scrolls the transcript (pgup/pgdn do). Default true.",
|
"description": "false: leave the mouse to the terminal — its own selection and Ctrl+Shift+C work without Shift, and the wheel no longer scrolls the transcript (pgup/pgdn do). Default true.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"notify": {
|
||||||
|
"description": "When a reply finishes, or the agent waits for you, while the terminal is not focused: ring the bell and ask the terminal for a desktop notification (OSC 9) naming the session — never what the reply says. Default true; false: neither.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"icons": {
|
"icons": {
|
||||||
"description": "emoji (default), or plain: one-column symbols for a terminal font that has no emoji.",
|
"description": "emoji (default), or plain: one-column symbols for a terminal font that has no emoji.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -870,7 +874,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"effort": {
|
"effort": {
|
||||||
"description": "The effort a session starts with, when the model takes it; otherwise the model's own default.",
|
"description": "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.",
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -886,9 +890,42 @@
|
|||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "off"
|
"const": "off"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"const": "default"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"agents": {
|
||||||
|
"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": "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": "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
|
||||||
|
},
|
||||||
|
"cap": {
|
||||||
|
"description": "Global config only. The highest max a session may set (default 32).",
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 9007199254740991
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"subagents": {
|
||||||
|
"description": "false: no subagents — no task tool and no team.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"settings_tool": {
|
"settings_tool": {
|
||||||
"description": "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.",
|
"description": "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.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -1042,6 +1079,10 @@
|
|||||||
"description": "Let the web UI open a terminal here (LLeMbas's terminal panel on a chat on this device): a login shell as this user, in a directory remote sessions may use. Default false.",
|
"description": "Let the web UI open a terminal here (LLeMbas's terminal panel on a chat on this device): a login shell as this user, in a directory remote sessions may use. Default false.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"scheduled": {
|
||||||
|
"description": "Accept scheduled work (harness acp.md, spec 3.5.0): let your schedules on the LLeMbas instance run here as agent turns, in the folders remote work may use, in the schedule's mode (Edit unless it says Auto) lowered to max_mode. Default false. lembas service scheduled on|off.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"terminal_integration": {
|
"terminal_integration": {
|
||||||
"description": "Shell integration in those terminals (bash, zsh, fish): after your own startup files, the shell marks each prompt, command and exit status (OSC 133) and its directory (OSC 7), so the web UI shows the directory, a mark beside each finished command, and can jump between commands. false: the shell starts as it is. Default true.",
|
"description": "Shell integration in those terminals (bash, zsh, fish): after your own startup files, the shell marks each prompt, command and exit status (OSC 133) and its directory (OSC 7), so the web UI shows the directory, a mark beside each finished command, and can jump between commands. false: the shell starts as it is. Default true.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
"maximum": 1
|
"maximum": 1
|
||||||
},
|
},
|
||||||
"efforts": {
|
"efforts": {
|
||||||
"description": "The model's own effort vocabulary, in offering order.",
|
"description": "The model's own effort vocabulary, in offering order. [] — it takes none (its one level is `default`).",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -141,8 +141,12 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"thinking_switch": {
|
||||||
|
"description": "The server turns thinking off for a request with chat_template_kwargs.enable_thinking false: only then is the level `off` offered.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"effort": {
|
"effort": {
|
||||||
"description": "Default effort; `off` sends none.",
|
"description": "The level a conversation starts with, when the model offers it: an effort, `off` (thinking switched off, with thinking_switch) or `default` (nothing sent, for a model that takes no efforts).",
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -158,6 +162,10 @@
|
|||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "off"
|
"const": "off"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"const": "default"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -202,9 +210,15 @@
|
|||||||
"maxLength": 300
|
"maxLength": 300
|
||||||
},
|
},
|
||||||
"single_session": {
|
"single_session": {
|
||||||
"description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused.",
|
"description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused (name a model on another server instead). The same as parallel: 1.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"parallel": {
|
||||||
|
"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
|
||||||
|
},
|
||||||
"group": {
|
"group": {
|
||||||
"description": "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.",
|
"description": "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.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|||||||
+160
-20
@@ -33,7 +33,8 @@ import { addPendingDeleted, clearPendingDeleted, pendingDeleted } from "./pendin
|
|||||||
import { shellLaunch } from "./shellrc.ts"
|
import { shellLaunch } from "./shellrc.ts"
|
||||||
import { sessionTurnFile, sessionTurns, TurnLog } from "./turns.ts"
|
import { sessionTurnFile, sessionTurns, TurnLog } from "./turns.ts"
|
||||||
import { expandHome } from "../config/paths.ts"
|
import { expandHome } from "../config/paths.ts"
|
||||||
import { asMode, EFFORTS, MODES, type Effort, type Mode } from "../config/schema.ts"
|
import { asMode, MODES, type Mode } from "../config/schema.ts"
|
||||||
|
import { isLevel, readLevel, type Level } from "../provider/effort.ts"
|
||||||
import { HARNESS_VERSION } from "../harness.ts"
|
import { HARNESS_VERSION } from "../harness.ts"
|
||||||
import { findProject, trustedRoots, trustedWithin, trustOf } from "../project/root.ts"
|
import { findProject, trustedRoots, trustedWithin, trustOf } from "../project/root.ts"
|
||||||
import { VERSION } from "../version.ts"
|
import { VERSION } from "../version.ts"
|
||||||
@@ -41,8 +42,9 @@ import { CWD_META, REMOTE_META, Store } from "../session/store.ts"
|
|||||||
import type { Message } from "../provider/types.ts"
|
import type { Message } from "../provider/types.ts"
|
||||||
import type { Hub } from "./hub.ts"
|
import type { Hub } from "./hub.ts"
|
||||||
import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts"
|
import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts"
|
||||||
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs } from "./jobs.ts"
|
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs, teamFindings, teamStop, teamTranscript, teamView, treeOf } from "./jobs.ts"
|
||||||
import { isNote } from "../session/prefix.ts"
|
import { isNote } from "../session/prefix.ts"
|
||||||
|
import { deniedText, finalReply, judgeMonitor, monitorTimeout, permissionFor, refusalText, runMonitor, runOutcome, scheduledMode, type ScheduledTurn } from "./scheduled.ts"
|
||||||
|
|
||||||
export const ACP_VERSION = 1
|
export const ACP_VERSION = 1
|
||||||
/** The link's version (LLeMbas `services/device_link.py:PROTOCOL`, harness/acp.md *Versions*). 3:
|
/** The link's version (LLeMbas `services/device_link.py:PROTOCOL`, harness/acp.md *Versions*). 3:
|
||||||
@@ -67,6 +69,8 @@ export interface Limits {
|
|||||||
/** `remote.enabled`, where the limits are the device's (the link, the service's hub). False:
|
/** `remote.enabled`, where the limits are the device's (the link, the service's hub). False:
|
||||||
* nothing new is opened or shared, whatever the roots say. Absent: not judged here. */
|
* nothing new is opened or shared, whatever the roots say. Absent: not judged here. */
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
|
/** Accept scheduled work (`remote.scheduled`, spec 3.5.0), with remote work on. Absent: no. */
|
||||||
|
scheduled?: boolean
|
||||||
/** The link a terminal holds for /remote on a machine whose remote work is off: only
|
/** The link a terminal holds for /remote on a machine whose remote work is off: only
|
||||||
* the sessions shared from that terminal can be reached; the web UI opens none of its own. */
|
* the sessions shared from that terminal can be reached; the web UI opens none of its own. */
|
||||||
sharedOnly?: boolean
|
sharedOnly?: boolean
|
||||||
@@ -131,10 +135,10 @@ interface Terminal {
|
|||||||
/** Most terminals one link may hold at once. */
|
/** Most terminals one link may hold at once. */
|
||||||
const MAX_TERMINALS = 4
|
const MAX_TERMINALS = 4
|
||||||
|
|
||||||
/** An effort as the instance sends it: a level, or "off"; anything else is not one. */
|
/** An effort level as the instance sends it (an effort, "off" or "default"); anything else is not
|
||||||
export function effortOf(v: unknown): Effort | "off" | undefined {
|
* one. Whether the model offers it is the session's to decide (readLevel). */
|
||||||
if (v === "off" || v === "" || v === null) return "off"
|
export function effortOf(v: unknown): Level | undefined {
|
||||||
return typeof v === "string" && (EFFORTS as readonly string[]).includes(v) ? (v as Effort) : undefined
|
return isLevel(v) ? v : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODE_RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
|
const MODE_RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
|
||||||
@@ -179,6 +183,9 @@ export interface Session {
|
|||||||
turns: TurnLog<PromptResult>
|
turns: TurnLog<PromptResult>
|
||||||
/** The command set last sent to the client, to send it again only when it changed. */
|
/** The command set last sent to the client, to send it again only when it changed. */
|
||||||
commands?: string
|
commands?: string
|
||||||
|
/** The scheduled turn it runs now (spec 3.5.0): its mode is the ceiling, its approvals wait
|
||||||
|
* the schedule's wait and say so, and nobody answers a question or a plan. */
|
||||||
|
scheduled?: ScheduledTurn
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What `session/prompt` answers. */
|
/** What `session/prompt` answers. */
|
||||||
@@ -272,6 +279,16 @@ export const CAPABILITIES = [
|
|||||||
// Harness spec 3.0.0: the show tool, offered in a session the client opened when the client
|
// Harness spec 3.0.0: the show tool, offered in a session the client opened when the client
|
||||||
// said it can show files; the device sends the file with `_lembas/show`.
|
// said it can show files; the device sends the file with `_lembas/show`.
|
||||||
"show",
|
"show",
|
||||||
|
// Harness spec 3.4.0: the progress phases (the `phase` event, `progress` in status); the subagent
|
||||||
|
// tree (the `team` event with the tree, `_lembas/team*`, `subagent` on an approval); the chat's
|
||||||
|
// Subagents setting read from `_meta.lembas.agents` (an agent chat's subagents are this device's).
|
||||||
|
"progress",
|
||||||
|
"tree",
|
||||||
|
"agents",
|
||||||
|
// Harness spec 3.5.0: scheduled turns (`scheduled` in session/new and session/prompt, the run's
|
||||||
|
// outcome in the answer, `scheduled` on an approval) and `_lembas/monitor`. Whether this device
|
||||||
|
// accepts them now is `limits.scheduled`.
|
||||||
|
"scheduled",
|
||||||
] as const
|
] as const
|
||||||
export const STOP: Record<string, string> = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" }
|
export const STOP: Record<string, string> = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" }
|
||||||
|
|
||||||
@@ -280,7 +297,7 @@ type AskRequest = Parameters<Asker["ask"]>[0]
|
|||||||
/** `session/request_permission`'s params for an approval — the same card whichever process asks
|
/** `session/request_permission`'s params for an approval — the same card whichever process asks
|
||||||
* (the hub for a session a terminal runs). `patient`: a person may answer at the terminal
|
* (the hub for a session a terminal runs). `patient`: a person may answer at the terminal
|
||||||
* too, so the instance waits rather than timing the card out. */
|
* too, so the instance waits rather than timing the card out. */
|
||||||
export function permissionParams(sessionId: string, req: AskRequest, o: { patient?: boolean; toolCallId?: string } = {}) {
|
export function permissionParams(sessionId: string, req: AskRequest, o: { patient?: boolean; toolCallId?: string; scheduled?: ScheduledTurn } = {}) {
|
||||||
const offerAlways = !req.request.alwaysAsk
|
const offerAlways = !req.request.alwaysAsk
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -305,6 +322,11 @@ export function permissionParams(sessionId: string, req: AskRequest, o: { patien
|
|||||||
...(o.patient ? { patient: true } : {}),
|
...(o.patient ? { patient: true } : {}),
|
||||||
// Which subagent asks (capability jobs), so the card can say — it may ask while the session itself is idle.
|
// Which subagent asks (capability jobs), so the card can say — it may ask while the session itself is idle.
|
||||||
...(req.source ? { job: { id: req.source.jobId, agent: req.source.agent, description: req.source.description } } : {}),
|
...(req.source ? { job: { id: req.source.jobId, agent: req.source.agent, description: req.source.description } } : {}),
|
||||||
|
// Spec 3.4.0 (capability tree): a subagent of the tree whose call only the person can allow.
|
||||||
|
...(req.source?.subagent ? { subagent: req.source.subagent } : {}),
|
||||||
|
// Spec 3.5.0 (capability scheduled): a scheduled turn's call — the client puts it to the
|
||||||
|
// person where they are, and the device waits the schedule's wait.
|
||||||
|
...(o.scheduled ? { scheduled: { schedule_id: o.scheduled.scheduleId, run_id: o.scheduled.runId, wait_s: Math.round(o.scheduled.waitMs / 1000) } } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -467,6 +489,11 @@ export class AcpAgent {
|
|||||||
peer.handle("_lembas/job/stop", (p) => this.job("stop", p))
|
peer.handle("_lembas/job/stop", (p) => this.job("stop", p))
|
||||||
peer.handle("_lembas/job/send", (p) => this.job("send", p))
|
peer.handle("_lembas/job/send", (p) => this.job("send", p))
|
||||||
peer.handle("_lembas/job/transcript", (p) => this.job("transcript", p))
|
peer.handle("_lembas/job/transcript", (p) => this.job("transcript", p))
|
||||||
|
peer.handle("_lembas/team", (p) => this.tree("team", p))
|
||||||
|
peer.handle("_lembas/team/transcript", (p) => this.tree("team_transcript", p))
|
||||||
|
peer.handle("_lembas/team/findings", (p) => this.tree("team_findings", p))
|
||||||
|
peer.handle("_lembas/team/stop", (p) => this.tree("team_stop", p))
|
||||||
|
peer.handle("_lembas/monitor", (p) => this.monitor(p))
|
||||||
peer.on("_lembas/terminal/input", (p) => this.terminalInput(p))
|
peer.on("_lembas/terminal/input", (p) => this.terminalInput(p))
|
||||||
peer.on("_lembas/terminal/resize", (p) => this.terminalResize(p))
|
peer.on("_lembas/terminal/resize", (p) => this.terminalResize(p))
|
||||||
peer.on("_lembas/terminal/close", (p) => this.terminalClose(String(p?.terminalId ?? "")))
|
peer.on("_lembas/terminal/close", (p) => this.terminalClose(String(p?.terminalId ?? "")))
|
||||||
@@ -515,7 +542,7 @@ export class AcpAgent {
|
|||||||
lembas: {
|
lembas: {
|
||||||
protocol: this.protocol,
|
protocol: this.protocol,
|
||||||
harness_spec: HARNESS_VERSION,
|
harness_spec: HARNESS_VERSION,
|
||||||
limits: this.limits ? { roots: this.limits.roots, max_mode: this.limits.maxMode } : null,
|
limits: this.limits ? { roots: this.limits.roots, max_mode: this.limits.maxMode, scheduled: this.limits.scheduled === true } : null,
|
||||||
// The login's connection: a `model` event's ref `<connection>/<name>` is the
|
// The login's connection: a `model` event's ref `<connection>/<name>` is the
|
||||||
// instance's own model `<name>`; any other ref is one only this device has.
|
// instance's own model `<name>`; any other ref is one only this device has.
|
||||||
connection: this.instanceConnection ?? null,
|
connection: this.instanceConnection ?? null,
|
||||||
@@ -574,12 +601,15 @@ export class AcpAgent {
|
|||||||
const cwd = typeof p?.cwd === "string" ? expandHome(p.cwd) : ""
|
const cwd = typeof p?.cwd === "string" ? expandHome(p.cwd) : ""
|
||||||
if (!cwd || !isAbsolute(cwd)) throw new RpcError(INVALID_PARAMS, "cwd must be an absolute path")
|
if (!cwd || !isAbsolute(cwd)) throw new RpcError(INVALID_PARAMS, "cwd must be an absolute path")
|
||||||
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) throw new RpcError(INVALID_PARAMS, `${cwd} is not a directory on this machine`)
|
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) throw new RpcError(INVALID_PARAMS, `${cwd} is not a directory on this machine`)
|
||||||
|
// Spec 3.5.0: a scheduled session is judged first — off is the same fixed refusal whatever else.
|
||||||
|
const scheduled = this.judgeScheduled(p?._meta?.lembas?.scheduled)
|
||||||
const refused = this.refusal(cwd)
|
const refused = this.refusal(cwd)
|
||||||
if (refused) throw new RpcError(INVALID_PARAMS, refused)
|
if (refused) throw new RpcError(INVALID_PARAMS, refused, scheduled ? { reason: "folder" } : undefined)
|
||||||
const L = this.limits
|
const L = this.limits
|
||||||
const wanted = asMode(p?._meta?.mode) ?? undefined
|
const wanted = asMode(p?._meta?.mode) ?? undefined
|
||||||
const mode = L ? clampMode(wanted ?? L.maxMode, L.maxMode) : wanted
|
// A scheduled session runs in the schedule's mode, never `_meta.mode`.
|
||||||
const session: Session = { id: "", cwd, prompting: false, app: undefined as unknown as App, turns: new TurnLog() }
|
const mode = scheduled ? scheduled.mode : L ? clampMode(wanted ?? L.maxMode, L.maxMode) : wanted
|
||||||
|
const session: Session = { id: "", cwd, prompting: false, app: undefined as unknown as App, turns: new TurnLog(), ...(scheduled ? { scheduled } : {}) }
|
||||||
// No model-made title: the client names its own conversations (LLeMbas has its chat's), and a
|
// No model-made title: the client names its own conversations (LLeMbas has its chat's), and a
|
||||||
// title request would be a second call the account pays for, for nothing.
|
// title request would be a second call the account pays for, for nothing.
|
||||||
// The chat's model and effort: `lembas_model` is the instance's own name for it, the
|
// The chat's model and effort: `lembas_model` is the instance's own name for it, the
|
||||||
@@ -619,7 +649,7 @@ export class AcpAgent {
|
|||||||
sessionId: id,
|
sessionId: id,
|
||||||
modes: { currentModeId: session.app.engine.mode, availableModes: this.modes().map((m) => ({ id: m, name: m })) },
|
modes: { currentModeId: session.app.engine.mode, availableModes: this.modes().map((m) => ({ id: m, name: m })) },
|
||||||
// `instance` (spec 3.0.0): the model is one this link's instance serves, as the model event says.
|
// `instance` (spec 3.0.0): the model is one this link's instance serves, as the model event says.
|
||||||
_meta: { lembas: { model: session.app.engine.model.ref, mode: session.app.engine.mode, effort: session.app.engine.effort ?? "off", instance: this.isInstance(session.app.engine.model.connectionName) } },
|
_meta: { lembas: { model: session.app.engine.model.ref, mode: session.app.engine.mode, effort: session.app.engine.effort, instance: this.isInstance(session.app.engine.model.connectionName) } },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,8 +702,11 @@ export class AcpAgent {
|
|||||||
s.app.switchModel(ref)
|
s.app.switchModel(ref)
|
||||||
}
|
}
|
||||||
const effort = effortOf(p?.effort)
|
const effort = effortOf(p?.effort)
|
||||||
if (effort !== undefined) s.app.engine.effort = effort === "off" ? null : effort
|
// A level the model does not offer is read as its start level; the answer says the one used.
|
||||||
return { model: s.app.engine.model.ref, effort: s.app.engine.effort ?? "off" }
|
if (effort !== undefined) s.app.engine.effort = readLevel(s.app.engine.model, effort)
|
||||||
|
// Spec 3.4.0: the chat's Subagents setting, changed between turns.
|
||||||
|
if (p?.agents) this.takeAgents(s, { agents: p.agents })
|
||||||
|
return { model: s.app.engine.model.ref, effort: s.app.engine.effort }
|
||||||
}
|
}
|
||||||
|
|
||||||
private modes(): Mode[] {
|
private modes(): Mode[] {
|
||||||
@@ -722,6 +755,19 @@ export class AcpAgent {
|
|||||||
private takeContext(s: Session, p: any) {
|
private takeContext(s: Session, p: any) {
|
||||||
const ctx = contextOf(p?._meta)
|
const ctx = contextOf(p?._meta)
|
||||||
if (ctx) s.app.setContext(ctx, this.instanceConnection)
|
if (ctx) s.app.setContext(ctx, this.instanceConnection)
|
||||||
|
this.takeAgents(s, p?._meta?.lembas)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spec 3.4.0 (capability agents): the chat's Subagents setting, sent with every turn by the
|
||||||
|
* instance — absent (an older one, an editor on serve --stdio): the device's own, as before.
|
||||||
|
* `said`: the person's earlier words in the chat. */
|
||||||
|
private takeAgents(s: Session, m: any) {
|
||||||
|
if (!this.lembas) return
|
||||||
|
const team = s.app.team.team
|
||||||
|
const a = m?.agents
|
||||||
|
if (Number.isInteger(a?.min) && Number.isInteger(a?.max) && a.min >= 1 && a.max >= 1) team.setSetting(Math.min(a.min, a.max), a.max)
|
||||||
|
if (m?.subagents === false) team.subagents = false
|
||||||
|
if (Array.isArray(m?.said) && !s.app.engine.saidBefore.length) s.app.engine.saidBefore = m.said.filter((x: unknown): x is string => typeof x === "string")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The session's plan, as a `plan` update — after session/new and after opening from the
|
/** The session's plan, as a `plan` update — after session/new and after opening from the
|
||||||
@@ -749,7 +795,8 @@ export class AcpAgent {
|
|||||||
* `_lembas/show`, where the client said it shows files; otherwise none, and show is not offered. */
|
* `_lembas/show`, where the client said it shows files; otherwise none, and show is not offered. */
|
||||||
private equipShow(s: Session) {
|
private equipShow(s: Session) {
|
||||||
const ctx = s.app.engine.o.toolCtx
|
const ctx = s.app.engine.o.toolCtx
|
||||||
if (!this.shows) return void delete ctx.show
|
// Nobody is looking at a scheduled run (spec 3.5.0): show is not offered there.
|
||||||
|
if (!this.shows || s.scheduled) return void delete ctx.show
|
||||||
ctx.show = async (req, callId) => {
|
ctx.show = async (req, callId) => {
|
||||||
if (s.agent !== this || this.peer.isClosed) return { shown: false, reason: "the client is not connected" }
|
if (s.agent !== this || this.peer.isClosed) return { shown: false, reason: "the client is not connected" }
|
||||||
const r = (await this.peer.request("_lembas/show", { sessionId: s.id, toolCallId: callId ?? "", ...req }, 30_000)) as { shown?: unknown; reason?: unknown } | undefined
|
const r = (await this.peer.request("_lembas/show", { sessionId: s.id, toolCallId: callId ?? "", ...req }, 30_000)) as { shown?: unknown; reason?: unknown } | undefined
|
||||||
@@ -965,12 +1012,18 @@ export class AcpAgent {
|
|||||||
// A turn id this session has taken already: not run again — the original's answer,
|
// A turn id this session has taken already: not run again — the original's answer,
|
||||||
// now or when it is done.
|
// now or when it is done.
|
||||||
if (s.turns.has(turnId)) return s.turns.run(turnId, () => Promise.reject(new Error("unreachable")))
|
if (s.turns.has(turnId)) return s.turns.run(turnId, () => Promise.reject(new Error("unreachable")))
|
||||||
|
// Spec 3.5.0: a scheduled turn is judged again (the setting may have been turned off since the
|
||||||
|
// session was opened); a prompt without `scheduled` is the person's, in the run's agent chat.
|
||||||
|
const scheduled = this.judgeScheduled(p?._meta?.lembas?.scheduled)
|
||||||
this.takeContext(s, p)
|
this.takeContext(s, p)
|
||||||
const prepared = preparePrompt(s.app, s.id, p.prompt, { roots: this.limits?.roots })
|
const prepared = preparePrompt(s.app, s.id, p.prompt, { roots: this.limits?.roots })
|
||||||
// Working already: the prompt waits for the turn to end and runs next. It used to be
|
// Working already: the prompt waits for the turn to end and runs next. It used to be
|
||||||
// refused, and a web UI that had misjudged the session as idle lost its message to an error.
|
// refused, and a web UI that had misjudged the session as idle lost its message to an error.
|
||||||
const run = async (started: () => void): Promise<PromptResult> => {
|
const run = async (started: () => void): Promise<PromptResult> => {
|
||||||
s.prompting = true
|
s.prompting = true
|
||||||
|
// The turn that runs now says whether the session is a scheduled run's (it may wait behind another).
|
||||||
|
if (scheduled) s.scheduled = scheduled
|
||||||
|
else delete s.scheduled
|
||||||
started()
|
started()
|
||||||
try {
|
try {
|
||||||
return await this.runPrepared(s, prepared, turnId)
|
return await this.runPrepared(s, prepared, turnId)
|
||||||
@@ -994,6 +1047,8 @@ export class AcpAgent {
|
|||||||
* the expanded prompt with its attachments, under the command's model and mode for this turn. */
|
* the expanded prompt with its attachments, under the command's model and mode for this turn. */
|
||||||
private async runPrepared(s: Session, prepared: ReturnType<typeof preparePrompt>, turnId: string): Promise<PromptResult> {
|
private async runPrepared(s: Session, prepared: ReturnType<typeof preparePrompt>, turnId: string): Promise<PromptResult> {
|
||||||
const app = s.app
|
const app = s.app
|
||||||
|
const turn = s.scheduled
|
||||||
|
if (turn) return this.runScheduled(s, prepared, turnId, turn)
|
||||||
const done = (meta: Record<string, unknown>): PromptResult => ({ stopReason: "end_turn", _meta: { lembas: { turnId, ...meta } } })
|
const done = (meta: Record<string, unknown>): PromptResult => ({ stopReason: "end_turn", _meta: { lembas: { turnId, ...meta } } })
|
||||||
if (prepared.kind === "message") {
|
if (prepared.kind === "message") {
|
||||||
app.bus.emit({ type: "notice", message: `/${prepared.name}: ${prepared.text}` })
|
app.bus.emit({ type: "notice", message: `/${prepared.name}: ${prepared.text}` })
|
||||||
@@ -1039,6 +1094,68 @@ export class AcpAgent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A scheduled request's settings as this device runs them (spec 3.5.0), or undefined when the
|
||||||
|
* request is not scheduled; a refusal is thrown as -32602 with `data.reason`. */
|
||||||
|
private judgeScheduled(meta: any): ScheduledTurn | undefined {
|
||||||
|
if (meta === undefined || meta === null) return undefined
|
||||||
|
const m = typeof meta === "object" ? meta : {}
|
||||||
|
const L = this.limits
|
||||||
|
const judged = scheduledMode({ accept: L?.scheduled === true && L.enabled !== false && !L.sharedOnly, mode: m.mode, max_mode: L?.maxMode ?? "edit", approval_wait_s: m.approval_wait_s })
|
||||||
|
if ("refused" in judged) throw new RpcError(INVALID_PARAMS, refusalText(judged.refused as "scheduled_off" | "plan"), { reason: judged.refused })
|
||||||
|
const str = (v: unknown) => (typeof v === "string" ? v.slice(0, 200) : "")
|
||||||
|
return { scheduleId: str(m.schedule_id), runId: str(m.run_id), title: str(m.title), mode: judged.mode, waitMs: judged.wait_s * 1000 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One scheduled turn: the schedule's mode is the ceiling for the turn — a command in the prompt
|
||||||
|
* narrows it, never widens it — and the answer carries the run's outcome. */
|
||||||
|
private async runScheduled(s: Session, prepared: ReturnType<typeof preparePrompt>, turnId: string, turn: ScheduledTurn): Promise<PromptResult> {
|
||||||
|
const app = s.app
|
||||||
|
const meta = (extra: Record<string, unknown>, reason: string, text: string, error?: string) => ({
|
||||||
|
turnId,
|
||||||
|
...extra,
|
||||||
|
scheduled: runOutcome({ runId: turn.runId, reason, text, error }),
|
||||||
|
})
|
||||||
|
if (prepared.kind !== "run") {
|
||||||
|
// A scheduled run's text is the server's; a command that is only an action has no reply.
|
||||||
|
return { stopReason: "end_turn", _meta: { lembas: meta({ command: prepared.name }, "stop", "") } }
|
||||||
|
}
|
||||||
|
app.engine.mode = prepared.mode ? clampMode(prepared.mode, turn.mode) : turn.mode
|
||||||
|
let error: string | undefined
|
||||||
|
const off = app.bus.on((e) => {
|
||||||
|
if (e.type === "error") error = e.message
|
||||||
|
})
|
||||||
|
const prev = { model: app.engine.model.ref, effort: app.engine.effort }
|
||||||
|
try {
|
||||||
|
if (prepared.model) app.switchModel(prepared.model)
|
||||||
|
const reason = await app.turns.prompt(prepared.prompt, prepared.extra, prepared.shown, { turnId, attachments: prepared.attachments })
|
||||||
|
const unsent = app.engine.takeInbox()
|
||||||
|
return { stopReason: STOP[reason] ?? "end_turn", _meta: { lembas: meta(unsent.length ? { unsent } : {}, reason, finalReply(app.engine.messages), error) } }
|
||||||
|
} finally {
|
||||||
|
off()
|
||||||
|
if (prepared.model && app.engine.model.ref !== prev.model) {
|
||||||
|
app.switchModel(prev.model)
|
||||||
|
app.engine.effort = prev.effort
|
||||||
|
}
|
||||||
|
app.engine.mode = turn.mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_lembas/monitor` (spec 3.5.0): a schedule's watch command, run here only where the
|
||||||
|
* schedule's mode allows it without asking — a watch has nobody to ask. */
|
||||||
|
private async monitor(p: any) {
|
||||||
|
const turn = this.judgeScheduled({ mode: p?.mode, schedule_id: p?.schedule_id, run_id: p?.run_id })!
|
||||||
|
const cwd = typeof p?.cwd === "string" ? expandHome(p.cwd) : ""
|
||||||
|
if (!cwd || !isAbsolute(cwd)) throw new RpcError(INVALID_PARAMS, "cwd must be an absolute path", { reason: "folder" })
|
||||||
|
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) throw new RpcError(INVALID_PARAMS, `${cwd} is not a directory on this machine`, { reason: "folder" })
|
||||||
|
const refused = this.refusal(cwd)
|
||||||
|
if (refused) throw new RpcError(INVALID_PARAMS, refused, { reason: "folder" })
|
||||||
|
const command = typeof p?.command === "string" ? p.command.trim() : ""
|
||||||
|
if (!command) throw new RpcError(INVALID_PARAMS, "command must be a command line")
|
||||||
|
const judged = judgeMonitor(command, turn.mode, { ...permissionFor(cwd), cwd })
|
||||||
|
if ("refused" in judged) throw new RpcError(INVALID_PARAMS, judged.text, { reason: "permission" })
|
||||||
|
return runMonitor(command, cwd, monitorTimeout(p?.timeout_s))
|
||||||
|
}
|
||||||
|
|
||||||
/** `_lembas/session/status`: whether the session is working, and where it runs — asked
|
/** `_lembas/session/status`: whether the session is working, and where it runs — asked
|
||||||
* after a restart or a dropped link, so the instance follows a turn still running instead of
|
* after a restart or a dropped link, so the instance follows a turn still running instead of
|
||||||
* sending its prompt a second time. */
|
* sending its prompt a second time. */
|
||||||
@@ -1065,6 +1182,8 @@ export class AcpAgent {
|
|||||||
...(s.turns.last ? { lastTurnId: s.turns.last } : {}),
|
...(s.turns.last ? { lastTurnId: s.turns.last } : {}),
|
||||||
plan: s.app.engine.plan ?? null,
|
plan: s.app.engine.plan ?? null,
|
||||||
jobs: jobList(s.app),
|
jobs: jobList(s.app),
|
||||||
|
progress: s.app.engine.progress.current,
|
||||||
|
tree: treeOf(s.app),
|
||||||
}
|
}
|
||||||
const row = (this.store ??= new Store()).session(id)
|
const row = (this.store ??= new Store()).session(id)
|
||||||
// Not open: the last turn it finished, as kept across a restart, its plan and its jobs as stored.
|
// Not open: the last turn it finished, as kept across a restart, its plan and its jobs as stored.
|
||||||
@@ -1089,6 +1208,24 @@ export class AcpAgent {
|
|||||||
return { jobs: jobList(this.get(p).app) }
|
return { jobs: jobList(this.get(p).app) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `_lembas/team`, `/transcript`, `/findings`, `/stop` (spec 3.4.0, capability tree): the
|
||||||
|
* session's subagent tree — the terminal's through the hub, as the jobs go. */
|
||||||
|
private async tree(op: "team" | "team_transcript" | "team_findings" | "team_stop", p: any) {
|
||||||
|
const t = this.terminalFor(p)
|
||||||
|
if (t) return t.client.request("hub/job", { sessionId: t.sessionId, op, id: p?.id, since: p?.since }, 15_000)
|
||||||
|
const app = this.get(p).app
|
||||||
|
switch (op) {
|
||||||
|
case "team":
|
||||||
|
return teamView(app, p?.since)
|
||||||
|
case "team_transcript":
|
||||||
|
return teamTranscript(app, p?.id)
|
||||||
|
case "team_findings":
|
||||||
|
return teamFindings(app, p?.id)
|
||||||
|
case "team_stop":
|
||||||
|
return teamStop(app, p?.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** `_lembas/job/output`, `/stop`, `/send`, `/transcript` (acp/jobs.ts): one job of the session. */
|
/** `_lembas/job/output`, `/stop`, `/send`, `/transcript` (acp/jobs.ts): one job of the session. */
|
||||||
private async job(op: "output" | "stop" | "send" | "transcript", p: any) {
|
private async job(op: "output" | "stop" | "send" | "transcript", p: any) {
|
||||||
const t = this.terminalFor(p)
|
const t = this.terminalFor(p)
|
||||||
@@ -1289,7 +1426,7 @@ export class AcpAgent {
|
|||||||
private forward(s: Session, e: Event) {
|
private forward(s: Session, e: Event) {
|
||||||
// A model change says whether the model is this link's instance's: its ref is the
|
// A model change says whether the model is this link's instance's: its ref is the
|
||||||
// provider's now (`deepseek/x`), so the ref alone no longer tells.
|
// provider's now (`deepseek/x`), so the ref alone no longer tells.
|
||||||
const event = e.type === "model" ? { ...e, instance: this.isInstance(e.connection) } : e
|
const event = e.type === "model" ? { ...e, instance: this.isInstance(e.connection) } : e.type === "team" ? { ...e, tree: treeOf(s.app) } : e
|
||||||
if (this.lembas) this.peer.notify("_lembas/event", { sessionId: s.id, event })
|
if (this.lembas) this.peer.notify("_lembas/event", { sessionId: s.id, event })
|
||||||
switch (e.type) {
|
switch (e.type) {
|
||||||
case "text":
|
case "text":
|
||||||
@@ -1321,7 +1458,8 @@ export class AcpAgent {
|
|||||||
* (its time ran out, the link dropped) is taken down with `_lembas/permission/settled`. */
|
* (its time ran out, the link dropped) is taken down with `_lembas/permission/settled`. */
|
||||||
private asker(s: Session): Asker {
|
private asker(s: Session): Asker {
|
||||||
const agentOf = () => s.agent ?? this
|
const agentOf = () => s.agent ?? this
|
||||||
const canAsk = () => agentOf().cards && !agentOf().peer.isClosed
|
// Nobody is at a scheduled run to answer a question or a plan (spec 3.5.0).
|
||||||
|
const canAsk = () => agentOf().cards && !agentOf().peer.isClosed && !s.scheduled
|
||||||
/** The client does not know the method after all: as if it had never been offered (and never again). */
|
/** The client does not know the method after all: as if it had never been offered (and never again). */
|
||||||
const unknown = (a: AcpAgent, e: unknown) => {
|
const unknown = (a: AcpAgent, e: unknown) => {
|
||||||
if (!(e instanceof RpcError) || e.code !== METHOD_NOT_FOUND) return false
|
if (!(e instanceof RpcError) || e.code !== METHOD_NOT_FOUND) return false
|
||||||
@@ -1333,15 +1471,17 @@ export class AcpAgent {
|
|||||||
ask: async (req): Promise<AskReply> => {
|
ask: async (req): Promise<AskReply> => {
|
||||||
// Whichever link holds the session now; none (dropped, not back yet) is nobody to ask.
|
// Whichever link holds the session now; none (dropped, not back yet) is nobody to ask.
|
||||||
const agent = agentOf()
|
const agent = agentOf()
|
||||||
const timeout = agent.limits?.approvalTimeoutMs ?? 0
|
// A scheduled turn's approval waits the schedule's wait (spec 3.5.0), and says so.
|
||||||
const params = permissionParams(s.id, req)
|
const turn = s.scheduled
|
||||||
|
const timeout = turn ? turn.waitMs : (agent.limits?.approvalTimeoutMs ?? 0)
|
||||||
|
const params = permissionParams(s.id, req, turn ? { scheduled: turn } : {})
|
||||||
try {
|
try {
|
||||||
if (agent.peer.isClosed) throw new Error("not linked")
|
if (agent.peer.isClosed) throw new Error("not linked")
|
||||||
const answer = await agent.peer.request("session/request_permission", params, timeout || undefined)
|
const answer = await agent.peer.request("session/request_permission", params, timeout || undefined)
|
||||||
return replyFrom(answer, !req.request.alwaysAsk)
|
return replyFrom(answer, !req.request.alwaysAsk)
|
||||||
} catch {
|
} catch {
|
||||||
settle(agent, params.toolCall.toolCallId)
|
settle(agent, params.toolCall.toolCallId)
|
||||||
return { kind: "deny", feedback: "Nobody answered the approval in time, so it was refused." }
|
return { kind: "deny", feedback: turn ? deniedText(turn.waitMs) : "Nobody answered the approval in time, so it was refused." }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Getters: whether there is anybody to ask is decided when the tool runs, by the link
|
// Getters: whether there is anybody to ask is decided when the tool runs, by the link
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import type { App } from "../app.ts"
|
import type { App } from "../app.ts"
|
||||||
import { jobRegistry, type Job, type JobInfo } from "../jobs/registry.ts"
|
import { jobRegistry, type Job, type JobInfo } from "../jobs/registry.ts"
|
||||||
import type { Store } from "../session/store.ts"
|
import type { Store } from "../session/store.ts"
|
||||||
|
import type { TreeInfo } from "../team/team.ts"
|
||||||
import { historyTurns } from "./agent.ts"
|
import { historyTurns } from "./agent.ts"
|
||||||
import { INVALID_PARAMS, RpcError } from "./rpc.ts"
|
import { INVALID_PARAMS, RpcError } from "./rpc.ts"
|
||||||
|
|
||||||
@@ -79,3 +80,48 @@ export function storedJobs(store: Store, sessionId: string): JobInfo[] {
|
|||||||
}
|
}
|
||||||
return [...list.filter((j) => j.state === "running"), ...list.filter((j) => j.state !== "running")]
|
return [...list.filter((j) => j.state === "running"), ...list.filter((j) => j.state !== "running")]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Spec 3.4.0 (acp.md capability `tree`): the subagent tree of a session ────────────────────
|
||||||
|
|
||||||
|
/** The tree (subagents.md §12), or null for a session with no subagents. */
|
||||||
|
export function treeOf(app: App): TreeInfo | null {
|
||||||
|
const t = app.team.team
|
||||||
|
if (!t.offered && !t.hasMembers() && !t.ended.length) return null
|
||||||
|
return t.info()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_lembas/team`: the tree and the whole room above `since`. */
|
||||||
|
export function teamView(app: App, since: unknown) {
|
||||||
|
const n = typeof since === "number" && since >= 0 ? since : 0
|
||||||
|
const t = app.team.team
|
||||||
|
return { tree: treeOf(app), room: t.room.filter((m) => m.n > n).map((m) => ({ n: m.n, from: m.from, to: m.to, recipients: m.recipients, text: m.text, time: m.time, report: m.report, kind: m.kind })) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentOf(app: App, id: unknown) {
|
||||||
|
const key = String(id ?? "")
|
||||||
|
const a = app.team.team.find(key)
|
||||||
|
if (!a || a.id !== key || a.id === "lead") throw new RpcError(INVALID_PARAMS, `this session has no subagent ${key}`)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_lembas/team/transcript`: a subagent's session, read-only. */
|
||||||
|
export function teamTranscript(app: App, id: unknown) {
|
||||||
|
const a = subagentOf(app, id)
|
||||||
|
const job = a.jobId ? app.registry.get(app.jobsSessionId(), a.jobId) : undefined
|
||||||
|
const messages = job?.subagent?.engine.messages ?? (job?.childSessionId && app.store ? app.store.messages(job.childSessionId) : [])
|
||||||
|
return { id: a.id, state: a.state, turns: historyTurns(messages, app.engine.o.toolCtx.cwd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_lembas/team/findings`. */
|
||||||
|
export function teamFindings(app: App, id: unknown) {
|
||||||
|
const a = subagentOf(app, id)
|
||||||
|
return { id: a.id, findings: a.findings.map((f) => ({ ...f })) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_lembas/team/stop`: the person stops a live subagent and its subtree. */
|
||||||
|
export function teamStop(app: App, id: unknown) {
|
||||||
|
const a = subagentOf(app, id)
|
||||||
|
if (a.endState) throw new RpcError(INVALID_PARAMS, `${a.id} has already ended`)
|
||||||
|
const gone = app.team.personStop(a.name)
|
||||||
|
return { stopped: app.team.team.ended.slice(-gone.length).map((x) => x.id) }
|
||||||
|
}
|
||||||
+5
-2
@@ -56,6 +56,9 @@ export function limitsFromConfig(o: { sharedOnly?: boolean } = {}): Limits & { e
|
|||||||
get terminal() {
|
get terminal() {
|
||||||
return r().terminal === true
|
return r().terminal === true
|
||||||
},
|
},
|
||||||
|
get scheduled() {
|
||||||
|
return r().enabled === true && r().scheduled === true
|
||||||
|
},
|
||||||
get terminalIntegration() {
|
get terminalIntegration() {
|
||||||
return r().terminal_integration !== false
|
return r().terminal_integration !== false
|
||||||
},
|
},
|
||||||
@@ -65,7 +68,7 @@ export function limitsFromConfig(o: { sharedOnly?: boolean } = {}): Limits & { e
|
|||||||
/** What the instance is told when the link opens (the hello, initialize's limits and
|
/** What the instance is told when the link opens (the hello, initialize's limits and
|
||||||
* capabilities): when it changes, the link is dialled again so the instance hears the new one. */
|
* capabilities): when it changes, the link is dialled again so the instance hears the new one. */
|
||||||
function announced(L: Limits & { enabled: boolean }): string {
|
function announced(L: Limits & { enabled: boolean }): string {
|
||||||
return JSON.stringify([L.enabled, L.sharedOnly, L.roots, L.maxMode, L.requireTrust, L.terminal])
|
return JSON.stringify([L.enabled, L.sharedOnly, L.roots, L.maxMode, L.requireTrust, L.terminal, L.scheduled === true])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pickInstance(name?: string): Instance {
|
export function pickInstance(name?: string): Instance {
|
||||||
@@ -199,7 +202,7 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
|
|||||||
version: VERSION,
|
version: VERSION,
|
||||||
harness_spec: HARNESS_VERSION,
|
harness_spec: HARNESS_VERSION,
|
||||||
device: { host: hostname(), platform: `${process.platform}-${process.arch}` },
|
device: { host: hostname(), platform: `${process.platform}-${process.arch}` },
|
||||||
limits: { roots: limits.roots, max_mode: limits.maxMode, require_trust: limits.requireTrust },
|
limits: { roots: limits.roots, max_mode: limits.maxMode, require_trust: limits.requireTrust, scheduled: limits.scheduled === true },
|
||||||
})
|
})
|
||||||
status("linked")
|
status("linked")
|
||||||
// The limits apply at once, each time they are looked at. What the instance was told at
|
// The limits apply at once, each time they are looked at. What the instance was told at
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// Scheduled work on a linked computer (harness acp.md, spec 3.5.0): what this device makes of a
|
||||||
|
// scheduled turn and of a monitor's watch command. The server decides when a schedule runs; the
|
||||||
|
// device decides whether (Accept scheduled work, `remote.scheduled`) and how far (the schedule's
|
||||||
|
// mode, never wider than Edit unless it says Auto, lowered to remote.max_mode). The pure rules are
|
||||||
|
// here, run by the conformance areas `scheduled`, `silent` and `monitor`; agent.ts wires them in.
|
||||||
|
import { spawn } from "node:child_process"
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { loadConfig } from "../config/load.ts"
|
||||||
|
import type { Mode, PermissionConfig } from "../config/schema.ts"
|
||||||
|
import { LOOP } from "../harness.ts"
|
||||||
|
import { DEFAULT_RULES, evaluate, toRules, type Rule } from "../permission/evaluate.ts"
|
||||||
|
import { BUILTIN_HARDLINE, hardlineRules, type HardlineRule } from "../permission/hardline.ts"
|
||||||
|
import { fillPrompt } from "../prompt/fill.ts"
|
||||||
|
import { findProject, trustOf } from "../project/root.ts"
|
||||||
|
import type { Message } from "../provider/types.ts"
|
||||||
|
|
||||||
|
export const SCHEDULED = LOOP.scheduled
|
||||||
|
|
||||||
|
const RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
|
||||||
|
/** A mode lowered to `max` (agent.ts clampMode, kept here so neither imports the other). */
|
||||||
|
const clampMode = (wanted: Mode, max: Mode): Mode => (RANK[wanted] < RANK[max] ? max : wanted)
|
||||||
|
|
||||||
|
/** Why a scheduled request is refused: `data.reason` of the -32602. */
|
||||||
|
export type Refusal = "scheduled_off" | "plan" | "folder" | "permission"
|
||||||
|
|
||||||
|
/** What a scheduled request says (`_meta.lembas.scheduled`, or `_lembas/monitor`'s params). */
|
||||||
|
export interface ScheduledMeta {
|
||||||
|
schedule_id?: string
|
||||||
|
run_id?: string
|
||||||
|
title?: string
|
||||||
|
mode?: unknown
|
||||||
|
approval_wait_s?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A scheduled turn's settings, once the device has judged it. */
|
||||||
|
export interface ScheduledTurn {
|
||||||
|
scheduleId: string
|
||||||
|
runId: string
|
||||||
|
title: string
|
||||||
|
mode: Mode
|
||||||
|
waitMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The schedule's mode and approval wait as this device runs them, or why it refuses
|
||||||
|
* (conformance/scheduled.json). `accept`: remote.scheduled with remote work on. */
|
||||||
|
export function scheduledMode(o: { accept: boolean; mode?: unknown; max_mode: Mode; approval_wait_s?: unknown }): { mode: Mode; wait_s: number } | { refused: Refusal } {
|
||||||
|
if (!o.accept) return { refused: "scheduled_off" }
|
||||||
|
if (o.mode === "plan") return { refused: "plan" }
|
||||||
|
// An unknown word is the default, never anything wider (`unrestricted` is not read as auto here).
|
||||||
|
const chosen: Mode = (SCHEDULED.modes as string[]).includes(o.mode as string) ? (o.mode as Mode) : (SCHEDULED.default_mode as Mode)
|
||||||
|
return { mode: clampMode(chosen, o.max_mode), wait_s: approvalWait(o.approval_wait_s) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The approval wait in whole seconds, clamped (loop.json scheduled.approval_wait_seconds). */
|
||||||
|
export function approvalWait(v: unknown): number {
|
||||||
|
const W = SCHEDULED.approval_wait_seconds
|
||||||
|
const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : W.default
|
||||||
|
return Math.max(W.min, Math.min(W.max, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fixed text of a refusal. */
|
||||||
|
export function refusalText(reason: "scheduled_off" | "plan"): string {
|
||||||
|
return reason === "plan" ? SCHEDULED.texts.plan : SCHEDULED.texts.off
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The feedback a scheduled approval nobody answered gives the model. */
|
||||||
|
export function deniedText(waitMs: number): string {
|
||||||
|
return fillPrompt(SCHEDULED.texts.denied, { minutes: String(Math.max(1, Math.round(waitMs / 60_000))) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The sentinel --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const marks = (line: string) => line.trim().replace(/\.+$/, "").trim().toUpperCase() === SCHEDULED.sentinel
|
||||||
|
|
||||||
|
/** Whether a reply is silent: the sentinel on its first or last non-empty line, any case, every
|
||||||
|
* trailing full stop forgiven (conformance/silent.json; the server's `notify.is_silent`). */
|
||||||
|
export function isSilent(text: string): boolean {
|
||||||
|
const lines = (text ?? "")
|
||||||
|
.trim()
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
return lines.length > 0 && (marks(lines[0]!) || marks(lines[lines.length - 1]!))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The reply without any sentinel line, trimmed: what a preview shows. */
|
||||||
|
export function stripSentinel(text: string): string {
|
||||||
|
return (text ?? "")
|
||||||
|
.trim()
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((l) => !marks(l))
|
||||||
|
.join("\n")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The text of the last assistant message: a turn's final reply. */
|
||||||
|
export function finalReply(messages: Message[]): string {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const m = messages[i]!
|
||||||
|
if (m.role === "user") return ""
|
||||||
|
if (m.role !== "assistant") continue
|
||||||
|
const text = m.parts.map((p) => (p.type === "text" ? p.text : "")).join("")
|
||||||
|
if (text.trim()) return text
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The run's outcome, for `session/prompt`'s `_meta.lembas.scheduled`. */
|
||||||
|
export function runOutcome(o: { runId: string; reason: string; text: string; error?: string }) {
|
||||||
|
let status: "ok" | "silent" | "failed" | "cancelled"
|
||||||
|
let error: string | null = null
|
||||||
|
if (o.reason === "cancelled") status = "cancelled"
|
||||||
|
else if (o.reason === "error") {
|
||||||
|
status = "failed"
|
||||||
|
error = o.error || "the turn failed"
|
||||||
|
} else if (!o.text.trim()) {
|
||||||
|
status = "failed"
|
||||||
|
error = SCHEDULED.texts.no_reply
|
||||||
|
} else status = isSilent(o.text) ? "silent" : "ok"
|
||||||
|
return { run_id: o.runId, status, text: o.text, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The watch command ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** `sha256:` and the hex SHA-256 of the output's bytes as read, a newline, and the exit status:
|
||||||
|
* the status after the bytes, so a stream is hashed as it comes (conformance/monitor.json). */
|
||||||
|
export function monitorHash(exit: number, output: Uint8Array | string): string {
|
||||||
|
const h = createHash("sha256")
|
||||||
|
h.update(typeof output === "string" ? Buffer.from(output, "utf8") : output)
|
||||||
|
h.update(`\n${exit}`)
|
||||||
|
return `sha256:${h.digest("hex")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The permission context a `bash` call in `cwd` would be judged in: the hardline, the default
|
||||||
|
* rules, the global rules and — in a trusted project — the project's (as app.ts builds them). */
|
||||||
|
export function permissionFor(cwd: string): { rules: Rule[]; hardline: HardlineRule[]; root: string; planDir: string; projectDir: string } {
|
||||||
|
const project = findProject(cwd)
|
||||||
|
const trusted = trustOf(project.root) === "trusted"
|
||||||
|
const loaded = loadConfig({ projectConfigDir: project.dir, trusted })
|
||||||
|
return {
|
||||||
|
rules: [...toRules(DEFAULT_RULES, "default"), ...loaded.permissions.flatMap((p, i) => toRules(p, loaded.permissionSources[i] ?? "global"))],
|
||||||
|
hardline: hardlineRules({ extra: loaded.config.hardline_extra, disable: loaded.config.hardline_disable }),
|
||||||
|
root: project.root,
|
||||||
|
planDir: join(project.dir, "plans"),
|
||||||
|
projectDir: project.dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a watch command may run in `mode`: only where the mode allows it without asking
|
||||||
|
* (conformance/monitor.json `judge`). */
|
||||||
|
export function judgeMonitor(line: string, mode: Mode, ctx: { rules: Rule[]; hardline: HardlineRule[]; root: string; cwd?: string; planDir?: string; projectDir?: string }): { run: true } | { refused: "asks" | "denied"; text: string } {
|
||||||
|
const d = evaluate({ permission: "bash", class: "execute", patterns: [line], command: line, paths: [ctx.cwd ?? ctx.root] }, { mode, rules: ctx.rules, hardline: ctx.hardline, root: ctx.root, planDir: ctx.planDir, projectDir: ctx.projectDir })
|
||||||
|
if (d.action === "allow") return { run: true }
|
||||||
|
const reason = d.reason || (d.action === "ask" ? "it is not allowed without asking" : "refused")
|
||||||
|
if (d.action === "ask") return { refused: "asks", text: fillPrompt(SCHEDULED.texts.monitor_asks, { mode, reason }) }
|
||||||
|
return { refused: "denied", text: fillPrompt(SCHEDULED.texts.monitor_denied, { reason }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The conformance runner's judge: the default rules and the case's own as global rules. */
|
||||||
|
export function judgeCase(line: string, mode: Mode, extra?: PermissionConfig) {
|
||||||
|
const rules = [...toRules(DEFAULT_RULES, "default"), ...toRules(extra ?? {}, "global")]
|
||||||
|
return judgeMonitor(line, mode, { rules, hardline: BUILTIN_HARDLINE, root: "/project", planDir: "/project/.agent/plans", projectDir: "/project/.agent" })
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorResult {
|
||||||
|
hash: string | null
|
||||||
|
exit: number | null
|
||||||
|
timed_out: boolean
|
||||||
|
excerpt: string
|
||||||
|
truncated: boolean
|
||||||
|
bytes: number
|
||||||
|
duration_ms: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The watch command's timeout in seconds, defaulted and clamped. */
|
||||||
|
export function monitorTimeout(v: unknown): number {
|
||||||
|
const T = SCHEDULED.monitor.timeout_seconds
|
||||||
|
const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : T.default
|
||||||
|
return Math.max(T.min, Math.min(T.max, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a watch command as `bash -c` in `cwd`, in its own process group, with no input; stop the
|
||||||
|
* group at the timeout. Everything streamed is hashed; the first output_max_bytes are kept. */
|
||||||
|
export function runMonitor(command: string, cwd: string, timeoutS: number, env: Record<string, string | undefined> = process.env): Promise<MonitorResult> {
|
||||||
|
const M = SCHEDULED.monitor
|
||||||
|
const started = Date.now()
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn("bash", ["-c", `exec 2>&1\n${command}`], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"], env })
|
||||||
|
const hash = createHash("sha256")
|
||||||
|
const kept: Buffer[] = []
|
||||||
|
let keptBytes = 0
|
||||||
|
let bytes = 0
|
||||||
|
let timedOut = false
|
||||||
|
const onData = (d: Buffer) => {
|
||||||
|
bytes += d.length
|
||||||
|
hash.update(d)
|
||||||
|
if (keptBytes < M.output_max_bytes) {
|
||||||
|
const piece = d.subarray(0, M.output_max_bytes - keptBytes)
|
||||||
|
kept.push(piece)
|
||||||
|
keptBytes += piece.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child.stdout!.on("data", onData)
|
||||||
|
child.stderr!.on("data", onData)
|
||||||
|
const killGroup = (sig: NodeJS.Signals) => {
|
||||||
|
try {
|
||||||
|
process.kill(-child.pid!, sig)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true
|
||||||
|
killGroup("SIGTERM")
|
||||||
|
setTimeout(() => killGroup("SIGKILL"), 2000).unref()
|
||||||
|
}, timeoutS * 1000)
|
||||||
|
let done = false
|
||||||
|
const finish = (code: number | null) => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
const text = Buffer.concat(kept).toString("utf8")
|
||||||
|
const excerpt = text.slice(0, M.excerpt_chars)
|
||||||
|
const exit = timedOut || child.signalCode ? null : code
|
||||||
|
if (exit !== null) hash.update(`\n${exit}`)
|
||||||
|
resolve({
|
||||||
|
hash: exit === null ? null : `sha256:${hash.digest("hex")}`,
|
||||||
|
exit,
|
||||||
|
timed_out: timedOut,
|
||||||
|
excerpt,
|
||||||
|
truncated: bytes > Buffer.byteLength(excerpt, "utf8"),
|
||||||
|
bytes,
|
||||||
|
duration_ms: Date.now() - started,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
child.on("close", (code) => finish(code))
|
||||||
|
child.on("error", () => finish(127))
|
||||||
|
})
|
||||||
|
}
|
||||||
+15
-5
@@ -14,8 +14,9 @@ import type { ImagePart } from "../provider/types.ts"
|
|||||||
import { commandSet, sourcesOf } from "../session/commands.ts"
|
import { commandSet, sourcesOf } from "../session/commands.ts"
|
||||||
import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts"
|
import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts"
|
||||||
import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts"
|
import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts"
|
||||||
|
import { readLevel } from "../provider/effort.ts"
|
||||||
import { clampMode, effortOf, markRemote, permissionParams, planParams, planReplyFrom, questionParams, questionReplyFrom, replyFrom, STOP, type PromptResult } from "./agent.ts"
|
import { clampMode, effortOf, markRemote, permissionParams, planParams, planReplyFrom, questionParams, questionReplyFrom, replyFrom, STOP, type PromptResult } from "./agent.ts"
|
||||||
import { jobList, jobOutput, jobSend, jobStop, jobTranscript } from "./jobs.ts"
|
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, teamFindings, teamStop, teamTranscript, teamView, treeOf } from "./jobs.ts"
|
||||||
import type { SessionContext } from "../app.ts"
|
import type { SessionContext } from "../app.ts"
|
||||||
import { sessionTurnFile, sessionTurns, type TurnLog } from "./turns.ts"
|
import { sessionTurnFile, sessionTurns, type TurnLog } from "./turns.ts"
|
||||||
import { dropAttachments, preparePrompt } from "./prompt.ts"
|
import { dropAttachments, preparePrompt } from "./prompt.ts"
|
||||||
@@ -390,7 +391,7 @@ export class Share {
|
|||||||
// is the instance's.
|
// is the instance's.
|
||||||
modelConnection: this.app.engine.model.connectionName,
|
modelConnection: this.app.engine.model.connectionName,
|
||||||
mode: this.app.engine.mode,
|
mode: this.app.engine.mode,
|
||||||
effort: this.app.engine.effort ?? "off",
|
effort: this.app.engine.effort,
|
||||||
busy: Boolean(this.app.turns.current),
|
busy: Boolean(this.app.turns.current),
|
||||||
explicit,
|
explicit,
|
||||||
},
|
},
|
||||||
@@ -453,7 +454,7 @@ export class Share {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.sharedId && this.hub && this.app.engine.sessionId === this.sharedId) {
|
if (this.sharedId && this.hub && this.app.engine.sessionId === this.sharedId) {
|
||||||
this.hub.notify("hub/event", { sessionId: this.sharedId, event: e })
|
this.hub.notify("hub/event", { sessionId: this.sharedId, event: e.type === "team" ? { ...e, tree: treeOf(this.app) } : e })
|
||||||
if (e.type === "task" && e.state === "end") this.sendCommands()
|
if (e.type === "task" && e.state === "end") this.sendCommands()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -519,6 +520,15 @@ export class Share {
|
|||||||
return jobSend(this.app, p?.id, p?.text)
|
return jobSend(this.app, p?.id, p?.text)
|
||||||
case "transcript":
|
case "transcript":
|
||||||
return jobTranscript(this.app, p?.id)
|
return jobTranscript(this.app, p?.id)
|
||||||
|
// Spec 3.4.0 (capability tree): the subagent tree, through the hub as the jobs go.
|
||||||
|
case "team":
|
||||||
|
return teamView(this.app, p?.since)
|
||||||
|
case "team_transcript":
|
||||||
|
return teamTranscript(this.app, p?.id)
|
||||||
|
case "team_findings":
|
||||||
|
return teamFindings(this.app, p?.id)
|
||||||
|
case "team_stop":
|
||||||
|
return teamStop(this.app, p?.id)
|
||||||
default:
|
default:
|
||||||
throw new RpcError(INVALID_PARAMS, `no such job operation: ${String(p?.op ?? "")}`)
|
throw new RpcError(INVALID_PARAMS, `no such job operation: ${String(p?.op ?? "")}`)
|
||||||
}
|
}
|
||||||
@@ -596,9 +606,9 @@ export class Share {
|
|||||||
// same model as `deepseek/x`, and switching to it again would reset the effort.
|
// same model as `deepseek/x`, and switching to it again would reset the effort.
|
||||||
if (typeof p?.model === "string" && p.model && this.app.canonicalRef(p.model) !== this.app.engine.model.ref) this.app.switchModel(p.model)
|
if (typeof p?.model === "string" && p.model && this.app.canonicalRef(p.model) !== this.app.engine.model.ref) this.app.switchModel(p.model)
|
||||||
const effort = effortOf(p?.effort)
|
const effort = effortOf(p?.effort)
|
||||||
if (effort !== undefined) this.app.engine.effort = effort === "off" ? null : effort
|
if (effort !== undefined) this.app.engine.effort = readLevel(this.app.engine.model, effort)
|
||||||
this.hooks.changed()
|
this.hooks.changed()
|
||||||
return { model: this.app.engine.model.ref, effort: this.app.engine.effort ?? "off" }
|
return { model: this.app.engine.model.ref, effort: this.app.engine.effort }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+220
-30
@@ -12,11 +12,11 @@ import { boardSummary } from "./project/board.ts"
|
|||||||
import { unsafeProjectFile } from "./project/safe.ts"
|
import { unsafeProjectFile } from "./project/safe.ts"
|
||||||
import { Bus, type Asker } from "./bus/index.ts"
|
import { Bus, type Asker } from "./bus/index.ts"
|
||||||
import { loadConfig, type Loaded } from "./config/load.ts"
|
import { loadConfig, type Loaded } from "./config/load.ts"
|
||||||
import type { Effort, Mode } from "./config/schema.ts"
|
import type { Mode } from "./config/schema.ts"
|
||||||
import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts"
|
import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts"
|
||||||
import { hardlineRules } from "./permission/hardline.ts"
|
import { hardlineRules } from "./permission/hardline.ts"
|
||||||
import { defaultEffort } from "./provider/effort.ts"
|
|
||||||
import { clientFor, familyOf, resolveModel } from "./provider/index.ts"
|
import { clientFor, familyOf, resolveModel } from "./provider/index.ts"
|
||||||
|
import { subagentModel, type ModelInfo } from "./team/subagents.ts"
|
||||||
import { assembleSystem, instructionFiles, knowledgeScope, modeText } from "./prompt/assemble.ts"
|
import { assembleSystem, instructionFiles, knowledgeScope, modeText } from "./prompt/assemble.ts"
|
||||||
import type { Changing } from "./session/prefix.ts"
|
import type { Changing } from "./session/prefix.ts"
|
||||||
import { describeRepo, repoState } from "./git/repo.ts"
|
import { describeRepo, repoState } from "./git/repo.ts"
|
||||||
@@ -45,14 +45,18 @@ import type { Plan } from "./plan/plan.ts"
|
|||||||
import { LIBRARY_SERVER, mcpInstructions } from "./mcp/index.ts"
|
import { LIBRARY_SERVER, mcpInstructions } from "./mcp/index.ts"
|
||||||
import type { McpServer } from "./config/schema.ts"
|
import type { McpServer } from "./config/schema.ts"
|
||||||
import { embedderFor } from "./library/embed.ts"
|
import { embedderFor } from "./library/embed.ts"
|
||||||
import { effortsFor } from "./provider/effort.ts"
|
import { isLevel, levelsFor, readLevel, type Level } from "./provider/effort.ts"
|
||||||
|
import { memberTools as memberToolsOf, readingBashByDefault, Team, TEAM, TEAM_TOOLS, type Capacity, type TeamAgent } from "./team/team.ts"
|
||||||
|
import { whoDecides } from "./team/delegation.ts"
|
||||||
|
import { TeamRuntime, type MemberHandle } from "./team/runtime.ts"
|
||||||
|
|
||||||
export interface AppOptions {
|
export interface AppOptions {
|
||||||
cwd: string
|
cwd: string
|
||||||
model?: string
|
model?: string
|
||||||
mode?: Mode
|
mode?: Mode
|
||||||
/** undefined = the model's default; "off" = none */
|
/** The effort level asked for (an effort, "off" or "default"); undefined, or one the model does
|
||||||
effort?: Effort | "off"
|
* not offer: its start level. */
|
||||||
|
effort?: string
|
||||||
asker: Asker
|
asker: Asker
|
||||||
store?: Store | false
|
store?: Store | false
|
||||||
/** Nobody can answer an approval (headless, no --yes). Said in the system prompt up front. */
|
/** Nobody can answer an approval (headless, no --yes). Said in the system prompt up front. */
|
||||||
@@ -149,6 +153,8 @@ export interface App {
|
|||||||
context: SessionContext
|
context: SessionContext
|
||||||
/** Replace it (`{}`: none), naming the instance it came from for the prompt's heading. */
|
/** Replace it (`{}`: none), naming the instance it came from for the prompt's heading. */
|
||||||
setContext(ctx: SessionContext, from?: string): void
|
setContext(ctx: SessionContext, from?: string): void
|
||||||
|
/** The session's team (spec 3.2.0, team.md): its rules and its members at work. */
|
||||||
|
team: TeamRuntime
|
||||||
/** The chat a session the client opened belongs to (its id): sent with every call to the server's
|
/** The chat a session the client opened belongs to (its id): sent with every call to the server's
|
||||||
* memory, which answers in that chat's data group (harness/acp.md *Memory*); undefined for a
|
* memory, which answers in that chat's data group (harness/acp.md *Memory*); undefined for a
|
||||||
* session of this device's own. The memory for the prompt is read again in it. */
|
* session of this device's own. The memory for the prompt is read again in it. */
|
||||||
@@ -198,8 +204,15 @@ function openInBrowser(url: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A model as the subagent rules see it (subagents.md): its names and what its server can do at once. */
|
||||||
|
function modelInfo(m: ResolvedModel): ModelInfo {
|
||||||
|
return { ref: m.ref, id: m.id, name: m.spec.name, capacity: { parallel: m.spec.parallel, single_session: m.spec.single_session, group: m.spec.group } }
|
||||||
|
}
|
||||||
|
|
||||||
/** Tools the spec offers to subagents only (`offered: subagent`): never a session's own model's. */
|
/** Tools the spec offers to subagents only (`offered: subagent`): never a session's own model's. */
|
||||||
export const SUBAGENT_ONLY = new Set(Object.values(TOOL_SPECS).filter((t) => t.offered === "subagent").map((t) => t.name))
|
export const SUBAGENT_ONLY = new Set(Object.values(TOOL_SPECS).filter((t) => t.offered === "subagent").map((t) => t.name))
|
||||||
|
/** Spec 3.4.0: tools the spec offers to the main agent only (`offered: main`): never a subagent's. */
|
||||||
|
export const MAIN_ONLY_TOOLS = new Set(Object.values(TOOL_SPECS).filter((t) => t.offered === "main").map((t) => t.name))
|
||||||
|
|
||||||
const MODE_RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
|
const MODE_RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
|
||||||
export function stricterMode(a: Mode | undefined, b: Mode): Mode {
|
export function stricterMode(a: Mode | undefined, b: Mode): Mode {
|
||||||
@@ -215,13 +228,10 @@ export function createApp(o: AppOptions): App {
|
|||||||
// An untrusted project is read-only: plan mode, and its own config is ignored.
|
// An untrusted project is read-only: plan mode, and its own config is ignored.
|
||||||
let mode: Mode = o.mode ?? loaded.config.mode ?? "manual"
|
let mode: Mode = o.mode ?? loaded.config.mode ?? "manual"
|
||||||
if (trust === "readonly") mode = "plan"
|
if (trust === "readonly") mode = "plan"
|
||||||
// `effort:` in config when this model takes it, else the model's own default.
|
// `effort:` in config when this model offers that level, else the model's own start level
|
||||||
const startEffort = (m: ResolvedModel) => {
|
// (harness models/schema.json `levels`).
|
||||||
const e = loaded.config.effort
|
const startEffort = (m: ResolvedModel): Level => readLevel(m, loaded.config.effort)
|
||||||
if (e === "off") return null
|
const effort: Level = o.effort !== undefined && isLevel(o.effort) && (levelsFor(model).levels as string[]).includes(o.effort) ? o.effort : startEffort(model)
|
||||||
return e && effortsFor(m).includes(e) ? e : defaultEffort(m)
|
|
||||||
}
|
|
||||||
const effort = o.effort === "off" ? null : (o.effort ?? startEffort(model))
|
|
||||||
// One object for the session's lifetime, so a setting changed later reaches the tools.
|
// One object for the session's lifetime, so a setting changed later reaches the tools.
|
||||||
loaded.config.search ??= {}
|
loaded.config.search ??= {}
|
||||||
const bus = o.bus ?? new Bus()
|
const bus = o.bus ?? new Bus()
|
||||||
@@ -363,7 +373,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
if (src.instance) named[`Instructions from ${src.instance.name}`] = src.instance.instructions
|
if (src.instance) named[`Instructions from ${src.instance.name}`] = src.instance.instructions
|
||||||
return { memory: src.memory ?? "", personality: src.personality ?? "", files: named, mcp: src.mcp, source: src }
|
return { memory: src.memory ?? "", personality: src.personality ?? "", files: named, mcp: src.mcp, source: src }
|
||||||
}
|
}
|
||||||
const system = (m: Mode, current: ResolvedModel, ch: Changing, subagent?: { name: string; instructions: string; report: boolean; skills: boolean; recall: boolean }, at?: { cwd: string; root: string }, plan?: Plan) => {
|
const system = (m: Mode, current: ResolvedModel, ch: Changing, subagent?: { name: string; instructions: string; report: boolean; skills: boolean; recall: boolean }, at?: { cwd: string; root: string }, plan?: Plan, member?: { id: string; name: string; manager: string; role: string; goal: string; lead: boolean }) => {
|
||||||
const src = ch.source as Source
|
const src = ch.source as Source
|
||||||
return assembleSystem({
|
return assembleSystem({
|
||||||
modelRef: current.ref,
|
modelRef: current.ref,
|
||||||
@@ -395,6 +405,9 @@ export function createApp(o: AppOptions): App {
|
|||||||
library: libraryBlock(),
|
library: libraryBlock(),
|
||||||
prototypes: prototypesBlock(),
|
prototypes: prototypesBlock(),
|
||||||
context: subagent ? subagent.recall : true,
|
context: subagent ? subagent.recall : true,
|
||||||
|
...(member ? { member } : {}),
|
||||||
|
// The lead's prompt says how to lead whenever the team tools are offered (team.md §10).
|
||||||
|
teamLead: !subagent && teamRt.team.offered,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/** A mode's text for a note, as the prompt would have it. */
|
/** A mode's text for a note, as the prompt would have it. */
|
||||||
@@ -420,6 +433,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
const inWords = (sec: number) => (sec % 60 === 0 ? `${sec / 60} minute${sec === 60 ? "" : "s"}` : `${sec} seconds`)
|
const inWords = (sec: number) => (sec % 60 === 0 ? `${sec / 60} minute${sec === 60 ? "" : "s"}` : `${sec} seconds`)
|
||||||
const vars: Record<string, Record<string, string>> = {
|
const vars: Record<string, Record<string, string>> = {
|
||||||
task: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") },
|
task: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") },
|
||||||
|
team_add: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") },
|
||||||
bash: {
|
bash: {
|
||||||
timeout: inWords(loaded.config.limits?.bash_timeout ?? 120),
|
timeout: inWords(loaded.config.limits?.bash_timeout ?? 120),
|
||||||
timeout_max: inWords(600),
|
timeout_max: inWords(600),
|
||||||
@@ -467,9 +481,24 @@ export function createApp(o: AppOptions): App {
|
|||||||
// The model asked for (task's `model`), else the agent's own, else the session's — and whether
|
// The model asked for (task's `model`), else the agent's own, else the session's — and whether
|
||||||
// its server can take it beside the session's and beside every subagent already running
|
// its server can take it beside the session's and beside every subagent already running
|
||||||
// (src/session/capacity.ts), before anything is made.
|
// (src/session/capacity.ts), before anything is made.
|
||||||
const m = opts.model ? resolveModel(loaded, opts.model) : spec.model ? resolveModel(loaded, spec.model) : engine.model
|
const member = opts.member
|
||||||
const refused = capacityRefusal(engine.model, m) || registry.running(jobsSessionId()).filter((j) => j.kind === "subagent" && j.subagent).map((j) => capacityBeside(j.subagent!.engine.model, m)).find(Boolean)
|
// Spec 3.3.0 (subagents.md): the session's model, unless the person named another; an agent
|
||||||
if (refused) throw new ToolError(`${refused} Do this part yourself, or hand it to a model on another connection.`)
|
// 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} Wait for one to finish, or do this part yourself.`)
|
||||||
|
}
|
||||||
// A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation).
|
// A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation).
|
||||||
let wt: Worktree | undefined
|
let wt: Worktree | undefined
|
||||||
if (opts.worktree || spec.isolation === "worktree") {
|
if (opts.worktree || spec.isolation === "worktree") {
|
||||||
@@ -497,8 +526,18 @@ export function createApp(o: AppOptions): App {
|
|||||||
}
|
}
|
||||||
: perm
|
: perm
|
||||||
// `report` is every subagent's: its way of speaking to the main agent before its final reply.
|
// `report` is every subagent's: its way of speaking to the main agent before its final reply.
|
||||||
const childTools = [...tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && (!spec.tools || spec.tools.includes(t.name))), ...BUILTIN_TOOLS.filter((t) => SUBAGENT_ONLY.has(t.name))]
|
// A subagent of the tree also has its team tools (spec 3.4.0 §8: whoami, read, send, findings,
|
||||||
const description = opts.description ?? prompt.split("\n")[0]!.slice(0, 60)
|
// finish, and request with request options) — never the main agent's (`offered: main`); a task
|
||||||
|
// subagent has none of them.
|
||||||
|
const memberTools = member ? memberToolsOf(member) : []
|
||||||
|
const isTeamTool = (n: string) => (TEAM_TOOLS as readonly string[]).includes(n)
|
||||||
|
const childTools = [
|
||||||
|
...tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && !MAIN_ONLY_TOOLS.has(t.name) && (!isTeamTool(t.name) || memberTools.includes(t.name)) && (!spec.tools || spec.tools.includes(t.name) || memberTools.includes(t.name))),
|
||||||
|
...BUILTIN_TOOLS.filter((t) => SUBAGENT_ONLY.has(t.name) && (!isTeamTool(t.name) || memberTools.includes(t.name))),
|
||||||
|
]
|
||||||
|
// Its mode: the one the main agent gave it, made stricter by its agent's own (explore reads).
|
||||||
|
if (member) member.mode = stricterMode(spec.mode, member.mode)
|
||||||
|
const description = member ? `${member.name}: ${member.role}` : (opts.description ?? prompt.split("\n")[0]!.slice(0, 60))
|
||||||
const ownerSession = jobsSessionId()
|
const ownerSession = jobsSessionId()
|
||||||
const childRow = store?.createSession(at?.root ?? project.root, m.ref, engine.sessionId)
|
const childRow = store?.createSession(at?.root ?? project.root, m.ref, engine.sessionId)
|
||||||
if (childRow) store!.setTitle(childRow.id, `${spec.name}: ${description}`)
|
if (childRow) store!.setTitle(childRow.id, `${spec.name}: ${description}`)
|
||||||
@@ -507,7 +546,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
let job: Job
|
let job: Job
|
||||||
// The asker is the session's; a child's ask says which subagent it comes from.
|
// The asker is the session's; a child's ask says which subagent it comes from.
|
||||||
const askerFor = (): Asker => ({
|
const askerFor = (): Asker => ({
|
||||||
ask: (req) => o.asker.ask({ ...req, source: { jobId: job.id, agent: spec.name, description } }),
|
ask: (req) => o.asker.ask({ ...req, source: { jobId: job.id, agent: spec.name, description, ...(member ? { subagent: { id: member.id, name: member.name, position: member.role } } : {}) } }),
|
||||||
get question() {
|
get question() {
|
||||||
return o.asker.question
|
return o.asker.question
|
||||||
},
|
},
|
||||||
@@ -523,8 +562,9 @@ export function createApp(o: AppOptions): App {
|
|||||||
tools: childTools,
|
tools: childTools,
|
||||||
store,
|
store,
|
||||||
sessionId: childRow?.id,
|
sessionId: childRow?.id,
|
||||||
// An agent's own mode can make it stricter than its parent, never looser.
|
// An agent's own mode can make it stricter than its parent, never looser. A subagent of the
|
||||||
mode: stricterMode(spec.mode, engine.mode),
|
// tree runs in the mode the main agent gave it (spec 3.4.0 §7), set above.
|
||||||
|
mode: member ? member.mode : stricterMode(spec.mode, engine.mode),
|
||||||
effort: m.ref === engine.model.ref ? engine.effort : startEffort(m),
|
effort: m.ref === engine.model.ref ? engine.effort : startEffort(m),
|
||||||
maxSteps: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default,
|
maxSteps: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default,
|
||||||
compaction,
|
compaction,
|
||||||
@@ -533,7 +573,24 @@ export function createApp(o: AppOptions): App {
|
|||||||
...baseToolCtx(),
|
...baseToolCtx(),
|
||||||
...(at ? { root: at.root, cwd: at.cwd, projectDir: undefined } : {}),
|
...(at ? { root: at.root, cwd: at.cwd, projectDir: undefined } : {}),
|
||||||
jobs: { registry, owner: () => ({ sessionId: childSessionId, job }) },
|
jobs: { registry, owner: () => ({ sessionId: childSessionId, job }) },
|
||||||
|
...(member ? { team: { runtime: teamRt, as: member.name } } : {}),
|
||||||
},
|
},
|
||||||
|
...(member
|
||||||
|
? {
|
||||||
|
team: {
|
||||||
|
fix: () => teamRt.team.offered,
|
||||||
|
offered: () => teamRt.team.offered,
|
||||||
|
refuse: (tool: Tool, args: unknown) => teamRt.refuse(member.name, tool, args),
|
||||||
|
// Spec 3.4.0 §7: itself, the main agent through a request, or the person.
|
||||||
|
permit: (request, ctx) => {
|
||||||
|
const a = teamRt.team.get(member.name)
|
||||||
|
return whoDecides(request, { ctx, mode: a?.mode ?? member.mode, main: engine.mode, request: a?.request ?? [], grants: a?.grants ?? [], unattended: o.unattended })
|
||||||
|
},
|
||||||
|
contextFull: (used: number, window: number) => (window ? teamRt.contextFull(member.name) : "go"),
|
||||||
|
ended: () => teamRt.hasEnded(member.name),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
steerMark: MARKS.to_subagent,
|
steerMark: MARKS.to_subagent,
|
||||||
changing: () => changing(true, at),
|
changing: () => changing(true, at),
|
||||||
modeText: modeNote,
|
modeText: modeNote,
|
||||||
@@ -545,7 +602,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
{
|
{
|
||||||
name: spec.name,
|
name: spec.name,
|
||||||
instructions: wt
|
instructions: wt
|
||||||
? `${spec.instructions}\n\nYou are working in a separate checkout of the repository, at ${at!.root}, on the branch ${wt.branch} (made from the last commit — uncommitted work of the main agent is not here). Work only inside it. When you finish, your changes are committed on that branch for the main agent to merge: do not commit, merge or push yourself.`
|
? `${spec.instructions}\n\nYou are working in a separate checkout of the repository — its root is your project root, so relative paths reach it — on the branch ${wt.branch} (made from the last commit — uncommitted work of the main agent is not here). Work only inside it. When you finish, your changes are committed on that branch for the main agent to merge: do not commit, merge or push yourself.`
|
||||||
: spec.instructions,
|
: spec.instructions,
|
||||||
report: childTools.some((t) => t.name === "report"),
|
report: childTools.some((t) => t.name === "report"),
|
||||||
skills: childTools.some((t) => t.name === "skill_view"),
|
skills: childTools.some((t) => t.name === "skill_view"),
|
||||||
@@ -553,6 +610,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
},
|
},
|
||||||
at,
|
at,
|
||||||
plan,
|
plan,
|
||||||
|
member ? { id: member.id, name: member.name, manager: member.manager ?? TEAM.names.lead, role: member.role, goal: member.goal, lead: member.leads } : undefined,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
// Its own background commands wake it at its next step while it runs; once it has ended they
|
// Its own background commands wake it at its next step while it runs; once it has ended they
|
||||||
@@ -568,7 +626,14 @@ export function createApp(o: AppOptions): App {
|
|||||||
const end = registry.finishSubagent(job, { reason, report: text, ...(error ? { error } : {}), ...(worktree ? { worktree } : {}) })
|
const end = registry.finishSubagent(job, { reason, report: text, ...(error ? { error } : {}), ...(worktree ? { worktree } : {}) })
|
||||||
return { text: worktree ? `${text}\n\n[worktree] ${worktree}` : text, steps: job.steps ?? 0, tools: job.tools ?? 0, reason, ...(error ? { error } : {}), ...(end.stopped ? { stopped: end.stopped } : {}) }
|
return { text: worktree ? `${text}\n\n[worktree] ${worktree}` : text, steps: job.steps ?? 0, tools: job.tools ?? 0, reason, ...(error ? { error } : {}), ...(end.stopped ? { stopped: end.stopped } : {}) }
|
||||||
}
|
}
|
||||||
const run = (text: string) => child.prompt(text).then(finish, (e) => (job.lastError = (e as Error).message, finish("error")))
|
// 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.taskStart(TEAM.names.lead, m.ref)
|
||||||
|
return p.finally(() => teamRt.team.taskDone(id))
|
||||||
|
}
|
||||||
|
const run = (text: string, machine = false, steps?: number) => counted(child.prompt(text, [], text, { ...(machine ? { machine: true as const } : {}), ...(steps ? { maxSteps: steps } : {}) }).then(finish, (e) => (job.lastError = (e as Error).message, finish("error"))))
|
||||||
const handle: SubagentHandle = {
|
const handle: SubagentHandle = {
|
||||||
bus: childBus,
|
bus: childBus,
|
||||||
engine: child,
|
engine: child,
|
||||||
@@ -576,7 +641,34 @@ export function createApp(o: AppOptions): App {
|
|||||||
restart: (text) => run(text).then(() => undefined),
|
restart: (text) => run(text).then(() => undefined),
|
||||||
cancel: () => child.cancel(),
|
cancel: () => child.cancel(),
|
||||||
}
|
}
|
||||||
job = registry.startSubagent({ sessionId: ownerSession, ownerCallId: opts.callId, ownerJobId: baseOwner().job?.id, agent: spec.name, model: m.ref, description, handle, childSessionId, silent: !opts.background })
|
job = registry.startSubagent({ sessionId: ownerSession, ownerCallId: opts.callId, ownerJobId: baseOwner().job?.id, agent: spec.name, model: m.ref, description, handle, childSessionId, silent: !opts.background || member !== undefined })
|
||||||
|
if (member) {
|
||||||
|
// A member's turn that ends is its report (team.md §5): the team, not a job note, carries it.
|
||||||
|
job.team = member.name
|
||||||
|
member.jobId = job.id
|
||||||
|
// Its progress and context use, for the tree (spec 3.4.0 §12).
|
||||||
|
childBus.on((e) => {
|
||||||
|
if (e.type === "phase") {
|
||||||
|
teamRt.team.setProgress(member.name, e.progress)
|
||||||
|
teamRt.notify()
|
||||||
|
} else if (e.type === "usage" && !e.child && e.context) {
|
||||||
|
teamRt.team.setContext(member.name, e.used ?? e.usage.input + e.usage.output, e.context)
|
||||||
|
teamRt.notify()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const memberRun = (text: string, machine: boolean, steps?: number) => {
|
||||||
|
if (job.state !== "running") registry.restartSubagent(job)
|
||||||
|
void run(text, machine, steps).then((r) => {
|
||||||
|
const leftover = [...child.takeNotes(), ...child.takeInbox()]
|
||||||
|
const reason = r.reason === "error" ? `error — ${r.error ?? "unknown"}` : r.reason
|
||||||
|
if (r.reason !== "cancelled") teamRt.memberDone(member.name, r.text, reason, leftover)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const mh: MemberHandle = { engine: child, run: (text) => memberRun(text, true), cancel: () => child.cancel(), final: (text) => memberRun(text, true, TEAM.context.final_steps) }
|
||||||
|
teamRt.attach(member.name, mh)
|
||||||
|
memberRun(prompt, false)
|
||||||
|
return { id: job.id, done: Promise.resolve({ text: "", steps: 0, tools: 0, reason: "stop" }), member: mh }
|
||||||
|
}
|
||||||
// A synchronous subagent goes with its parent's turn (Esc stops both); a background one does not.
|
// A synchronous subagent goes with its parent's turn (Esc stops both); a background one does not.
|
||||||
const onAbort = () => child.cancel()
|
const onAbort = () => child.cancel()
|
||||||
opts.signal?.addEventListener("abort", onAbort, { once: true })
|
opts.signal?.addEventListener("abort", onAbort, { once: true })
|
||||||
@@ -602,14 +694,91 @@ export function createApp(o: AppOptions): App {
|
|||||||
budget: { wall_seconds: loaded.config.limits?.wall_seconds, output_bytes: loaded.config.limits?.output_bytes, completion_tokens: loaded.config.limits?.completion_tokens },
|
budget: { wall_seconds: loaded.config.limits?.wall_seconds, output_bytes: loaded.config.limits?.output_bytes, completion_tokens: loaded.config.limits?.completion_tokens },
|
||||||
compaction,
|
compaction,
|
||||||
perm,
|
perm,
|
||||||
toolCtx: { ...baseToolCtx(), jobs: { registry, owner: baseOwner } },
|
toolCtx: { ...baseToolCtx(), jobs: { registry, owner: baseOwner }, team: { get runtime() { return teamRt }, as: TEAM.names.lead } },
|
||||||
system: (m, current, plan, ch) => system(m, current, ch, undefined, undefined, plan),
|
system: (m, current, plan, ch) => system(m, current, ch, undefined, undefined, plan),
|
||||||
|
team: {
|
||||||
|
fix: (ref, offered) => teamRt.team.fix(ref, offered),
|
||||||
|
offered: () => teamRt.team.offered,
|
||||||
|
refuse: (tool, args) => teamRt.refuse(TEAM.names.lead, tool, args),
|
||||||
|
note: () => teamRt.team.noteLines(),
|
||||||
|
forget: () => teamRt.team.forgetTold(),
|
||||||
|
beforeTurn: (signal) => teamRt.beforeLeadTurn(signal),
|
||||||
|
},
|
||||||
changing: () => changing(),
|
changing: () => changing(),
|
||||||
modeText: modeNote,
|
modeText: modeNote,
|
||||||
// The configured MCP servers, up to loop.json's wait: their tools in the first list.
|
// The configured MCP servers, up to loop.json's wait: their tools in the first list.
|
||||||
beforeFirst: () => Promise.race([mcpReady.catch(() => {}), Bun.sleep(LOOP.context.mcp_wait_ms)]),
|
beforeFirst: () => Promise.race([mcpReady.catch(() => {}), Bun.sleep(LOOP.context.mcp_wait_ms)]),
|
||||||
persistRule: trusted ? (rule) => persistProjectRule(project, rule) : undefined,
|
persistRule: trusted ? (rule) => persistProjectRule(project, rule) : undefined,
|
||||||
spawn,
|
// subagents: false — no task tool, and no team (team.md §2).
|
||||||
|
spawn: loaded.config.subagents === false ? undefined : spawn,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The session's team (spec 3.2.0, team.md): its size from the configuration (the cap the global
|
||||||
|
// one's), the capacity of each model as the server says it.
|
||||||
|
const capacityOf = (ref: string): Capacity => {
|
||||||
|
try {
|
||||||
|
const s = ref === engine.model.ref ? engine.model.spec : resolveModel(loaded, ref).spec
|
||||||
|
return { parallel: s.parallel, single_session: s.single_session, group: s.group }
|
||||||
|
} catch {
|
||||||
|
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 = () =>
|
||||||
|
new Team({
|
||||||
|
min: teamSize.min ?? loaded.config.agents?.min,
|
||||||
|
max: teamSize.max ?? loaded.config.agents?.max,
|
||||||
|
cap: loaded.config.agents?.cap,
|
||||||
|
subagents: loaded.config.subagents !== false,
|
||||||
|
model: engine.model.ref,
|
||||||
|
capacity: capacityOf,
|
||||||
|
models: usableModels,
|
||||||
|
leadState: "idle",
|
||||||
|
readingBash: (command) => readingBashByDefault(command, project.root),
|
||||||
|
mode: engine.mode,
|
||||||
|
steps: { main: loaded.config.limits?.steps ?? LOOP.steps.default, sub: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default },
|
||||||
|
})
|
||||||
|
const teamRt: TeamRuntime = new TeamRuntime({
|
||||||
|
team: newTeam(),
|
||||||
|
bus,
|
||||||
|
// spawn makes the member's engine and attaches it before its first await, so a message to it
|
||||||
|
// finds it at once.
|
||||||
|
spawnMember: (member: TeamAgent, text: string) => {
|
||||||
|
spawn(member.agent, text, { member, model: member.model, background: true, description: `${member.name}: ${member.role}` }).catch((e) => {
|
||||||
|
bus.emit({ type: "notice", level: "warn", message: `team: ${member.name} could not start: ${(e as Error).message}` })
|
||||||
|
teamRt.memberDone(member.name, "", `error — ${(e as Error).message}`, [])
|
||||||
|
})
|
||||||
|
},
|
||||||
|
lead: {
|
||||||
|
note: (text) => engine.note(text),
|
||||||
|
wake: (text) => wake(text, shownLine(text)),
|
||||||
|
waiting: (model) => engine.progress.set({ phase: "waiting_for_model", model: engine.model.spec.name ?? model, reason: "slots" }),
|
||||||
|
},
|
||||||
|
tools: () => tools,
|
||||||
|
resolve: (agent, model) => {
|
||||||
|
if (!agentList.some((a) => a.name === agent)) return { error: `There is no agent "${agent}". The agents are: ${agentList.map((a) => a.name).join(", ")}.` }
|
||||||
|
try {
|
||||||
|
// 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 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// The lead's turn ended: it is idle, and its slot frees (team.md §8).
|
||||||
|
bus.on((e) => e.type === "done" && teamRt.leadDone())
|
||||||
|
// Spec 3.4.0: the main agent's mode reaches down the tree; its progress and context are in it.
|
||||||
|
bus.on((e) => {
|
||||||
|
if (e.type === "mode") teamRt.mainMode(e.mode)
|
||||||
|
else if (e.type === "phase") {
|
||||||
|
teamRt.team.setProgress(TEAM.names.lead, e.progress)
|
||||||
|
if (teamRt.team.hasMembers()) teamRt.notify()
|
||||||
|
} else if (e.type === "usage" && !e.child && e.context) teamRt.team.setContext(TEAM.names.lead, e.used ?? e.usage.input + e.usage.output, e.context)
|
||||||
})
|
})
|
||||||
|
|
||||||
// MCP tools join the engine's list as servers connect (the engine reads it at each prompt).
|
// MCP tools join the engine's list as servers connect (the engine reads it at each prompt).
|
||||||
@@ -721,19 +890,28 @@ export function createApp(o: AppOptions): App {
|
|||||||
},
|
},
|
||||||
live(key) {
|
live(key) {
|
||||||
if (key === "model") return engine.model.ref
|
if (key === "model") return engine.model.ref
|
||||||
if (key === "effort") return engine.effort ?? "off"
|
if (key === "effort") return engine.effort
|
||||||
if (key === "mode") return engine.mode
|
if (key === "mode") return engine.mode
|
||||||
|
// The team's size as the session holds it (the cap applied).
|
||||||
|
if (key === "agents.min") return teamRt.team.min
|
||||||
|
if (key === "agents.max") return teamRt.team.max
|
||||||
return key.split(".").reduce<unknown>((x, k) => (x && typeof x === "object" ? (x as Record<string, unknown>)[k] : undefined), loaded.config)
|
return key.split(".").reduce<unknown>((x, k) => (x && typeof x === "object" ? (x as Record<string, unknown>)[k] : undefined), loaded.config)
|
||||||
},
|
},
|
||||||
check(key, v) {
|
check(key, v) {
|
||||||
if (key === "model" || key === "small_model") resolveModel(loaded, String(v))
|
if (key === "model" || key === "small_model") resolveModel(loaded, String(v))
|
||||||
if (key === "effort" && v !== "off" && !effortsFor(engine.model).includes(v as Effort))
|
if (key === "effort" && !(levelsFor(engine.model).levels as string[]).includes(String(v)))
|
||||||
throw new Error(`${engine.model.ref} takes ${effortsFor(engine.model).join(", ")} or off`)
|
throw new Error(`${engine.model.ref} offers the levels ${levelsFor(engine.model).levels.join(", ")}`)
|
||||||
if (key === "knowledge") for (const b of v as string[]) if (!library.base(b)) throw new Error(`there is no knowledge base "${b}" — lembas kb lists them`)
|
if (key === "knowledge") for (const b of v as string[]) if (!library.base(b)) throw new Error(`there is no knowledge base "${b}" — lembas kb lists them`)
|
||||||
|
// The team's size (team.md §2): refused outside 1 ≤ min ≤ max ≤ cap, with the bounds.
|
||||||
|
if (key === "agents.min" || key === "agents.max") {
|
||||||
|
const t = teamRt.team
|
||||||
|
const no = Team.check(key === "agents.min" ? Number(v) : t.min, key === "agents.max" ? Number(v) : t.max, t.cap)
|
||||||
|
if (no) throw new Error(no)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
apply(key, v) {
|
apply(key, v) {
|
||||||
if (key === "model") switchModel(String(v))
|
if (key === "model") switchModel(String(v))
|
||||||
else if (key === "effort") engine.effort = v === "off" ? null : (v as Effort)
|
else if (key === "effort") engine.effort = v as Level
|
||||||
else if (key === "mode") engine.mode = v as Mode
|
else if (key === "mode") engine.mode = v as Mode
|
||||||
else if (key === "limits.steps") engine.o.maxSteps = Number(v)
|
else if (key === "limits.steps") engine.o.maxSteps = Number(v)
|
||||||
else if (key === "busy_input") engine.busyInput = v as "steer" | "queue"
|
else if (key === "busy_input") engine.busyInput = v as "steer" | "queue"
|
||||||
@@ -741,6 +919,14 @@ export function createApp(o: AppOptions): App {
|
|||||||
else if (key === "limits.wall_seconds" || key === "limits.output_bytes" || key === "limits.completion_tokens") engine.o.budget = { ...engine.o.budget, [key.slice("limits.".length)]: Number(v) }
|
else if (key === "limits.wall_seconds" || key === "limits.output_bytes" || key === "limits.completion_tokens") engine.o.budget = { ...engine.o.budget, [key.slice("limits.".length)]: Number(v) }
|
||||||
else if (key === "compaction.auto_at") compaction.autoAt = Number(v)
|
else if (key === "compaction.auto_at") compaction.autoAt = Number(v)
|
||||||
else if (key === "knowledge") libraryCtx.bases = (v as string[]).length ? (v as string[]) : undefined
|
else if (key === "knowledge") libraryCtx.bases = (v as string[]).length ? (v as string[]) : undefined
|
||||||
|
else if (key === "agents.min" || key === "agents.max") {
|
||||||
|
// Said to the lead as a note on its next message (team.md §2), never in the prompt.
|
||||||
|
const t = teamRt.team
|
||||||
|
t.setSetting(key === "agents.min" ? Number(v) : t.min, key === "agents.max" ? Number(v) : t.max)
|
||||||
|
teamSize.min = t.min
|
||||||
|
teamSize.max = t.max
|
||||||
|
bus.emit({ type: "team" })
|
||||||
|
}
|
||||||
if (key !== "model" && key !== "effort" && key !== "mode") setIn(key, v)
|
if (key !== "model" && key !== "effort" && key !== "mode") setIn(key, v)
|
||||||
bus.emit({ type: "setting", key, value: v })
|
bus.emit({ type: "setting", key, value: v })
|
||||||
},
|
},
|
||||||
@@ -792,6 +978,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
mcpReady,
|
mcpReady,
|
||||||
small,
|
small,
|
||||||
close: () => {
|
close: () => {
|
||||||
|
teamRt.stopAll()
|
||||||
detach()
|
detach()
|
||||||
for (const d of childDetach.splice(0)) d()
|
for (const d of childDetach.splice(0)) d()
|
||||||
return mcp.close()
|
return mcp.close()
|
||||||
@@ -806,6 +993,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
jobsSessionId,
|
jobsSessionId,
|
||||||
jobs: () => registry.list(jobsSessionId()),
|
jobs: () => registry.list(jobsSessionId()),
|
||||||
wake,
|
wake,
|
||||||
|
team: teamRt,
|
||||||
get context() {
|
get context() {
|
||||||
return context
|
return context
|
||||||
},
|
},
|
||||||
@@ -831,6 +1019,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
const s = store?.createSession(project.root, engine.model.ref)
|
const s = store?.createSession(project.root, engine.model.ref)
|
||||||
keepCwd(s?.id)
|
keepCwd(s?.id)
|
||||||
standIn = `local_${crypto.randomUUID().slice(0, 8)}`
|
standIn = `local_${crypto.randomUUID().slice(0, 8)}`
|
||||||
|
teamRt.reset(newTeam())
|
||||||
engine.newSession(s?.id)
|
engine.newSession(s?.id)
|
||||||
turns.reset()
|
turns.reset()
|
||||||
if (s) bus.emit({ type: "session", id: s.id, model: engine.model.ref, mode: engine.mode })
|
if (s) bus.emit({ type: "session", id: s.id, model: engine.model.ref, mode: engine.mode })
|
||||||
@@ -844,6 +1033,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
const before = engine.sessionId
|
const before = engine.sessionId
|
||||||
frozen = freeze()
|
frozen = freeze()
|
||||||
void readServerMemory()
|
void readServerMemory()
|
||||||
|
teamRt.reset(newTeam())
|
||||||
engine.resume(id, store.context(id))
|
engine.resume(id, store.context(id))
|
||||||
// A session from before titles gets its first prompt's line now, and the model's after the next reply.
|
// A session from before titles gets its first prompt's line now, and the model's after the next reply.
|
||||||
firstShown = ""
|
firstShown = ""
|
||||||
|
|||||||
+8
-2
@@ -47,7 +47,7 @@ export type Event =
|
|||||||
| { type: "steered"; texts: string[]; ids?: string[] }
|
| { type: "steered"; texts: string[]; ids?: string[] }
|
||||||
/** A task — one prompt, with every step and tool it takes — starts or ends; `turnId` as on `prompt`. */
|
/** A task — one prompt, with every step and tool it takes — starts or ends; `turnId` as on `prompt`. */
|
||||||
| { type: "task"; state: "start" | "end"; turnId?: string }
|
| { type: "task"; state: "start" | "end"; turnId?: string }
|
||||||
| { type: "notice"; message: string; level?: "info" | "warn" }
|
| { type: "notice"; message: string; level?: "info" | "warn"; /** Spec 3.4.0: a request that will be sent again. */ retry?: { reason: string; in: number; attempt?: number } }
|
||||||
/** Discard the reply streamed so far in this step: it is being retried. */
|
/** Discard the reply streamed so far in this step: it is being retried. */
|
||||||
| { type: "retract" }
|
| { type: "retract" }
|
||||||
/** The session's plan was written, changed or cleared (plan_write, plan_update, /new). */
|
/** The session's plan was written, changed or cleared (plan_write, plan_update, /new). */
|
||||||
@@ -62,6 +62,12 @@ export type Event =
|
|||||||
/** The note about that job was given to the model: inside a turn at its next step, or as the
|
/** The note about that job was given to the model: inside a turn at its next step, or as the
|
||||||
* `machine` turn whose `prompt` follows. */
|
* `machine` turn whose `prompt` follows. */
|
||||||
| { type: "job_note"; jobId: string; text: string }
|
| { type: "job_note"; jobId: string; text: string }
|
||||||
|
/** The session's team changed (spec 3.2.0): a member added, started, finished or stopped, a
|
||||||
|
* message in the room. Read the team itself for what. */
|
||||||
|
| { type: "team" }
|
||||||
|
/** Spec 3.4.0 (harness/progress.md): what the session's agent is doing now, or null. A
|
||||||
|
* subagent's progress is in the tree (`team`). */
|
||||||
|
| { type: "phase"; progress: import("../session/progress.ts").Progress | null }
|
||||||
/** The permission mode changed from inside a turn (plan approved). */
|
/** The permission mode changed from inside a turn (plan approved). */
|
||||||
| { type: "mode"; mode: Mode }
|
| { type: "mode"; mode: Mode }
|
||||||
/** The session's model or effort changed, for whatever reason — /model, /effort, the settings
|
/** The session's model or effort changed, for whatever reason — /model, /effort, the settings
|
||||||
@@ -124,7 +130,7 @@ export interface Asker {
|
|||||||
purpose?: string
|
purpose?: string
|
||||||
/** Set when a subagent running as a background job asks, so the card can say which — it may
|
/** Set when a subagent running as a background job asks, so the card can say which — it may
|
||||||
* ask while the session itself is idle. */
|
* ask while the session itself is idle. */
|
||||||
source?: { jobId: string; agent: string; description: string }
|
source?: { jobId: string; agent: string; description: string; /** Spec 3.4.0: a subagent of the tree. */ subagent?: { id: string; name: string; position: string } }
|
||||||
}): Promise<AskReply>
|
}): Promise<AskReply>
|
||||||
/** The ask_user tool. Absent: the tool reports that nobody can be asked. `callId`: the tool call's
|
/** The ask_user tool. Absent: the tool reports that nobody can be asked. `callId`: the tool call's
|
||||||
* id, which keys the card on another screen. */
|
* id, which keys the card on another screen. */
|
||||||
|
|||||||
+38
-12
@@ -5,13 +5,13 @@ import { parseArgs } from "node:util"
|
|||||||
import { createApp } from "./app.ts"
|
import { createApp } from "./app.ts"
|
||||||
import { attachmentsFor } from "./project/attach.ts"
|
import { attachmentsFor } from "./project/attach.ts"
|
||||||
import { ConfigError, loadConfig } from "./config/load.ts"
|
import { ConfigError, loadConfig } from "./config/load.ts"
|
||||||
import { asMode, EFFORTS, MODES, type Effort, type Mode } from "./config/schema.ts"
|
import { asMode, EFFORTS, MODES, type Mode } from "./config/schema.ts"
|
||||||
import { isLocal, localRoot } from "./config/paths.ts"
|
import { isLocal, localRoot } from "./config/paths.ts"
|
||||||
import { SettingError } from "./config/settings.ts"
|
import { SettingError } from "./config/settings.ts"
|
||||||
import { renderJson, renderPlain, settleJobs, terminalAsker } from "./headless.ts"
|
import { renderJson, renderPlain, settleJobs, terminalAsker } from "./headless.ts"
|
||||||
import { enterLocal, localFor, LocalError, localRefusal, localTmp, LOCAL_FLAGS } from "./project/local.ts"
|
import { enterLocal, localFor, LocalError, localRefusal, localTmp, LOCAL_FLAGS } from "./project/local.ts"
|
||||||
import { findProject, setTrust, trustOf } from "./project/root.ts"
|
import { findProject, setTrust, trustOf } from "./project/root.ts"
|
||||||
import { effortsFor } from "./provider/effort.ts"
|
import { isLevel, levelsFor } from "./provider/effort.ts"
|
||||||
import { clientFor, ModelError, NOT_LOGGED_IN, resolveModel } from "./provider/index.ts"
|
import { clientFor, ModelError, NOT_LOGGED_IN, resolveModel } from "./provider/index.ts"
|
||||||
import { Store } from "./session/store.ts"
|
import { Store } from "./session/store.ts"
|
||||||
import { promptTitle } from "./session/title.ts"
|
import { promptTitle } from "./session/title.ts"
|
||||||
@@ -45,12 +45,16 @@ Usage:
|
|||||||
lembas serve --stdio an ACP agent on stdin/stdout, for an editor that speaks ACP
|
lembas serve --stdio an ACP agent on stdin/stdout, for an editor that speaks ACP
|
||||||
lembas service install | uninstall | status | logs [-f] | run
|
lembas service install | uninstall | status | logs [-f] | run
|
||||||
work from a LLeMbas instance as a background service (remote:)
|
work from a LLeMbas instance as a background service (remote:)
|
||||||
|
lembas service scheduled [on|off]
|
||||||
|
Accept scheduled work: your schedules may run here as agent
|
||||||
|
turns (remote.scheduled, off by default)
|
||||||
lembas remote list | add <dir>… | remove <dir>… | enable | disable
|
lembas remote list | add <dir>… | remove <dir>… | enable | disable
|
||||||
| mode <manual|edit|auto|plan> | terminal <on|off> [--json]
|
| mode <manual|edit|auto|plan> | terminal <on|off> | scheduled <on|off> [--json]
|
||||||
what the instance may do here (remote:): the directories it may
|
what the instance may do here (remote:): the directories it may
|
||||||
work in (add turns remote work on), the most permissive mode,
|
work in (add turns remote work on), the most permissive mode,
|
||||||
terminals; a running service applies a change within seconds
|
terminals; a running service applies a change within seconds
|
||||||
(--json: one object, {enabled, roots, max_mode, terminal})
|
(--json: one object, {enabled, roots, max_mode, terminal,
|
||||||
|
scheduled})
|
||||||
lembas mcp [list] connect the MCP servers and list their tools and prompts
|
lembas mcp [list] connect the MCP servers and list their tools and prompts
|
||||||
lembas mcp auth <server> sign in to a remote MCP server (OAuth)
|
lembas mcp auth <server> sign in to a remote MCP server (OAuth)
|
||||||
lembas mcp logout <server> forget a server's stored sign-in
|
lembas mcp logout <server> forget a server's stored sign-in
|
||||||
@@ -84,7 +88,7 @@ Local mode:
|
|||||||
Options for run:
|
Options for run:
|
||||||
-m, --model conn/model model to use (default: config.yaml \`model\`)
|
-m, --model conn/model model to use (default: config.yaml \`model\`)
|
||||||
--mode MODE manual | edit | auto | plan
|
--mode MODE manual | edit | auto | plan
|
||||||
-e, --effort LEVEL ${EFFORTS.join(" | ")} | off
|
-e, --effort LEVEL ${EFFORTS.join(" | ")} | off | default (one the model offers)
|
||||||
-y, --yes approve every ask (still never the hardline)
|
-y, --yes approve every ask (still never the hardline)
|
||||||
--json events as JSON lines
|
--json events as JSON lines
|
||||||
--reasoning show the model's reasoning
|
--reasoning show the model's reasoning
|
||||||
@@ -98,7 +102,7 @@ function fail(msg: string, code = 1): never {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loginCommand(cmd: "login" | "logout", args: string[]) {
|
async function loginCommand(cmd: "login" | "logout", args: string[]) {
|
||||||
const { codeEvent, codeInstructions, doneEvent, instances, isWebuiEntry, login, loginSummary, loginWithToken, logout, sync, LembasError } = await import("./lembas/login.ts")
|
const { codeEvent, codeInstructions, doneEvent, instances, isWebuiEntry, login, loginSummary, loginWithToken, logout, sync, LembasError, SignInRejected } = await import("./lembas/login.ts")
|
||||||
const { values, positionals } = parseArgs({
|
const { values, positionals } = parseArgs({
|
||||||
args,
|
args,
|
||||||
allowPositionals: true,
|
allowPositionals: true,
|
||||||
@@ -144,10 +148,19 @@ async function loginCommand(cmd: "login" | "logout", args: string[]) {
|
|||||||
// `login` again with no address refreshes the one instance there is.
|
// `login` again with no address refreshes the one instance there is.
|
||||||
const again = address ? Object.values(known).find((i) => sameBase(i.base_url, address)) : Object.keys(known).length === 1 ? Object.values(known)[0] : undefined
|
const again = address ? Object.values(known).find((i) => sameBase(i.base_url, address)) : Object.keys(known).length === 1 ? Object.values(known)[0] : undefined
|
||||||
if (again && !values.ca && isWebuiEntry(again.connection)) {
|
if (again && !values.ca && isWebuiEntry(again.connection)) {
|
||||||
|
try {
|
||||||
const r = await sync(again.connection, { setDefault: values.default })
|
const r = await sync(again.connection, { setDefault: values.default })
|
||||||
if (json) emit(doneEvent(r, false))
|
if (json) emit(doneEvent(r, false))
|
||||||
else console.log(loginSummary(r, false).join("\n"))
|
else console.log(loginSummary(r, false).join("\n"))
|
||||||
return
|
return
|
||||||
|
} catch (e) {
|
||||||
|
// The token here was revoked or is unknown to the instance: refreshing it can never work,
|
||||||
|
// and "run login again" would send the person straight back here. Sign in afresh instead;
|
||||||
|
// the new login replaces the old one.
|
||||||
|
if (!(e instanceof SignInRejected)) throw e
|
||||||
|
if (!json) console.error("The instance no longer accepts the sign-in kept here: signing in again.")
|
||||||
|
address = again.base_url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// An older kind of login (the models copied into connections.yaml) is replaced by signing in
|
// An older kind of login (the models copied into connections.yaml) is replaced by signing in
|
||||||
// again, at the address it had.
|
// again, at the address it had.
|
||||||
@@ -240,7 +253,7 @@ async function main() {
|
|||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
model: values.model,
|
model: values.model,
|
||||||
mode: asMode(values.mode),
|
mode: asMode(values.mode),
|
||||||
effort: values.effort as Effort | "off" | undefined,
|
effort: values.effort,
|
||||||
resume: values.session ?? (values.continue ? "last" : undefined),
|
resume: values.session ?? (values.continue ? "last" : undefined),
|
||||||
})
|
})
|
||||||
if (done.reload) await (await import("./reload.ts")).restart(done.reload)
|
if (done.reload) await (await import("./reload.ts")).restart(done.reload)
|
||||||
@@ -277,7 +290,20 @@ async function main() {
|
|||||||
if (sub === "status") return void console.log(service.status().join("\n"))
|
if (sub === "status") return void console.log(service.status().join("\n"))
|
||||||
if (sub === "logs") process.exit(service.logs(argv.includes("-f") || argv.includes("--follow")))
|
if (sub === "logs") process.exit(service.logs(argv.includes("-f") || argv.includes("--follow")))
|
||||||
if (sub === "run") process.exit(await service.run(argv[2]))
|
if (sub === "run") process.exit(await service.run(argv[2]))
|
||||||
fail("usage: lembas service install | uninstall | status | logs [-f] | run [connection]", 2)
|
// Accept scheduled work (spec 3.5.0): the same switch as `lembas remote scheduled`.
|
||||||
|
if (sub === "scheduled") {
|
||||||
|
const { remoteCommand } = await import("./remote.ts")
|
||||||
|
const v = argv[2]
|
||||||
|
if (v === undefined || v === "--json") {
|
||||||
|
const { remoteState } = await import("./config/remote.ts")
|
||||||
|
const s = remoteState()
|
||||||
|
if (v === "--json") console.log(JSON.stringify({ scheduled: s.scheduled, enabled: s.enabled }))
|
||||||
|
else console.log(`accept scheduled work: ${s.scheduled ? "on" : "off"}${s.scheduled && !s.enabled ? " (but remote work is off, so nothing runs: lembas remote enable)" : ""}`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
process.exit(remoteCommand(["scheduled", ...argv.slice(2)]))
|
||||||
|
}
|
||||||
|
fail("usage: lembas service install | uninstall | status | logs [-f] | run [connection] | scheduled [on|off]", 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmd === "run") {
|
if (cmd === "run") {
|
||||||
@@ -299,14 +325,13 @@ async function main() {
|
|||||||
if (!prompt && !process.stdin.isTTY) prompt = (await new Response(Bun.stdin.stream()).text()).trim()
|
if (!prompt && !process.stdin.isTTY) prompt = (await new Response(Bun.stdin.stream()).text()).trim()
|
||||||
if (!prompt) fail('nothing to do: lembas run "your prompt"', 2)
|
if (!prompt) fail('nothing to do: lembas run "your prompt"', 2)
|
||||||
if (values.mode && !asMode(values.mode)) fail(`--mode must be one of ${MODES.join(", ")}`, 2)
|
if (values.mode && !asMode(values.mode)) fail(`--mode must be one of ${MODES.join(", ")}`, 2)
|
||||||
if (values.effort && values.effort !== "off" && !(EFFORTS as readonly string[]).includes(values.effort))
|
if (values.effort && !isLevel(values.effort)) fail(`--effort must be one of ${EFFORTS.join(", ")}, off, default`, 2)
|
||||||
fail(`--effort must be one of ${EFFORTS.join(", ")}, off`, 2)
|
|
||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
model: values.model,
|
model: values.model,
|
||||||
mode: asMode(values.mode),
|
mode: asMode(values.mode),
|
||||||
effort: values.effort as Effort | "off" | undefined,
|
effort: values.effort,
|
||||||
asker: terminalAsker({ yes: values.yes ?? false, unattended: loadConfig().config.question?.unattended, theme: loadConfig().config.theme }),
|
asker: terminalAsker({ yes: values.yes ?? false, unattended: loadConfig().config.question?.unattended, theme: loadConfig().config.theme }),
|
||||||
unattended: !values.yes && !process.stdin.isTTY,
|
unattended: !values.yes && !process.stdin.isTTY,
|
||||||
store: values["no-store"] ? false : undefined,
|
store: values["no-store"] ? false : undefined,
|
||||||
@@ -342,7 +367,8 @@ async function main() {
|
|||||||
spec.max_output ? `out ${spec.max_output}` : "",
|
spec.max_output ? `out ${spec.max_output}` : "",
|
||||||
spec.vision ? "vision" : "",
|
spec.vision ? "vision" : "",
|
||||||
spec.tools === false ? "no-tools" : "",
|
spec.tools === false ? "no-tools" : "",
|
||||||
spec.effort || spec.efforts ? `effort ${spec.effort ?? "off"} of ${effortsFor(m).join("/")}` : "",
|
((l) => (l.control === "none" ? "" : `effort ${l.start} of ${l.levels.join("/")}`))(levelsFor(m)),
|
||||||
|
spec.parallel ? `parallel ${spec.parallel}` : spec.single_session ? "parallel 1" : "",
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
console.log(` ${m.ref}${bits.length ? " — " + bits.join(", ") : ""}`)
|
console.log(` ${m.ref}${bits.length ? " — " + bits.join(", ") : ""}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,12 @@ function readConfig(file: string, label: string, notes: string[], dropped?: stri
|
|||||||
delete all[k]
|
delete all[k]
|
||||||
dropped.push(k)
|
dropped.push(k)
|
||||||
}
|
}
|
||||||
|
// The team's cap is the global configuration's: a project never raises it (harness team.md §2).
|
||||||
|
if (dropped && all.agents && typeof all.agents === "object" && !Array.isArray(all.agents) && "cap" in all.agents) {
|
||||||
|
const { cap: _cap, ...agents } = all.agents as Record<string, unknown>
|
||||||
|
all.agents = agents
|
||||||
|
dropped.push("agents.cap")
|
||||||
|
}
|
||||||
// mcp, voice and search are filled in later, one server / half / service at a time: a key that
|
// mcp, voice and search are filled in later, one server / half / service at a time: a key that
|
||||||
// cannot be read turns off that one thing instead of the whole file.
|
// cannot be read turns off that one thing instead of the whole file.
|
||||||
// update: filled in when an update is looked for — a token file missing must not stop a start.
|
// update: filled in when an update is looked for — a token file missing must not stop a start.
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export interface RemoteState {
|
|||||||
roots: string[]
|
roots: string[]
|
||||||
max_mode: Mode
|
max_mode: Mode
|
||||||
terminal: boolean
|
terminal: boolean
|
||||||
|
/** Accept scheduled work (spec 3.5.0). */
|
||||||
|
scheduled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function remoteState(b: RemoteBlock = remoteBlock()): RemoteState {
|
export function remoteState(b: RemoteBlock = remoteBlock()): RemoteState {
|
||||||
@@ -70,6 +72,7 @@ export function remoteState(b: RemoteBlock = remoteBlock()): RemoteState {
|
|||||||
roots: (b.roots ?? []).map((r) => resolve(expandHome(r))),
|
roots: (b.roots ?? []).map((r) => resolve(expandHome(r))),
|
||||||
max_mode: b.max_mode ?? "edit",
|
max_mode: b.max_mode ?? "edit",
|
||||||
terminal: b.terminal === true,
|
terminal: b.terminal === true,
|
||||||
|
scheduled: b.scheduled === true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-4
@@ -51,15 +51,17 @@ export const ModelSpec = z.strictObject({
|
|||||||
max_output: z.number().int().positive().optional().describe("Max tokens per reply."),
|
max_output: z.number().int().positive().optional().describe("Max tokens per reply."),
|
||||||
temperature: z.number().min(0).max(2).optional().describe("Sampling temperature; absent = the server's default."),
|
temperature: z.number().min(0).max(2).optional().describe("Sampling temperature; absent = the server's default."),
|
||||||
top_p: z.number().min(0).max(1).optional().describe("Nucleus sampling; absent = the server's default."),
|
top_p: z.number().min(0).max(1).optional().describe("Nucleus sampling; absent = the server's default."),
|
||||||
efforts: z.array(z.enum(EFFORTS)).optional().describe("The model's own effort vocabulary, in offering order."),
|
efforts: z.array(z.enum(EFFORTS)).optional().describe("The model's own effort vocabulary, in offering order. [] — it takes none (its one level is `default`)."),
|
||||||
effort: z.union([z.enum(EFFORTS), z.literal("off")]).optional().describe("Default effort; `off` sends none."),
|
thinking_switch: z.boolean().optional().describe("The server turns thinking off for a request with chat_template_kwargs.enable_thinking false: only then is the level `off` offered."),
|
||||||
|
effort: z.union([z.enum(EFFORTS), z.literal("off"), z.literal("default")]).optional().describe("The level a conversation starts with, when the model offers it: an effort, `off` (thinking switched off, with thinking_switch) or `default` (nothing sent, for a model that takes no efforts)."),
|
||||||
effort_style: z.enum(["top", "kwargs", "both"]).optional().describe("Where the effort goes in a request: top-level, in chat_template_kwargs, or both (the default: llama.cpp drops top-level)."),
|
effort_style: z.enum(["top", "kwargs", "both"]).optional().describe("Where the effort goes in a request: top-level, in chat_template_kwargs, or both (the default: llama.cpp drops top-level)."),
|
||||||
vision: z.boolean().optional().describe("The model reads images: @image files, pasted paths and view_image."),
|
vision: z.boolean().optional().describe("The model reads images: @image files, pasted paths and view_image."),
|
||||||
tools: z.boolean().optional().describe("false: a model without tool calls — it can only talk."),
|
tools: z.boolean().optional().describe("false: a model without tool calls — it can only talk."),
|
||||||
headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers for this model's requests."),
|
headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers for this model's requests."),
|
||||||
body: z.record(z.string(), z.unknown()).optional().describe("Merged into every request body for this model."),
|
body: z.record(z.string(), z.unknown()).optional().describe("Merged into every request body for this model."),
|
||||||
notes: z.string().max(300).optional().describe("What this model is good at, in a line: the model in use is told which others there are, for handing work over (task) or switching."),
|
notes: z.string().max(300).optional().describe("What this model is good at, in a line: the model in use is told which others there are, for handing work over (task) or switching."),
|
||||||
single_session: z.boolean().optional().describe("The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused."),
|
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."),
|
group: z.string().optional().describe("Models sharing one GPU server that holds one model at a time (llama-swap), as the LLeMbas server says (capacity.group): a subagent on another model of the same group would unload this one, so it is refused."),
|
||||||
})
|
})
|
||||||
export type ModelSpec = z.infer<typeof ModelSpec>
|
export type ModelSpec = z.infer<typeof ModelSpec>
|
||||||
@@ -239,6 +241,7 @@ export const Config = z.strictObject({
|
|||||||
skin: z.string().optional().describe("The old name of `theme`; still read when `theme` is not set."),
|
skin: z.string().optional().describe("The old name of `theme`; still read when `theme` is not set."),
|
||||||
busy_input: z.enum(["steer", "queue"]).optional().describe("A message sent while the agent works: steer (default) — it goes in at the next step, after the running tools finish, and the agent carries on with it; queue — it is sent as the next prompt once the task is done."),
|
busy_input: z.enum(["steer", "queue"]).optional().describe("A message sent while the agent works: steer (default) — it goes in at the next step, after the running tools finish, and the agent carries on with it; queue — it is sent as the next prompt once the task is done."),
|
||||||
mouse: z.boolean().optional().describe("false: leave the mouse to the terminal — its own selection and Ctrl+Shift+C work without Shift, and the wheel no longer scrolls the transcript (pgup/pgdn do). Default true."),
|
mouse: z.boolean().optional().describe("false: leave the mouse to the terminal — its own selection and Ctrl+Shift+C work without Shift, and the wheel no longer scrolls the transcript (pgup/pgdn do). Default true."),
|
||||||
|
notify: z.boolean().optional().describe("When a reply finishes, or the agent waits for you, while the terminal is not focused: ring the bell and ask the terminal for a desktop notification (OSC 9) naming the session — never what the reply says. Default true; false: neither."),
|
||||||
icons: z.enum(["emoji", "plain"]).optional().describe("emoji (default), or plain: one-column symbols for a terminal font that has no emoji."),
|
icons: z.enum(["emoji", "plain"]).optional().describe("emoji (default), or plain: one-column symbols for a terminal font that has no emoji."),
|
||||||
personality: z
|
personality: z
|
||||||
.enum(PERSONALITIES)
|
.enum(PERSONALITIES)
|
||||||
@@ -293,7 +296,16 @@ export const Config = z.strictObject({
|
|||||||
mcp: z.record(z.string(), McpServer).optional().describe("MCP servers by name. `{env:}`/`{file:}` are filled in per server (a missing one disables that server only). A project's servers count only once the project is trusted."),
|
mcp: z.record(z.string(), McpServer).optional().describe("MCP servers by name. `{env:}`/`{file:}` are filled in per server (a missing one disables that server only). A project's servers count only once the project is trusted."),
|
||||||
knowledge: z.array(z.string()).optional().describe("The knowledge bases searched (lembas kb); absent: all of them. A project's own config narrows it to what that project needs."),
|
knowledge: z.array(z.string()).optional().describe("The knowledge bases searched (lembas kb); absent: all of them. A project's own config narrows it to what that project needs."),
|
||||||
embedding: z.string().optional().describe("Global config only. An embedding model the LLeMbas server serves, by its id there: knowledge bases are then searched by meaning as well as by words. lembas kb reindex after changing it."),
|
embedding: z.string().optional().describe("Global config only. An embedding model the LLeMbas server serves, by its id there: knowledge bases are then searched by meaning as well as by words. lembas kb reindex after changing it."),
|
||||||
effort: z.union([z.enum(EFFORTS), z.literal("off")]).optional().describe("The effort a session starts with, when the model takes it; otherwise the model's own default."),
|
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("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("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."),
|
settings_tool: z.enum(["ask", "allow", "off"]).optional().describe("Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool."),
|
||||||
update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."),
|
update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."),
|
||||||
prototypes: z.boolean().optional().describe("Global config only. Offer the model prototypes: a complete HTML page in a `prototype-chat` or `prototype-page` fence, saved as a file under the state directory (prototypes/<session>/) and listed by /prototype; the LLeMbas web UI renders the same fences live. Default false."),
|
prototypes: z.boolean().optional().describe("Global config only. Offer the model prototypes: a complete HTML page in a `prototype-chat` or `prototype-page` fence, saved as a file under the state directory (prototypes/<session>/) and listed by /prototype; the LLeMbas web UI renders the same fences live. Default false."),
|
||||||
@@ -306,6 +318,7 @@ export const Config = z.strictObject({
|
|||||||
approval_timeout: z.number().int().positive().optional().describe("Seconds an approval waits for an answer from the web before it counts as a denial (default 600)."),
|
approval_timeout: z.number().int().positive().optional().describe("Seconds an approval waits for an answer from the web before it counts as a denial (default 600)."),
|
||||||
require_trust: z.boolean().optional().describe("Only projects trusted on this machine (default true)."),
|
require_trust: z.boolean().optional().describe("Only projects trusted on this machine (default true)."),
|
||||||
terminal: z.boolean().optional().describe("Let the web UI open a terminal here (LLeMbas's terminal panel on a chat on this device): a login shell as this user, in a directory remote sessions may use. Default false."),
|
terminal: z.boolean().optional().describe("Let the web UI open a terminal here (LLeMbas's terminal panel on a chat on this device): a login shell as this user, in a directory remote sessions may use. Default false."),
|
||||||
|
scheduled: z.boolean().optional().describe("Accept scheduled work (harness acp.md, spec 3.5.0): let your schedules on the LLeMbas instance run here as agent turns, in the folders remote work may use, in the schedule's mode (Edit unless it says Auto) lowered to max_mode. Default false. lembas service scheduled on|off."),
|
||||||
terminal_integration: z.boolean().optional().describe("Shell integration in those terminals (bash, zsh, fish): after your own startup files, the shell marks each prompt, command and exit status (OSC 133) and its directory (OSC 7), so the web UI shows the directory, a mark beside each finished command, and can jump between commands. false: the shell starts as it is. Default true."),
|
terminal_integration: z.boolean().optional().describe("Shell integration in those terminals (bash, zsh, fish): after your own startup files, the shell marks each prompt, command and exit status (OSC 133) and its directory (OSC 7), so the web UI shows the directory, a mark beside each finished command, and can jump between commands. false: the shell starts as it is. Default true."),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
+13
-3
@@ -9,6 +9,7 @@ import { existsSync, readFileSync } from "node:fs"
|
|||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { parse, parseDocument } from "yaml"
|
import { parse, parseDocument } from "yaml"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
import LOOP_JSON from "../../harness/loop.json"
|
||||||
import { paths } from "./paths.ts"
|
import { paths } from "./paths.ts"
|
||||||
import { asMode, Config, EFFORTS, MODES, PERSONALITIES, UPDATE_CHANNELS } from "./schema.ts"
|
import { asMode, Config, EFFORTS, MODES, PERSONALITIES, UPDATE_CHANNELS } from "./schema.ts"
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ export type Source = "default" | "global" | "project" | "session"
|
|||||||
|
|
||||||
export interface SettingDef {
|
export interface SettingDef {
|
||||||
key: string
|
key: string
|
||||||
/** model: a model the server serves, by its id; effort: one the model takes, or off; list: comma-separated. */
|
/** model: a model the server serves, by its id; effort: one of the model's levels; list: comma-separated. */
|
||||||
kind: "enum" | "model" | "effort" | "number" | "boolean" | "list" | "string"
|
kind: "enum" | "model" | "effort" | "number" | "boolean" | "list" | "string"
|
||||||
values?: readonly string[]
|
values?: readonly string[]
|
||||||
/** The agent may change it (through the settings tool, with approval). */
|
/** The agent may change it (through the settings tool, with approval). */
|
||||||
@@ -29,14 +30,22 @@ export interface SettingDef {
|
|||||||
reload?: boolean
|
reload?: boolean
|
||||||
/** What it is when nothing sets it. */
|
/** What it is when nothing sets it. */
|
||||||
fallback?: string
|
fallback?: string
|
||||||
|
/** What a person reads beside the key in /settings. */
|
||||||
|
hint?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Subagents setting's names and what its numbers count (harness loop.json subagents.setting).
|
||||||
|
const SUB = LOOP_JSON.subagents.setting
|
||||||
|
|
||||||
const ALL: Scope[] = ["session", "global", "project"]
|
const ALL: Scope[] = ["session", "global", "project"]
|
||||||
const USER: Scope[] = ["session", "global"]
|
const USER: Scope[] = ["session", "global"]
|
||||||
|
|
||||||
export const SETTINGS: SettingDef[] = [
|
export const SETTINGS: SettingDef[] = [
|
||||||
{ key: "model", kind: "model", agent: true, scopes: ALL },
|
{ key: "model", kind: "model", agent: true, scopes: ALL },
|
||||||
{ key: "effort", kind: "effort", agent: true, scopes: ALL, fallback: "the model's default" },
|
{ 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", 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: "mode", kind: "enum", values: MODES, agent: true, scopes: ALL, fallback: "manual" },
|
||||||
{ key: "small_model", kind: "model", agent: true, scopes: ALL, fallback: "the session's model" },
|
{ key: "small_model", kind: "model", agent: true, scopes: ALL, fallback: "the session's model" },
|
||||||
{ key: "knowledge", kind: "list", agent: true, scopes: ALL, fallback: "every base" },
|
{ key: "knowledge", kind: "list", agent: true, scopes: ALL, fallback: "every base" },
|
||||||
@@ -51,6 +60,7 @@ export const SETTINGS: SettingDef[] = [
|
|||||||
{ key: "theme_background", kind: "enum", values: ["theme", "terminal"], agent: true, scopes: USER, fallback: "theme" },
|
{ key: "theme_background", kind: "enum", values: ["theme", "terminal"], agent: true, scopes: USER, fallback: "theme" },
|
||||||
{ key: "busy_input", kind: "enum", values: ["steer", "queue"], agent: true, scopes: USER, fallback: "steer" },
|
{ key: "busy_input", kind: "enum", values: ["steer", "queue"], agent: true, scopes: USER, fallback: "steer" },
|
||||||
{ key: "icons", kind: "enum", values: ["emoji", "plain"], agent: true, scopes: USER, fallback: "emoji" },
|
{ key: "icons", kind: "enum", values: ["emoji", "plain"], agent: true, scopes: USER, fallback: "emoji" },
|
||||||
|
{ key: "notify", kind: "boolean", agent: true, scopes: USER, fallback: "true", hint: "bell and desktop notification when a reply finishes or the agent waits for you while the terminal is not focused" },
|
||||||
{ key: "mouse", kind: "boolean", agent: true, scopes: ["global"], reload: true, fallback: "true" },
|
{ key: "mouse", kind: "boolean", agent: true, scopes: ["global"], reload: true, fallback: "true" },
|
||||||
{ key: "compaction.auto_at", kind: "number", agent: true, scopes: ALL, fallback: "0.85" },
|
{ key: "compaction.auto_at", kind: "number", agent: true, scopes: ALL, fallback: "0.85" },
|
||||||
{ key: "search.order", kind: "list", values: ["searxng", "firecrawl", "ddg"], agent: true, scopes: ALL, fallback: "those configured, then ddg" },
|
{ key: "search.order", kind: "list", values: ["searxng", "firecrawl", "ddg"], agent: true, scopes: ALL, fallback: "those configured, then ddg" },
|
||||||
@@ -104,7 +114,7 @@ export function parseValue(def: SettingDef, raw: unknown): unknown {
|
|||||||
if (def.key === "mode") v = asMode(v) ?? v
|
if (def.key === "mode") v = asMode(v) ?? v
|
||||||
// "none" is how a dialog or a person says it; the file keeps the web UI's empty string.
|
// "none" is how a dialog or a person says it; the file keeps the web UI's empty string.
|
||||||
if (def.key === "personality" && typeof v === "string" && ["none", "off", "default"].includes(v.toLowerCase())) v = ""
|
if (def.key === "personality" && typeof v === "string" && ["none", "off", "default"].includes(v.toLowerCase())) v = ""
|
||||||
if (def.kind === "effort" && v !== "off" && !(EFFORTS as readonly string[]).includes(String(v))) throw new SettingError(`effort: one of ${EFFORTS.join(", ")} or off`)
|
if (def.kind === "effort" && !["off", "default", ...EFFORTS].includes(String(v))) throw new SettingError(`effort: one of ${EFFORTS.join(", ")}, off or default`)
|
||||||
const schema = schemaAt(def.key.split("."))
|
const schema = schemaAt(def.key.split("."))
|
||||||
const r = schema?.safeParse(v)
|
const r = schema?.safeParse(v)
|
||||||
if (!r || !r.success) throw new SettingError(`${def.key}: ${def.values ? `one of ${def.values.join(", ")}` : (r?.error.issues.map((i) => i.message).join("; ") ?? "not settable")}`)
|
if (!r || !r.success) throw new SettingError(`${def.key}: ${def.values ? `one of ${def.values.join(", ")}` : (r?.error.issues.map((i) => i.message).join("; ") ?? "not settable")}`)
|
||||||
|
|||||||
+96
-5
@@ -48,6 +48,17 @@ import t_skills_list from "../harness/tools/skills_list.json"
|
|||||||
import t_tag from "../harness/tools/tag.json"
|
import t_tag from "../harness/tools/tag.json"
|
||||||
import t_task from "../harness/tools/task.json"
|
import t_task from "../harness/tools/task.json"
|
||||||
import t_tasks from "../harness/tools/tasks.json"
|
import t_tasks from "../harness/tools/tasks.json"
|
||||||
|
import t_team_add from "../harness/tools/team_add.json"
|
||||||
|
import t_team_read from "../harness/tools/team_read.json"
|
||||||
|
import t_team_send from "../harness/tools/team_send.json"
|
||||||
|
import t_team_stop from "../harness/tools/team_stop.json"
|
||||||
|
import t_team_assign from "../harness/tools/team_assign.json"
|
||||||
|
import t_team_decide from "../harness/tools/team_decide.json"
|
||||||
|
import t_team_findings from "../harness/tools/team_findings.json"
|
||||||
|
import t_team_finish from "../harness/tools/team_finish.json"
|
||||||
|
import t_team_replace from "../harness/tools/team_replace.json"
|
||||||
|
import t_team_request from "../harness/tools/team_request.json"
|
||||||
|
import t_team_whoami from "../harness/tools/team_whoami.json"
|
||||||
import t_view_image from "../harness/tools/view_image.json"
|
import t_view_image from "../harness/tools/view_image.json"
|
||||||
import t_web_fetch from "../harness/tools/web_fetch.json"
|
import t_web_fetch from "../harness/tools/web_fetch.json"
|
||||||
import t_web_search from "../harness/tools/web_search.json"
|
import t_web_search from "../harness/tools/web_search.json"
|
||||||
@@ -72,14 +83,16 @@ export interface ToolSpecDef {
|
|||||||
purpose: string | false
|
purpose: string | false
|
||||||
/** Names each project used before spec v1; a call under one is read as this tool. */
|
/** Names each project used before spec v1; a call under one is read as this tool. */
|
||||||
aliases: { cli: string[]; llembas: string[] }
|
aliases: { cli: string[]; llembas: string[] }
|
||||||
/** `subagent`: offered only to a subagent (report), never to a session's own model. */
|
/** `subagent`: offered only to a subagent (report, team_finish, team_request), never to a
|
||||||
offered?: "subagent"
|
* session's own model; `main` (spec 3.4.0): only to the main agent, never to a subagent. */
|
||||||
|
offered?: "subagent" | "main"
|
||||||
argument_aliases?: Record<string, string>
|
argument_aliases?: Record<string, string>
|
||||||
/** Spec 3.0.0: what must hold for it to be offered — `display` (the session is shown where a file
|
/** Spec 3.0.0: what must hold for it to be offered — `display` (the session is shown where a file
|
||||||
* can be opened: over the link, to a client that offers `show`), `page`, `forge` (the chat's);
|
* can be opened: over the link, to a client that offers `show`), `page`, `forge` (the chat's);
|
||||||
* `plan` (3.1.0): plan mode. From 3.1.0 one that can come and go during a session does not
|
* `plan` (3.1.0): plan mode. From 3.1.0 one that can come and go during a session does not
|
||||||
* leave the tool out: it is offered and answers `unavailable` while unmet (context.md §10). */
|
* leave the tool out: it is offered and answers `unavailable` while unmet (context.md §10).
|
||||||
requires?: ("display" | "page" | "forge" | "plan")[]
|
* `team` (3.2.0): the team setting allows more than one agent (harness/team.md §2). */
|
||||||
|
requires?: ("display" | "page" | "forge" | "plan" | "team")[]
|
||||||
/** What a call answers, word for word, when a requirement went away after the offer. */
|
/** What a call answers, word for word, when a requirement went away after the offer. */
|
||||||
unavailable?: string
|
unavailable?: string
|
||||||
/** `always`: put to the person in every mode, auto included; never remembered. */
|
/** `always`: put to the person in every mode, auto included; never remembered. */
|
||||||
@@ -113,6 +126,75 @@ export const LOOP: {
|
|||||||
running_max: { default: number }
|
running_max: { default: number }
|
||||||
subagent_steps: { default: number }
|
subagent_steps: { default: number }
|
||||||
}
|
}
|
||||||
|
/** Subagents (spec 3.3.0, harness/subagents.md): the model refusals, what names a model, and how
|
||||||
|
* the size setting is shown. */
|
||||||
|
subagents: {
|
||||||
|
texts: { not_named: string; same_group: string; one_request: string }
|
||||||
|
named: { before: string; after: string }
|
||||||
|
setting: { label: string; min: string; max: string; hint: string; short: string; own: string }
|
||||||
|
}
|
||||||
|
/** Teams (spec 3.2.0, harness/team.md): the size setting, names, marks, texts. */
|
||||||
|
team: {
|
||||||
|
agents: { min: number; max: number; cap: number }
|
||||||
|
names: { pattern: string; lead: string; reserved: string[]; parent: string; id_prefix: string }
|
||||||
|
marks: { message: string; report: string; assignment: string; request: string; decision: string; context_full: string }
|
||||||
|
unavailable: { one_agent: string; one_request: string }
|
||||||
|
work: { write: string[]; execute: string[]; kept: string[] }
|
||||||
|
read_max: number
|
||||||
|
texts: Record<
|
||||||
|
| "added" | "added_waiting" | "added_under" | "added_waiting_under" | "sent" | "stopped" | "manager" | "full" | "bad_name" | "all_name" | "taken" | "no_goal" | "unknown" | "self" | "nobody" | "not_yours" | "nobody_to_stop" | "no_report" | "ended"
|
||||||
|
// Spec 3.4.0 (subagents.md §5–§13).
|
||||||
|
| "only_main" | "not_manager" | "wider" | "request_not_narrower" | "request_kind" | "requestable" | "not_delegated" | "not_requestable" | "requested" | "no_request" | "decided" | "allowed" | "refused" | "decision_allowed" | "decision_refused" | "no_parent" | "finished" | "has_ended" | "cycle" | "assigned" | "replaced" | "replace_brief" | "no_findings" | "noted" | "not_yours_findings" | "context_full" | "gone" | "request_body",
|
||||||
|
string
|
||||||
|
>
|
||||||
|
read: {
|
||||||
|
tree: string
|
||||||
|
agent: string
|
||||||
|
lead_role: string
|
||||||
|
states: Record<"working" | "waiting" | "finished" | "idle" | "paused" | "done" | "out_of_context" | "stopped", string>
|
||||||
|
active: string
|
||||||
|
room: string
|
||||||
|
message: string
|
||||||
|
report: string
|
||||||
|
none: string
|
||||||
|
more: string
|
||||||
|
slots: string
|
||||||
|
ended: string
|
||||||
|
ended_agent: string
|
||||||
|
context: string
|
||||||
|
request: string
|
||||||
|
}
|
||||||
|
/** Spec 3.4.0. */
|
||||||
|
main_only: { tools: string[] }
|
||||||
|
context: { pause_at: number; final_steps: number }
|
||||||
|
request: { kinds: string[]; id_prefix: string }
|
||||||
|
whoami: { lines: Record<"who" | "parent" | "task" | "mode" | "request" | "model" | "limits" | "spawn_yes" | "spawn_no" | "context", string> }
|
||||||
|
findings: { header: string; item: string }
|
||||||
|
}
|
||||||
|
/** Scheduled work on a linked computer (spec 3.5.0, acp.md): the setting, the modes, the
|
||||||
|
* approval wait, the sentinel, the watch command's limits, the refusals. */
|
||||||
|
scheduled: {
|
||||||
|
setting: { key: string; label: string; hint: string }
|
||||||
|
modes: string[]
|
||||||
|
default_mode: string
|
||||||
|
approval_wait_seconds: { default: number; min: number; max: number }
|
||||||
|
sentinel: string
|
||||||
|
statuses: string[]
|
||||||
|
monitor: { timeout_seconds: { default: number; min: number; max: number }; excerpt_chars: number; output_max_bytes: number }
|
||||||
|
texts: Record<"off" | "plan" | "denied" | "monitor_asks" | "monitor_denied" | "no_reply", string>
|
||||||
|
}
|
||||||
|
/** Progress (spec 3.4.0, harness/progress.md): the phases, their texts, the line. */
|
||||||
|
progress: {
|
||||||
|
phases: string[]
|
||||||
|
texts: Record<string, string>
|
||||||
|
reason: Record<string, string>
|
||||||
|
what: Record<string, string>
|
||||||
|
stopped_reason: Record<string, string>
|
||||||
|
line: string
|
||||||
|
no_elapsed: string[]
|
||||||
|
what_max: number
|
||||||
|
pace_per_second: number
|
||||||
|
}
|
||||||
/** Context hygiene (spec 3.0.0, harness/context.md): the result ids, the texts of a read's
|
/** Context hygiene (spec 3.0.0, harness/context.md): the result ids, the texts of a read's
|
||||||
* header, a stub, the elided line, recall; the output cap; what is deduplicated; the table
|
* header, a stub, the elided line, recall; the output cap; what is deduplicated; the table
|
||||||
* compaction adds. */
|
* compaction adds. */
|
||||||
@@ -133,6 +215,8 @@ export const LOOP: {
|
|||||||
dedupe: { by_hash: string[]; by_result: string[] }
|
dedupe: { by_hash: string[]; by_result: string[] }
|
||||||
compaction: { heading: string; line: string; rows: number }
|
compaction: { heading: string; line: string; rows: number }
|
||||||
reasoning: string
|
reasoning: string
|
||||||
|
/** The environment block's lines about where an agent session works (spec 3.2.0, §11). */
|
||||||
|
env: { project: string; started: string }
|
||||||
/** A note on the next user message (spec 3.1.0, context.md §10): its tags and its lines, in
|
/** A note on the next user message (spec 3.1.0, context.md §10): its tags and its lines, in
|
||||||
* the order they are said. */
|
* the order they are said. */
|
||||||
note: { open: string; close: string; date: string; mode: string; plan: string; memory: string; instructions: string; personality: string; mcp: string; empty: string
|
note: { open: string; close: string; date: string; mode: string; plan: string; memory: string; instructions: string; personality: string; mcp: string; empty: string
|
||||||
@@ -142,6 +226,13 @@ export const LOOP: {
|
|||||||
plan_ended: string
|
plan_ended: string
|
||||||
/** For a chat, which does not resend earlier tool results; the CLI's model has them. */
|
/** For a chat, which does not resend earlier tool results; the CLI's model has them. */
|
||||||
plan_own: string
|
plan_own: string
|
||||||
|
/** Spec 3.2.0 (team.md §10): the size setting, said to the lead. */
|
||||||
|
team: string
|
||||||
|
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. */
|
/** How long a session waits for its configured MCP servers before its first request. */
|
||||||
mcp_wait_ms: number
|
mcp_wait_ms: number
|
||||||
@@ -222,7 +313,7 @@ export function narrowParameters(parameters: Record<string, unknown>, narrow: Na
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TOOL_SPECS: Record<string, ToolSpecDef> = Object.fromEntries(
|
export const TOOL_SPECS: Record<string, ToolSpecDef> = Object.fromEntries(
|
||||||
([t_apply_patch, t_ask_user, t_bash, t_branch, t_commit, t_decisions, t_edit, t_glob, t_grep, t_job_list, t_job_output, t_job_send, t_job_stop, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_merge, t_mkdir, t_multiedit, t_mv, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_pr, t_python, t_read, t_recall, t_release, t_report, t_rm, t_search, t_session_search, t_settings, t_show, t_skill_manage, t_skill_view, t_skills_list, t_tag, t_task, t_tasks, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]),
|
([t_apply_patch, t_ask_user, t_bash, t_branch, t_commit, t_decisions, t_edit, t_glob, t_grep, t_job_list, t_job_output, t_job_send, t_job_stop, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_merge, t_mkdir, t_multiedit, t_mv, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_pr, t_python, t_read, t_recall, t_release, t_report, t_rm, t_search, t_session_search, t_settings, t_show, t_skill_manage, t_skill_view, t_skills_list, t_tag, t_task, t_tasks, t_team_add, t_team_assign, t_team_decide, t_team_findings, t_team_finish, t_team_read, t_team_replace, t_team_request, t_team_send, t_team_stop, t_team_whoami, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Whether the spec gives a tool to this CLI: not the server's alone (`scope: llembas`), and
|
/** Whether the spec gives a tool to this CLI: not the server's alone (`scope: llembas`), and
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ export interface Job {
|
|||||||
/** Set when the note's wake should not happen: a synchronous subagent whose result goes back
|
/** Set when the note's wake should not happen: a synchronous subagent whose result goes back
|
||||||
* as the task call's output. */
|
* as the task call's output. */
|
||||||
silent?: boolean
|
silent?: boolean
|
||||||
|
/** A team member's name (spec 3.2.0): not a job to the model — the job tools do not take it —
|
||||||
|
* but a subagent job to a client over the link (team.md §4, §11). */
|
||||||
|
team?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A front end holding a session: where its jobs' events go, and how a note wakes it. The
|
/** A front end holding a session: where its jobs' events go, and how a note wakes it. The
|
||||||
|
|||||||
+20
-6
@@ -27,6 +27,8 @@ export function protocolMismatch(base: string, d: Pick<Discovery, "protocol" | "
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class LembasError extends Error {}
|
export class LembasError extends Error {}
|
||||||
|
/** The instance answered 401: the token this machine holds is revoked or unknown there. `login` signs in afresh. */
|
||||||
|
export class SignInRejected extends LembasError {}
|
||||||
/** The server and this CLI speak no link protocol in common: no login, and no dial gets further. */
|
/** The server and this CLI speak no link protocol in common: no login, and no dial gets further. */
|
||||||
export class ProtocolError extends LembasError {}
|
export class ProtocolError extends LembasError {}
|
||||||
|
|
||||||
@@ -58,10 +60,12 @@ export interface ServedModel {
|
|||||||
top_p?: number
|
top_p?: number
|
||||||
efforts?: string[]
|
efforts?: string[]
|
||||||
effort?: string
|
effort?: string
|
||||||
|
/** Spec 3.2.0: the server turns thinking off for enable_thinking false. */
|
||||||
|
thinking_switch?: boolean
|
||||||
vision?: boolean
|
vision?: boolean
|
||||||
tools?: boolean
|
tools?: boolean
|
||||||
notes?: string
|
notes?: string
|
||||||
capacity?: { group?: string; single_session?: boolean }
|
capacity?: { group?: string; single_session?: boolean; parallel?: number }
|
||||||
default?: boolean
|
default?: boolean
|
||||||
/** The provider's slug — the part of `id` before its `/` — and what kind of model it is. */
|
/** The provider's slug — the part of `id` before its `/` — and what kind of model it is. */
|
||||||
provider?: string
|
provider?: string
|
||||||
@@ -103,6 +107,16 @@ async function call(url: string, init: RequestInit, tls: Tls | undefined, timeou
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One request to the instance with the device's token, its JSON answer and status (spec 3.5.0:
|
||||||
|
* the schedule routes of /api/v2). A body that is not JSON is null. */
|
||||||
|
export async function apiCall(base: string, token: string, tls: Tls | undefined, method: string, path: string, body?: unknown, timeoutMs = 30_000): Promise<{ status: number; json: any }> {
|
||||||
|
const headers: Record<string, string> = { authorization: `Bearer ${token}`, accept: "application/json" }
|
||||||
|
if (body !== undefined) headers["content-type"] = "application/json"
|
||||||
|
const res = await call(`${base}${path}`, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }, tls, timeoutMs)
|
||||||
|
const json = res.status === 204 ? null : await res.json().catch(() => null)
|
||||||
|
return { status: res.status, json }
|
||||||
|
}
|
||||||
|
|
||||||
/** What a person typed, as a base address: a scheme added (https unless they said http), the path
|
/** What a person typed, as a base address: a scheme added (https unless they said http), the path
|
||||||
* and a trailing slash dropped. */
|
* and a trailing slash dropped. */
|
||||||
export function normalise(address: string): string {
|
export function normalise(address: string): string {
|
||||||
@@ -266,7 +280,7 @@ export async function checkToken(d: Discovery, token: string, tls: Tls | undefin
|
|||||||
|
|
||||||
export async function models(d: { api: { openai: string } }, token: string, tls: Tls | undefined, timeoutMs?: number): Promise<ServedModel[]> {
|
export async function models(d: { api: { openai: string } }, token: string, tls: Tls | undefined, timeoutMs?: number): Promise<ServedModel[]> {
|
||||||
const res = await call(`${d.api.openai}/models`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
|
const res = await call(`${d.api.openai}/models`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
|
||||||
if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again")
|
if (res.status === 401) throw new SignInRejected("the instance no longer accepts this sign-in — run login again")
|
||||||
if (res.status === 403) throw new LembasError("this account may not use the instance's models through the API (the “Use the API” permission)")
|
if (res.status === 403) throw new LembasError("this account may not use the instance's models through the API (the “Use the API” permission)")
|
||||||
if (!res.ok) throw new LembasError(`listing the models: ${res.status} ${res.statusText}`)
|
if (!res.ok) throw new LembasError(`listing the models: ${res.status} ${res.statusText}`)
|
||||||
const body = (await res.json()) as { data?: ServedModel[] }
|
const body = (await res.json()) as { data?: ServedModel[] }
|
||||||
@@ -291,7 +305,7 @@ export interface InstanceInfo {
|
|||||||
export async function instanceInfo(base: string, token: string, tls: Tls | undefined, timeoutMs?: number): Promise<InstanceInfo | undefined> {
|
export async function instanceInfo(base: string, token: string, tls: Tls | undefined, timeoutMs?: number): Promise<InstanceInfo | undefined> {
|
||||||
const res = await call(`${base}/api/v1/instance`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
|
const res = await call(`${base}/api/v1/instance`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
|
||||||
if (res.status === 404) return undefined
|
if (res.status === 404) return undefined
|
||||||
if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again")
|
if (res.status === 401) throw new SignInRejected("the instance no longer accepts this sign-in — run login again")
|
||||||
if (!res.ok) throw new LembasError(`reading the instance: ${res.status} ${res.statusText}`)
|
if (!res.ok) throw new LembasError(`reading the instance: ${res.status} ${res.statusText}`)
|
||||||
const body = (await res.json().catch(() => undefined)) as InstanceInfo | undefined
|
const body = (await res.json().catch(() => undefined)) as InstanceInfo | undefined
|
||||||
return body && typeof body === "object" ? body : undefined
|
return body && typeof body === "object" ? body : undefined
|
||||||
@@ -339,7 +353,7 @@ export interface Usage {
|
|||||||
export async function usage(base: string, token: string, tls: Tls | undefined, timeoutMs = 5000): Promise<Usage | undefined> {
|
export async function usage(base: string, token: string, tls: Tls | undefined, timeoutMs = 5000): Promise<Usage | undefined> {
|
||||||
const res = await call(`${base}/v1/usage`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
|
const res = await call(`${base}/v1/usage`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
|
||||||
if (res.status === 404) return undefined
|
if (res.status === 404) return undefined
|
||||||
if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again")
|
if (res.status === 401) throw new SignInRejected("the instance no longer accepts this sign-in — run login again")
|
||||||
if (!res.ok) throw new LembasError(`reading the usage: ${res.status} ${res.statusText}`)
|
if (!res.ok) throw new LembasError(`reading the usage: ${res.status} ${res.statusText}`)
|
||||||
const body = (await res.json().catch(() => undefined)) as Usage | undefined
|
const body = (await res.json().catch(() => undefined)) as Usage | undefined
|
||||||
return body && typeof body === "object" ? body : undefined
|
return body && typeof body === "object" ? body : undefined
|
||||||
@@ -367,7 +381,7 @@ function asPersonalization(b: unknown): Personalization | undefined {
|
|||||||
export async function personalization(base: string, token: string, tls: Tls | undefined, timeoutMs = 5000): Promise<Personalization | undefined> {
|
export async function personalization(base: string, token: string, tls: Tls | undefined, timeoutMs = 5000): Promise<Personalization | undefined> {
|
||||||
const res = await call(`${base}/v1/me/personalization`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
|
const res = await call(`${base}/v1/me/personalization`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
|
||||||
if (res.status === 404) return undefined
|
if (res.status === 404) return undefined
|
||||||
if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again")
|
if (res.status === 401) throw new SignInRejected("the instance no longer accepts this sign-in — run login again")
|
||||||
if (!res.ok) throw new LembasError(`reading the personalization: ${res.status} ${res.statusText}`)
|
if (!res.ok) throw new LembasError(`reading the personalization: ${res.status} ${res.statusText}`)
|
||||||
return asPersonalization(await res.json().catch(() => undefined))
|
return asPersonalization(await res.json().catch(() => undefined))
|
||||||
}
|
}
|
||||||
@@ -391,7 +405,7 @@ export async function savePersonalization(base: string, token: string, tls: Tls
|
|||||||
if (res.status === 422) throw new LembasError(`the instance would not take that personalization${detail ? `: ${detail}` : ""}`)
|
if (res.status === 422) throw new LembasError(`the instance would not take that personalization${detail ? `: ${detail}` : ""}`)
|
||||||
throw new LembasError(detail ? `the instance did not allow it: ${detail}` : "not allowed: the instance refused to save the personalization")
|
throw new LembasError(detail ? `the instance did not allow it: ${detail}` : "not allowed: the instance refused to save the personalization")
|
||||||
}
|
}
|
||||||
if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again")
|
if (res.status === 401) throw new SignInRejected("the instance no longer accepts this sign-in — run login again")
|
||||||
if (!res.ok) throw new LembasError(`saving the personalization: ${res.status} ${res.statusText}`)
|
if (!res.ok) throw new LembasError(`saving the personalization: ${res.status} ${res.statusText}`)
|
||||||
return asPersonalization(await res.json().catch(() => undefined))
|
return asPersonalization(await res.json().catch(() => undefined))
|
||||||
}
|
}
|
||||||
+2
-2
@@ -13,7 +13,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync }
|
|||||||
import { join, resolve } from "node:path"
|
import { join, resolve } from "node:path"
|
||||||
import { parseDocument } from "yaml"
|
import { parseDocument } from "yaml"
|
||||||
import { expandHome, globalPaths, isLocal, paths } from "../config/paths.ts"
|
import { expandHome, globalPaths, isLocal, paths } from "../config/paths.ts"
|
||||||
import { awaitToken, checkToken, discover, LembasError, signOut, spoken, startDevice, type DeviceStart, type Discovery, type Granted, type InstanceInfo, type ServedModel, type Tls } from "./client.ts"
|
import { awaitToken, checkToken, discover, LembasError, SignInRejected, signOut, spoken, startDevice, type DeviceStart, type Discovery, type Granted, type InstanceInfo, type ServedModel, type Tls } from "./client.ts"
|
||||||
import { dropCache, fetchWebui, isChatModel, readCache, specFor } from "./webui.ts"
|
import { dropCache, fetchWebui, isChatModel, readCache, specFor } from "./webui.ts"
|
||||||
|
|
||||||
export interface Instance {
|
export interface Instance {
|
||||||
@@ -557,4 +557,4 @@ export async function logout(connection: string): Promise<LogoutResult> {
|
|||||||
return { connection, revoked, modelLeft: model && (ours || model.startsWith(`${connection}/`)) ? model : undefined }
|
return { connection, revoked, modelLeft: model && (ours || model.startsWith(`${connection}/`)) ? model : undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
export { LembasError, readCache, specFor }
|
export { LembasError, readCache, SignInRejected, specFor }
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// `/schedule`: the account's schedules on the LLeMbas server, over the schedule routes of /api/v2,
|
||||||
|
// which accept this device's token (harness acp.md *The HTTP API*, spec 3.5.0). Scheduling is the
|
||||||
|
// server's — the rule, the runs, the history, the notifications; this is a window onto it from the
|
||||||
|
// terminal: list, create from words (the server's compile), pause, resume, run now, history.
|
||||||
|
import { remoteState } from "../config/remote.ts"
|
||||||
|
import { apiCall, LembasError, SignInRejected } from "./client.ts"
|
||||||
|
import { soleInstance } from "./personal.ts"
|
||||||
|
|
||||||
|
export interface Run {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
timing?: string
|
||||||
|
due_at?: string | null
|
||||||
|
started_at?: string | null
|
||||||
|
finished_at?: string | null
|
||||||
|
waiting_for?: string | null
|
||||||
|
retry_at?: string | null
|
||||||
|
model?: { name?: string | null } | null
|
||||||
|
preview?: string
|
||||||
|
error?: { class?: string; text?: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Schedule {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
instruction: string
|
||||||
|
description?: string | null
|
||||||
|
enabled: boolean
|
||||||
|
next_run?: string | null
|
||||||
|
last_run?: Run | null
|
||||||
|
running?: boolean
|
||||||
|
runs_on?: { device?: string; device_name?: string; cwd?: string; mode?: string } | null
|
||||||
|
watch?: { kind: string; url?: string; command?: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Compiled {
|
||||||
|
ok: boolean
|
||||||
|
reason?: string | null
|
||||||
|
title: string
|
||||||
|
instruction: string
|
||||||
|
target?: string
|
||||||
|
rule: Record<string, unknown>
|
||||||
|
description?: string | null
|
||||||
|
next_runs: string[]
|
||||||
|
timezone?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One call to the schedule routes; the server's own refusal (`detail`) as the error. */
|
||||||
|
async function api(method: string, path: string, body?: unknown): Promise<any> {
|
||||||
|
const one = soleInstance()
|
||||||
|
if (!one) throw new LembasError("not logged in to one LLeMbas server (/login)")
|
||||||
|
const r = await apiCall(one.instance.base_url, one.token, one.instance.ca ? { ca: one.instance.ca } : undefined, method, `/api/v2${path}`, body)
|
||||||
|
const detail = typeof r.json?.detail === "string" ? r.json.detail : undefined
|
||||||
|
if (r.status === 401 && !detail) throw new SignInRejected("the server no longer accepts this sign-in — /login again")
|
||||||
|
// A server from before spec 3.5.0 lets only an app's token reach /api/v2.
|
||||||
|
if (r.status === 401 || (r.status === 403 && /scope|token/i.test(detail ?? "")))
|
||||||
|
throw new LembasError(`${one.instance.connection} does not let a linked computer manage schedules yet (it needs a newer LLeMbas server)`)
|
||||||
|
if (r.status === 404 && !detail) throw new LembasError(`${one.instance.connection} has no schedules here (an older LLeMbas server)`)
|
||||||
|
if (r.status >= 400) throw new LembasError(detail ?? `${method} ${path}: ${r.status}`)
|
||||||
|
return r.json
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listSchedules = async (): Promise<Schedule[]> => ((await api("GET", "/schedules"))?.schedules ?? []) as Schedule[]
|
||||||
|
export const compileWords = async (text: string): Promise<Compiled> => (await api("POST", "/schedules/compile", { text })) as Compiled
|
||||||
|
export const pauseSchedule = async (id: string): Promise<Schedule> => (await api("POST", `/schedules/${encodeURIComponent(id)}/pause`)).schedule
|
||||||
|
export const resumeSchedule = async (id: string): Promise<Schedule> => (await api("POST", `/schedules/${encodeURIComponent(id)}/resume`)).schedule
|
||||||
|
export const runSchedule = async (id: string): Promise<Run> => (await api("POST", `/schedules/${encodeURIComponent(id)}/run`)).run
|
||||||
|
export const scheduleRuns = async (id: string, limit = 10): Promise<{ runs: Run[]; more: boolean }> => {
|
||||||
|
const r = await api("GET", `/schedules/${encodeURIComponent(id)}/runs?limit=${limit}`)
|
||||||
|
return { runs: (r?.runs ?? []) as Run[], more: r?.more === true }
|
||||||
|
}
|
||||||
|
export const runResult = async (id: string, run: string): Promise<{ run: Run; text: string | null }> => api("GET", `/schedules/${encodeURIComponent(id)}/runs/${encodeURIComponent(run)}`)
|
||||||
|
|
||||||
|
/** Create one from what compile said. `here`: it runs on this computer, in `cwd`, in Edit
|
||||||
|
* (`runs_on.device: "this"` is the calling device). */
|
||||||
|
export async function createSchedule(c: Compiled, request: string, here?: { cwd: string }): Promise<Schedule> {
|
||||||
|
const body: Record<string, unknown> = { title: c.title, instruction: c.instruction, request, rule: c.rule, ...(c.target ? { target: c.target } : {}) }
|
||||||
|
if (here) body.runs_on = { device: "this", cwd: here.cwd, mode: "edit" }
|
||||||
|
return (await api("POST", "/schedules", body)).schedule
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A schedule by its id, or by a title that starts with `query` (no case) when only one does. */
|
||||||
|
export function findSchedule(all: Schedule[], query: string): Schedule | string {
|
||||||
|
const q = query.trim()
|
||||||
|
if (!q) return "name a schedule: its id or the start of its title (/schedule lists them)"
|
||||||
|
const exact = all.find((s) => s.id === q)
|
||||||
|
if (exact) return exact
|
||||||
|
const low = q.toLowerCase()
|
||||||
|
const titled = all.filter((s) => s.title.toLowerCase() === low)
|
||||||
|
if (titled.length === 1) return titled[0]!
|
||||||
|
const starts = all.filter((s) => s.title.toLowerCase().startsWith(low))
|
||||||
|
if (starts.length === 1) return starts[0]!
|
||||||
|
if (!starts.length) return `no schedule "${q}" — /schedule lists them`
|
||||||
|
return `"${q}" fits ${starts.length} schedules: ${starts.map((s) => `${s.title} (${s.id})`).join(", ")} — use the id`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A time as the person reads it: the local date and time, minutes only. */
|
||||||
|
export function when(iso: string | null | undefined, now = new Date()): string {
|
||||||
|
if (!iso) return "—"
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return iso
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0")
|
||||||
|
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
const sameDay = d.toDateString() === now.toDateString()
|
||||||
|
return sameDay ? `today ${time}` : `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A run's status in a word or two: what history and the list show. */
|
||||||
|
export function runStatus(r: Run): string {
|
||||||
|
if (r.status === "waiting") return r.waiting_for === "device" ? "waiting for the computer" : r.waiting_for === "model" ? "waiting for a free model" : "waiting to try again"
|
||||||
|
if (r.status === "missed") return "missed"
|
||||||
|
if (r.status === "unchanged") return "unchanged (nothing new)"
|
||||||
|
if (r.status === "silent") return "nothing to report"
|
||||||
|
return r.status
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/schedule` and `/schedule list`. */
|
||||||
|
export function listLines(all: Schedule[], now = new Date()): string[] {
|
||||||
|
if (!all.length) return ["no schedules yet — /schedule new <what, and when> (\"every weekday at 8 summarise the open issues\")"]
|
||||||
|
const out: string[] = []
|
||||||
|
for (const s of all) {
|
||||||
|
const state = s.running ? "running" : s.enabled ? `next ${when(s.next_run, now)}` : "paused"
|
||||||
|
const last = s.last_run ? ` · last ${runStatus(s.last_run)}${s.last_run.finished_at ? ` ${when(s.last_run.finished_at, now)}` : ""}` : ""
|
||||||
|
const where = s.runs_on ? ` · on ${s.runs_on.device_name || "a computer"}${s.runs_on.cwd ? ` in ${s.runs_on.cwd}` : ""}` : ""
|
||||||
|
const watch = s.watch ? ` · watches ${s.watch.kind === "url" ? s.watch.url : s.watch.command}` : ""
|
||||||
|
out.push(`${s.title} (${s.id}) — ${s.description || "no rule"}`)
|
||||||
|
out.push(` ${state}${last}${where}${watch}`)
|
||||||
|
}
|
||||||
|
out.push("", "/schedule new <words> · pause|resume|run|history <id or title>")
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/schedule history`: the newest runs first. Previews are model output: shown as plain text. */
|
||||||
|
export function historyLines(s: Schedule, runs: Run[], more: boolean, now = new Date()): string[] {
|
||||||
|
const out = [`${s.title} (${s.id}) — ${s.description || "no rule"}`]
|
||||||
|
if (!runs.length) out.push(" no runs yet")
|
||||||
|
for (const r of runs) {
|
||||||
|
const at = when(r.finished_at ?? r.started_at ?? r.due_at, now)
|
||||||
|
const model = r.model?.name ? ` · ${r.model.name}` : ""
|
||||||
|
out.push(` ${at} ${runStatus(r)}${r.timing === "late" ? " (late)" : r.timing === "catch_up" ? " (caught up)" : ""}${model} [${r.id}]`)
|
||||||
|
const said = r.error?.text || (r.preview ?? "").replace(/\s+/g, " ").trim()
|
||||||
|
if (said) out.push(` ${said.length > 160 ? said.slice(0, 159) + "…" : said}`)
|
||||||
|
}
|
||||||
|
if (more) out.push(" … older runs on the server")
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What compile made of the words, before anything is created. */
|
||||||
|
export function previewLines(c: Compiled, now = new Date()): string[] {
|
||||||
|
const out = [`${c.title || "(no title)"} — ${c.description || "no rule worked out"}`]
|
||||||
|
if (c.instruction) out.push(` does: ${c.instruction}`)
|
||||||
|
if (c.next_runs.length) out.push(` next: ${c.next_runs.map((t) => when(t, now)).join(", ")}${c.timezone ? ` (${c.timezone})` : ""}`)
|
||||||
|
if (!c.ok && c.reason) out.push(` ${c.reason}`)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a schedule could run here, in `cwd`: Accept scheduled work is on and remote work may use it. */
|
||||||
|
export function canRunHere(): boolean {
|
||||||
|
const s = remoteState()
|
||||||
|
return s.enabled && s.scheduled && s.roots.length > 0
|
||||||
|
}
|
||||||
+5
-2
@@ -18,7 +18,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { parse } from "yaml"
|
import { parse } from "yaml"
|
||||||
import { connectionFiles, isLocal, paths } from "../config/paths.ts"
|
import { connectionFiles, isLocal, paths } from "../config/paths.ts"
|
||||||
import type { Connection, ModelSpec, WebuiConnection } from "../config/schema.ts"
|
import { EFFORTS, type Connection, type ModelSpec, type WebuiConnection } from "../config/schema.ts"
|
||||||
import { substituteDeep } from "../config/substitute.ts"
|
import { substituteDeep } from "../config/substitute.ts"
|
||||||
import { instanceInfo, LembasError, models as listModels, normalise, personalization, type InstanceInfo, type Personalization, type ServedModel, type Tls } from "./client.ts"
|
import { instanceInfo, LembasError, models as listModels, normalise, personalization, type InstanceInfo, type Personalization, type ServedModel, type Tls } from "./client.ts"
|
||||||
|
|
||||||
@@ -94,12 +94,15 @@ export function specFor(m: ServedModel, connection: string): ModelSpec {
|
|||||||
if (m.max_output) out.max_output = m.max_output
|
if (m.max_output) out.max_output = m.max_output
|
||||||
if (typeof m.temperature === "number" && m.temperature >= 0 && m.temperature <= 2) out.temperature = m.temperature
|
if (typeof m.temperature === "number" && m.temperature >= 0 && m.temperature <= 2) out.temperature = m.temperature
|
||||||
if (typeof m.top_p === "number" && m.top_p >= 0 && m.top_p <= 1) out.top_p = m.top_p
|
if (typeof m.top_p === "number" && m.top_p >= 0 && m.top_p <= 1) out.top_p = m.top_p
|
||||||
if (m.efforts?.length) out.efforts = m.efforts
|
// [] is said: the model takes no efforts (its one level is `default`), unlike an absent list.
|
||||||
|
if (Array.isArray(m.efforts)) out.efforts = m.efforts.filter((e) => (EFFORTS as readonly string[]).includes(e))
|
||||||
if (m.effort) out.effort = m.effort
|
if (m.effort) out.effort = m.effort
|
||||||
|
if (m.thinking_switch === true) out.thinking_switch = true
|
||||||
if (m.vision !== undefined) out.vision = m.vision
|
if (m.vision !== undefined) out.vision = m.vision
|
||||||
if (m.tools !== undefined) out.tools = m.tools
|
if (m.tools !== undefined) out.tools = m.tools
|
||||||
if (m.notes) out.notes = m.notes.slice(0, 300)
|
if (m.notes) out.notes = m.notes.slice(0, 300)
|
||||||
if (m.capacity?.single_session) out.single_session = true
|
if (m.capacity?.single_session) out.single_session = true
|
||||||
|
if (Number.isInteger(m.capacity?.parallel) && m.capacity!.parallel! >= 1) out.parallel = m.capacity!.parallel
|
||||||
if (m.capacity?.group) out.group = `${connection}:${m.capacity.group}`
|
if (m.capacity?.group) out.group = `${connection}:${m.capacity.group}`
|
||||||
return out as ModelSpec
|
return out as ModelSpec
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -371,6 +371,16 @@ export class McpManager {
|
|||||||
return r.contents.map((x) => ("text" in x && typeof x.text === "string" ? x.text : "")).join("\n").trim()
|
return r.contents.map((x) => ("text" in x && typeof x.text === "string" ? x.text : "")).join("\n").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A tool called directly (not by the model): `/memory` changing the LLeMbas server's memory.
|
||||||
|
* The text it answered; a tool error is thrown with its text. */
|
||||||
|
async callText(server: string, tool: string, args: Record<string, unknown>, scope?: string): Promise<string> {
|
||||||
|
const c = await this.clientOf(server)
|
||||||
|
const r = (await c.callTool({ name: tool, arguments: args, ...(scope ? { _meta: { lembas: { scope } } } : {}) })) as { content?: { type: string; text?: string }[]; isError?: boolean }
|
||||||
|
const text = (r.content ?? []).map((x) => (x.type === "text" && typeof x.text === "string" ? x.text : "")).join("\n").trim()
|
||||||
|
if (r.isError) throw new Error(text || `${tool} failed`)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
/** Every connected server's tools as LLeMbas CLI tools, and list/read tools for resources. */
|
/** Every connected server's tools as LLeMbas CLI tools, and list/read tools for resources. */
|
||||||
tools(): Tool[] {
|
tools(): Tool[] {
|
||||||
const out: Tool[] = []
|
const out: Tool[] = []
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface AgentSpec {
|
|||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
instructions: string
|
instructions: string
|
||||||
|
/** Read, and not used for a subagent (spec 3.3.0, subagents.md §2): shown so a file that sets it says so. */
|
||||||
model?: string
|
model?: string
|
||||||
/** A fixed permission mode; absent = the parent's. */
|
/** A fixed permission mode; absent = the parent's. */
|
||||||
mode?: Mode
|
mode?: Mode
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// them, and counted as read so the model may edit them at once. The prompt text is unchanged.
|
// them, and counted as read so the model may edit them at once. The prompt text is unchanged.
|
||||||
import { readdirSync, readFileSync, statSync } from "node:fs"
|
import { readdirSync, readFileSync, statSync } from "node:fs"
|
||||||
import { join, resolve } from "node:path"
|
import { join, resolve } from "node:path"
|
||||||
|
import { promptPath } from "../prompt/assemble.ts"
|
||||||
import type { ImagePart } from "../provider/types.ts"
|
import type { ImagePart } from "../provider/types.ts"
|
||||||
import type { ToolContext } from "../tool/tool.ts"
|
import type { ToolContext } from "../tool/tool.ts"
|
||||||
import { isImage, loadImage } from "./image.ts"
|
import { isImage, loadImage } from "./image.ts"
|
||||||
@@ -34,6 +35,8 @@ export function attachmentsFor(text: string, ctx: Pick<ToolContext, "root" | "cw
|
|||||||
const rel = m[1]!.replace(/\\([ #])/g, "$1").replace(/[.,;:!?)]+$/, "")
|
const rel = m[1]!.replace(/\\([ #])/g, "$1").replace(/[.,;:!?)]+$/, "")
|
||||||
const abs = resolve(ctx.cwd, rel)
|
const abs = resolve(ctx.cwd, rel)
|
||||||
if (allow && !allow(abs)) continue
|
if (allow && !allow(abs)) continue
|
||||||
|
// Named to the model as its tools read paths: from the project root, ~/… under home (context.md §11).
|
||||||
|
const named = promptPath(ctx.root, abs) + (rel.endsWith("/") && abs !== "/" ? "/" : "")
|
||||||
const key = `${abs}#${m[2] ?? ""}-${m[3] ?? ""}`
|
const key = `${abs}#${m[2] ?? ""}-${m[3] ?? ""}`
|
||||||
if (seen.has(key)) continue
|
if (seen.has(key)) continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
@@ -47,25 +50,25 @@ export function attachmentsFor(text: string, ctx: Pick<ToolContext, "root" | "cw
|
|||||||
const entries = readdirSync(abs, { withFileTypes: true })
|
const entries = readdirSync(abs, { withFileTypes: true })
|
||||||
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
|
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
|
||||||
.sort()
|
.sort()
|
||||||
out.push({ path: rel, text: `<directory path="${rel}">\n${entries.join("\n")}\n</directory>` })
|
out.push({ path: rel, text: `<directory path="${named}">\n${entries.join("\n")}\n</directory>` })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (isImage(abs)) {
|
if (isImage(abs)) {
|
||||||
if (!vision) {
|
if (!vision) {
|
||||||
out.push({ path: rel, text: `<image path="${rel}">(not attached: this model has no vision)</image>` })
|
out.push({ path: rel, text: `<image path="${named}">(not attached: this model has no vision)</image>` })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const img = loadImage(abs)
|
const img = loadImage(abs)
|
||||||
out.push(typeof img === "string" ? { path: rel, text: `<image path="${rel}">(not attached: ${img})</image>` } : { path: rel, text: `<image path="${rel}"/>`, image: img })
|
out.push(typeof img === "string" ? { path: rel, text: `<image path="${named}">(not attached: ${img})</image>` } : { path: rel, text: `<image path="${named}"/>`, image: img })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (st.size > MAX_BYTES && !m[2]) {
|
if (st.size > MAX_BYTES && !m[2]) {
|
||||||
out.push({ path: rel, text: `<file path="${rel}">\n(${st.size} bytes — too large to attach whole; read it in parts, or reference a range like @${rel}#1-200)\n</file>` })
|
out.push({ path: rel, text: `<file path="${named}">\n(${st.size} bytes — too large to attach whole; read it in parts, or reference a range like @${named}#1-200)\n</file>` })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const raw = readFileSync(abs)
|
const raw = readFileSync(abs)
|
||||||
if (raw.subarray(0, 8192).includes(0)) {
|
if (raw.subarray(0, 8192).includes(0)) {
|
||||||
out.push({ path: rel, text: `<file path="${rel}">\n(binary file, ${st.size} bytes)\n</file>` })
|
out.push({ path: rel, text: `<file path="${named}">\n(binary file, ${st.size} bytes)\n</file>` })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const lines = raw.toString("utf8").split("\n")
|
const lines = raw.toString("utf8").split("\n")
|
||||||
@@ -75,7 +78,7 @@ export function attachmentsFor(text: string, ctx: Pick<ToolContext, "root" | "cw
|
|||||||
const body = lines.slice(from - 1, to).map((l, i) => `${from + i}: ${l}`).join("\n")
|
const body = lines.slice(from - 1, to).map((l, i) => `${from + i}: ${l}`).join("\n")
|
||||||
const range = m[2] ? ` lines="${from}-${to}"` : ""
|
const range = m[2] ? ` lines="${from}-${to}"` : ""
|
||||||
const more = !m[2] && lines.length > to ? `\n(${lines.length - to} more lines — read with offset ${to + 1})` : ""
|
const more = !m[2] && lines.length > to ? `\n(${lines.length - to} more lines — read with offset ${to + 1})` : ""
|
||||||
out.push({ path: rel, text: `<file path="${rel}"${range}>\n${body}${more}\n</file>` })
|
out.push({ path: rel, text: `<file path="${named}"${range}>\n${body}${more}\n</file>` })
|
||||||
if (!m[2]) {
|
if (!m[2]) {
|
||||||
ctx.readFiles.add(abs)
|
ctx.readFiles.add(abs)
|
||||||
ctx.fileStamps.set(abs, st.mtimeMs)
|
ctx.fileStamps.set(abs, st.mtimeMs)
|
||||||
|
|||||||
+63
-10
@@ -1,6 +1,7 @@
|
|||||||
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
|
||||||
import { platform, release } from "node:os"
|
import { homedir, platform, release } from "node:os"
|
||||||
import { dirname, join, relative, resolve, sep } from "node:path"
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
|
||||||
|
import { LOOP } from "../harness.ts"
|
||||||
import type { Mode } from "../config/schema.ts"
|
import type { Mode } from "../config/schema.ts"
|
||||||
import { expandHome, paths } from "../config/paths.ts"
|
import { expandHome, paths } from "../config/paths.ts"
|
||||||
import { render, type Plan } from "../plan/plan.ts"
|
import { render, type Plan } from "../plan/plan.ts"
|
||||||
@@ -17,6 +18,9 @@ import blockLibrary from "../../harness/prompts/blocks/library.md" with { type:
|
|||||||
import blockPrototypes from "../../harness/prompts/blocks/prototypes.md" with { type: "text" }
|
import blockPrototypes from "../../harness/prompts/blocks/prototypes.md" with { type: "text" }
|
||||||
import blockSubagent from "../../harness/prompts/blocks/subagent.md" with { type: "text" }
|
import blockSubagent from "../../harness/prompts/blocks/subagent.md" with { type: "text" }
|
||||||
import blockContext from "../../harness/prompts/blocks/context.md" with { type: "text" }
|
import blockContext from "../../harness/prompts/blocks/context.md" with { type: "text" }
|
||||||
|
import blockTeamLead from "../../harness/prompts/blocks/team_lead.md" with { type: "text" }
|
||||||
|
import blockTeamMember from "../../harness/prompts/blocks/team_member.md" with { type: "text" }
|
||||||
|
import blockTeamSubmanager from "../../harness/prompts/blocks/team_submanager.md" with { type: "text" }
|
||||||
import agentExplore from "../../harness/prompts/agents/explore.md" with { type: "text" }
|
import agentExplore from "../../harness/prompts/agents/explore.md" with { type: "text" }
|
||||||
import agentGeneral from "../../harness/prompts/agents/general.md" with { type: "text" }
|
import agentGeneral from "../../harness/prompts/agents/general.md" with { type: "text" }
|
||||||
import modeManual from "../../harness/prompts/modes/manual.md" with { type: "text" }
|
import modeManual from "../../harness/prompts/modes/manual.md" with { type: "text" }
|
||||||
@@ -56,6 +60,9 @@ const BUILTIN: Record<string, string> = {
|
|||||||
"blocks/prototypes.md": blockPrototypes,
|
"blocks/prototypes.md": blockPrototypes,
|
||||||
"blocks/subagent.md": blockSubagent,
|
"blocks/subagent.md": blockSubagent,
|
||||||
"blocks/context.md": blockContext,
|
"blocks/context.md": blockContext,
|
||||||
|
"blocks/team_lead.md": blockTeamLead,
|
||||||
|
"blocks/team_member.md": blockTeamMember,
|
||||||
|
"blocks/team_submanager.md": blockTeamSubmanager,
|
||||||
"agents/explore.md": agentExplore,
|
"agents/explore.md": agentExplore,
|
||||||
"agents/general.md": agentGeneral,
|
"agents/general.md": agentGeneral,
|
||||||
"modes/manual.md": modeManual,
|
"modes/manual.md": modeManual,
|
||||||
@@ -231,6 +238,12 @@ export interface PromptContext {
|
|||||||
/** Set for a subagent: who it is and what it is for; replaces the mode's own instructions.
|
/** Set for a subagent: who it is and what it is for; replaces the mode's own instructions.
|
||||||
* `report`: it has the report tool. */
|
* `report`: it has the report tool. */
|
||||||
subagent?: { name: string; instructions: string; report?: boolean }
|
subagent?: { name: string; instructions: string; report?: boolean }
|
||||||
|
/** Spec 3.2.0 (team.md §4), 3.4.0: a subagent of the tree — blocks/team_member.md in place of
|
||||||
|
* blocks/subagent.md, with blocks/team_submanager.md for a sub-manager. */
|
||||||
|
member?: { id: string; name: string; manager: string; role: string; goal: string; lead: boolean }
|
||||||
|
/** Spec 3.2.0 (team.md §10): the team tools are offered to this session's agent (the lead):
|
||||||
|
* blocks/team_lead.md is in its prompt. */
|
||||||
|
teamLead?: boolean
|
||||||
/** The memory as it was when the prompt was made ("" when both files are empty); undefined
|
/** The memory as it was when the prompt was made ("" when both files are empty); undefined
|
||||||
* when memory is off. A later change by the person goes as a note. */
|
* when memory is off. A later change by the person goes as a note. */
|
||||||
memory?: string
|
memory?: string
|
||||||
@@ -264,6 +277,29 @@ export interface PromptContext {
|
|||||||
|
|
||||||
const MAX_INSTRUCTION_CHARS = 40_000
|
const MAX_INSTRUCTION_CHARS = 40_000
|
||||||
|
|
||||||
|
/** The environment block's lines about where the session works (loop.json `context.env`, spec
|
||||||
|
* 3.2.0): the project by its root's last path component, and — started below the root — that
|
||||||
|
* directory relative to it. No absolute path. */
|
||||||
|
export function envLines(root: string, started: string): string[] {
|
||||||
|
const r = resolve(root)
|
||||||
|
const name = basename(r) || r
|
||||||
|
const lines = [fillPrompt(LOOP.context.env.project, { name })]
|
||||||
|
const rel = relative(r, resolve(started))
|
||||||
|
if (rel && !rel.startsWith("..") && !isAbsolute(rel)) lines.push(fillPrompt(LOOP.context.env.started, { path: rel }))
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A path as a prompt names it (context.md §11): relative to the project root inside it, `~/…`
|
||||||
|
* under the person's home, absolute only outside both. */
|
||||||
|
export function promptPath(root: string, path: string): string {
|
||||||
|
const rel = relative(resolve(root), path)
|
||||||
|
if (rel === "") return "."
|
||||||
|
if (!rel.startsWith("..") && !isAbsolute(rel)) return rel
|
||||||
|
const home = homedir()
|
||||||
|
if (home && home !== "/" && (path === home || path.startsWith(home + sep))) return `~${path.slice(home.length)}`
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
/** The system prompt (harness spec; its stability is context.md §10, from 3.1.0). It is made once
|
/** The system prompt (harness spec; its stability is context.md §10, from 3.1.0). It is made once
|
||||||
* per session — at the start, after a compaction, on a model switch — and the engine resends
|
* per session — at the start, after a compaction, on a model switch — and the engine resends
|
||||||
* those bytes on every request, so nothing in it may be a clock or anything else that moves on
|
* those bytes on every request, so nothing in it may be a clock or anything else that moves on
|
||||||
@@ -301,8 +337,8 @@ export function assembleSystem(ctx: PromptContext): string {
|
|||||||
sections.push(
|
sections.push(
|
||||||
[
|
[
|
||||||
"<env>",
|
"<env>",
|
||||||
`Working directory: ${ctx.cwd}`,
|
// No absolute path (context.md §11): the project by its name, relative paths against its root.
|
||||||
`Project root: ${ctx.root}`,
|
...envLines(ctx.root, ctx.cwd),
|
||||||
`Git repository: ${ctx.isGit ? "yes" : "no"}`,
|
`Git repository: ${ctx.isGit ? "yes" : "no"}`,
|
||||||
`Platform: ${platform()} ${release()}`,
|
`Platform: ${platform()} ${release()}`,
|
||||||
// No date: it would change at midnight and the whole session be read again (§10 rule 2).
|
// No date: it would change at midnight and the whole session be read again (§10 rule 2).
|
||||||
@@ -311,17 +347,17 @@ export function assembleSystem(ctx: PromptContext): string {
|
|||||||
].join("\n"),
|
].join("\n"),
|
||||||
)
|
)
|
||||||
if (ctx.roster && !ctx.subagent)
|
if (ctx.roster && !ctx.subagent)
|
||||||
sections.push(`Other models configured here — the task tool can run a subagent on one (its model argument); the user can switch to one, and so can you through the settings tool when they ask:\n<models>\n${ctx.roster}\n</models>`)
|
sections.push(`Other models configured here — a subagent runs on one (task's or team_add's model argument) only when the user names it; the user can switch to one, and so can you through the settings tool when they ask:\n<models>\n${ctx.roster}\n</models>`)
|
||||||
if (ctx.git) sections.push(`The git repository as it was when this session started — a snapshot, not kept up to date; run git status for how it is now:\n<git>\n${ctx.git}\n</git>`)
|
if (ctx.git) sections.push(`The git repository as it was when this session started — a snapshot, not kept up to date; run git status for how it is now:\n<git>\n${ctx.git}\n</git>`)
|
||||||
if (ctx.tasks && !ctx.subagent) sections.push(`Open items on the project's task board (the tasks tool changes it):\n<project_tasks>\n${ctx.tasks}\n</project_tasks>`)
|
if (ctx.tasks && !ctx.subagent) sections.push(`Open items on the project's task board (the tasks tool changes it):\n<project_tasks>\n${ctx.tasks}\n</project_tasks>`)
|
||||||
|
|
||||||
const files = ctx.files ?? instructionFiles(ctx.cwd, ctx.root, ctx.instructions)
|
const files = ctx.files ?? instructionFiles(ctx.cwd, ctx.root, ctx.instructions)
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
let text = f.text.trim()
|
let text = f.text.trim()
|
||||||
|
// A file in the project by its path from the root, one under home as ~/… (context.md §11).
|
||||||
|
const named = promptPath(ctx.root, f.path)
|
||||||
if (text.length > MAX_INSTRUCTION_CHARS)
|
if (text.length > MAX_INSTRUCTION_CHARS)
|
||||||
text = text.slice(0, MAX_INSTRUCTION_CHARS) + `\n[…truncated: kept ${MAX_INSTRUCTION_CHARS} of ${f.text.length} characters; read ${f.path} for the rest]`
|
text = text.slice(0, MAX_INSTRUCTION_CHARS) + `\n[…truncated: kept ${MAX_INSTRUCTION_CHARS} of ${f.text.length} characters; read ${named} for the rest]`
|
||||||
// The chain's files by their path in the project (the spec's "relative to the boundary").
|
|
||||||
const named = f.chain ? relative(ctx.root, f.path) || f.path : f.path
|
|
||||||
sections.push(`Instructions from ${named}:\n<instructions>\n${text}\n</instructions>`)
|
sections.push(`Instructions from ${named}:\n<instructions>\n${text}\n</instructions>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +388,24 @@ export function assembleSystem(ctx: PromptContext): string {
|
|||||||
if (ctx.library && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/library.md"), { knowledge_scope: ctx.library.scope }))
|
if (ctx.library && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/library.md"), { knowledge_scope: ctx.library.scope }))
|
||||||
if (ctx.prototypes && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/prototypes.md"), { prototype_sources: ctx.prototypes.sources, prototype_surface: PROTOTYPE_SURFACE }))
|
if (ctx.prototypes && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/prototypes.md"), { prototype_sources: ctx.prototypes.sources, prototype_surface: PROTOTYPE_SURFACE }))
|
||||||
|
|
||||||
if (ctx.subagent)
|
if (ctx.teamLead && !ctx.subagent) sections.push(promptText("blocks/team_lead.md"))
|
||||||
|
if (ctx.subagent && ctx.member)
|
||||||
|
sections.push(
|
||||||
|
fillPrompt(promptText("blocks/team_member.md"), {
|
||||||
|
name: ctx.member.name,
|
||||||
|
id: ctx.member.id,
|
||||||
|
manager: ctx.member.manager,
|
||||||
|
role: ctx.member.role,
|
||||||
|
goal: ctx.member.goal,
|
||||||
|
instructions: ctx.subagent.instructions,
|
||||||
|
read_only: ctx.mode === "plan" ? SUBAGENT_READ_ONLY : "",
|
||||||
|
// Spec 3.4.0: a sub-manager directs; it no longer gets the whole lead block.
|
||||||
|
lead: ctx.member.lead ? promptText("blocks/team_submanager.md") : "",
|
||||||
|
})
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim(),
|
||||||
|
)
|
||||||
|
else if (ctx.subagent)
|
||||||
sections.push(
|
sections.push(
|
||||||
fillPrompt(promptText("blocks/subagent.md"), {
|
fillPrompt(promptText("blocks/subagent.md"), {
|
||||||
name: ctx.subagent.name,
|
name: ctx.subagent.name,
|
||||||
@@ -389,7 +442,7 @@ function agentsBlock(ctx: PromptContext, files: { path: string; text: string; ch
|
|||||||
// Files handed in without the mark (an older caller): anything but the global file, as before.
|
// Files handed in without the mark (an older caller): anything but the global file, as before.
|
||||||
const marked = files.some((f) => f.chain)
|
const marked = files.some((f) => f.chain)
|
||||||
const own = files.filter((f) => (marked ? f.chain : f.path !== join(paths.config, "AGENTS.md")))
|
const own = files.filter((f) => (marked ? f.chain : f.path !== join(paths.config, "AGENTS.md")))
|
||||||
const vars = { root: ctx.root, git_question: ctx.isGit ? GIT_QUESTION : GIT_QUESTION_NO_REPO }
|
const vars = { git_question: ctx.isGit ? GIT_QUESTION : GIT_QUESTION_NO_REPO }
|
||||||
if (!own.length) return fillPrompt(promptText("blocks/agents-missing.md"), vars)
|
if (!own.length) return fillPrompt(promptText("blocks/agents-missing.md"), vars)
|
||||||
const keep = promptText("blocks/agents-keep.md")
|
const keep = promptText("blocks/agents-keep.md")
|
||||||
return own.some((f) => /^#{1,3}\s*git\b/im.test(f.text)) ? keep : `${fillPrompt(promptText("blocks/agents-git.md"), vars)}\n\n${keep}`
|
return own.some((f) => /^#{1,3}\s*git\b/im.test(f.text)) ? keep : `${fillPrompt(promptText("blocks/agents-git.md"), vars)}\n\n${keep}`
|
||||||
|
|||||||
+17
-1
@@ -54,7 +54,8 @@ export async function* retrying(attempt: () => AsyncGenerator<StreamEvent>, hand
|
|||||||
// Twice: the first at once, the second after a pause (a model still loading, a busy server).
|
// Twice: the first at once, the second after a pause (a model still loading, a busy server).
|
||||||
if ((e.status ?? 0) >= 500 && retried5xx < 2) {
|
if ((e.status ?? 0) >= 500 && retried5xx < 2) {
|
||||||
retried5xx++
|
retried5xx++
|
||||||
yield { type: "notice", message: `server error, retrying (${retried5xx} of 2) — ${e.message.split("\n")[0]!.slice(0, 160)}` }
|
const reason = e.message.split("\n")[0]!.slice(0, 160)
|
||||||
|
yield { type: "notice", message: `server error, retrying (${retried5xx} of 2) — ${reason}`, retry: { reason, in: retried5xx === 2 ? 2 : 0, attempt: retried5xx } }
|
||||||
if (retried5xx === 2) await new Promise((r) => setTimeout(r, 2000))
|
if (retried5xx === 2) await new Promise((r) => setTimeout(r, 2000))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -168,3 +169,18 @@ export function historyArgs(args: string): string {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return "{}"
|
return "{}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Harness context.md §8 (spec 3.2.0): the history as a request sends it — every assistant message
|
||||||
|
* of the current turn (after the last user message that started a turn) keeps its reasoning,
|
||||||
|
* earlier ones carry none. What is stored is unchanged; only the request is. */
|
||||||
|
export function turnReasoning(messages: Message[]): Message[] {
|
||||||
|
let start = -1
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const m = messages[i]!
|
||||||
|
if (m.role === "user" && m.turn !== false) {
|
||||||
|
start = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages.map((m, i) => (m.role === "assistant" && i < start && m.parts.some((p) => p.type === "reasoning") ? { ...m, parts: m.parts.filter((p) => p.type !== "reasoning") } : m))
|
||||||
|
}
|
||||||
+79
-15
@@ -10,29 +10,83 @@ import { learned } from "./learned.ts"
|
|||||||
|
|
||||||
export const DEFAULT_EFFORTS: Effort[] = ["low", "medium", "high"]
|
export const DEFAULT_EFFORTS: Effort[] = ["low", "medium", "high"]
|
||||||
|
|
||||||
/** The efforts this model accepts, in offering order: learned > configured > the common three. */
|
/** The efforts this model accepts, in offering order: learned > the server's > the common three.
|
||||||
|
* `[]` from the server: it takes none (its one level is `default`). */
|
||||||
export function effortsFor(m: ResolvedModel): Effort[] {
|
export function effortsFor(m: ResolvedModel): Effort[] {
|
||||||
const list = learned().efforts[m.ref] ?? m.spec.efforts ?? DEFAULT_EFFORTS
|
const list = learned().efforts[m.ref] ?? m.spec.efforts ?? DEFAULT_EFFORTS
|
||||||
return EFFORTS.filter((e) => list.includes(e))
|
return EFFORTS.filter((e) => list.includes(e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The model's default effort, or null for off / not a reasoning model. */
|
/** An effort level (harness models/schema.json `levels`, spec 3.2.0): an effort; `off` — thinking
|
||||||
export function defaultEffort(m: ResolvedModel): Effort | null {
|
* switched off, only on a model with `thinking_switch`; `default` — nothing sent, the model's own,
|
||||||
const e = m.spec.effort
|
* only on a model that takes no efforts. */
|
||||||
if (!e || e === "off") return null
|
export type Level = Effort | "off" | "default"
|
||||||
return effortsFor(m).includes(e) ? e : null
|
export const LEVEL_WORDS: readonly Level[] = [...EFFORTS, "off", "default"]
|
||||||
|
export type Control = "none" | "switch" | "slider"
|
||||||
|
|
||||||
|
/** What the levels are read from: the model's metadata, efforts as given (never absent here: an
|
||||||
|
* absent list is each project's fallback, effortsFor's). */
|
||||||
|
export interface LevelMeta {
|
||||||
|
thinking_switch?: boolean
|
||||||
|
efforts: readonly Effort[]
|
||||||
|
effort?: string
|
||||||
|
effort_style?: "top" | "kwargs" | "both"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Put the effort into an openai-chat request body. An effort outside the vocabulary is dropped. */
|
export function isLevel(v: unknown): v is Level {
|
||||||
export function applyEffort(body: Record<string, unknown>, m: ResolvedModel, effort: Effort | null | undefined) {
|
return typeof v === "string" && (LEVEL_WORDS as readonly string[]).includes(v)
|
||||||
if (!effort || !effortsFor(m).includes(effort)) return
|
|
||||||
const style = m.spec.effort_style ?? "both"
|
|
||||||
if (style !== "kwargs") body.reasoning_effort = effort
|
|
||||||
if (style !== "top") {
|
|
||||||
const kwargs = { ...((body.chat_template_kwargs as Record<string, unknown>) ?? {}) }
|
|
||||||
kwargs.reasoning_effort = effort
|
|
||||||
body.chat_template_kwargs = kwargs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The levels a chat offers, how they are shown, and where a new conversation starts. */
|
||||||
|
export function levelsOf(meta: LevelMeta): { levels: Level[]; control: Control; start: Level } {
|
||||||
|
const levels: Level[] = [...(meta.thinking_switch === true ? (["off"] as Level[]) : []), ...(meta.efforts.length ? meta.efforts : (["default"] as Level[]))]
|
||||||
|
const control: Control = levels.length === 1 ? "none" : levels.length === 2 && levels.includes("off") ? "switch" : "slider"
|
||||||
|
const start: Level =
|
||||||
|
meta.effort && (levels as string[]).includes(meta.effort) ? (meta.effort as Level) : meta.efforts.includes("medium") ? "medium" : (meta.efforts[0] ?? "default")
|
||||||
|
return { levels, control, start }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A model's levels: its switch, its efforts (learned > the server's > the common three). */
|
||||||
|
export function levelsFor(m: ResolvedModel) {
|
||||||
|
return levelsOf({ thinking_switch: m.spec.thinking_switch, efforts: effortsFor(m), effort: m.spec.effort, effort_style: m.spec.effort_style })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The level a request uses for `asked`: itself when the model offers it, else the start level (a
|
||||||
|
* level the model does not offer — an `off` from an older client — is read as the start level). */
|
||||||
|
export function levelOf(meta: LevelMeta, asked: string | null | undefined): Level {
|
||||||
|
const { levels, start } = levelsOf(meta)
|
||||||
|
return asked && (levels as string[]).includes(asked) ? (asked as Level) : start
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A model's level for `asked` (levelOf), or its start level when nothing is asked. */
|
||||||
|
export function readLevel(m: ResolvedModel, asked?: string | null): Level {
|
||||||
|
return levelOf({ thinking_switch: m.spec.thinking_switch, efforts: effortsFor(m), effort: m.spec.effort }, asked)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The model's start level. */
|
||||||
|
export function defaultEffort(m: ResolvedModel): Level {
|
||||||
|
return levelsFor(m).start
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What an OpenAI chat request body carries for a level: `off` — enable_thinking false in
|
||||||
|
* chat_template_kwargs and no effort anywhere; `default` — nothing; an effort — where
|
||||||
|
* effort_style puts it, and no enable_thinking. A level the model does not offer is read as its
|
||||||
|
* start level. */
|
||||||
|
export function levelFields(meta: LevelMeta, asked: string | null | undefined): { reasoning_effort?: Effort; chat_template_kwargs?: Record<string, unknown> } {
|
||||||
|
const level = levelOf(meta, asked)
|
||||||
|
if (level === "default") return {}
|
||||||
|
if (level === "off") return { chat_template_kwargs: { enable_thinking: false } }
|
||||||
|
const style = meta.effort_style ?? "both"
|
||||||
|
return { ...(style !== "kwargs" ? { reasoning_effort: level } : {}), ...(style !== "top" ? { chat_template_kwargs: { reasoning_effort: level } } : {}) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put the level into an openai-chat request body. `null`/undefined: nothing sent (a small job
|
||||||
|
* of the harness's own — a title, a commit message — sends no level). */
|
||||||
|
export function applyEffort(body: Record<string, unknown>, m: ResolvedModel, level: Level | null | undefined) {
|
||||||
|
if (!level) return
|
||||||
|
const f = levelFields({ thinking_switch: m.spec.thinking_switch, efforts: effortsFor(m), effort: m.spec.effort, effort_style: m.spec.effort_style }, level)
|
||||||
|
if (f.reasoning_effort) body.reasoning_effort = f.reasoning_effort
|
||||||
|
if (f.chat_template_kwargs) body.chat_template_kwargs = { ...((body.chat_template_kwargs as Record<string, unknown>) ?? {}), ...f.chat_template_kwargs }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stripEffort(body: Record<string, unknown>): Record<string, unknown> {
|
export function stripEffort(body: Record<string, unknown>): Record<string, unknown> {
|
||||||
@@ -40,6 +94,7 @@ export function stripEffort(body: Record<string, unknown>): Record<string, unkno
|
|||||||
delete out.reasoning_effort
|
delete out.reasoning_effort
|
||||||
const kwargs = { ...((out.chat_template_kwargs as Record<string, unknown>) ?? {}) }
|
const kwargs = { ...((out.chat_template_kwargs as Record<string, unknown>) ?? {}) }
|
||||||
delete kwargs.reasoning_effort
|
delete kwargs.reasoning_effort
|
||||||
|
delete kwargs.enable_thinking
|
||||||
if (Object.keys(kwargs).length) out.chat_template_kwargs = kwargs
|
if (Object.keys(kwargs).length) out.chat_template_kwargs = kwargs
|
||||||
else delete out.chat_template_kwargs
|
else delete out.chat_template_kwargs
|
||||||
return out
|
return out
|
||||||
@@ -77,3 +132,12 @@ export function effortsFromTemplate(template: string): Effort[] {
|
|||||||
const out = EFFORTS.filter((e) => found.has(e))
|
const out = EFFORTS.filter((e) => found.has(e))
|
||||||
return out.length >= 2 ? out : []
|
return out.length >= 2 ? out : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The level as a status line says it, by how the model's levels are shown: nothing for a model
|
||||||
|
* with one level, "thinking" / "thinking off" for a Thinking switch, else the level's name. */
|
||||||
|
export function levelLabel(m: ResolvedModel, level: Level): string {
|
||||||
|
const { control } = levelsFor(m)
|
||||||
|
if (control === "none") return ""
|
||||||
|
if (control === "switch") return level === "off" ? "thinking off" : "thinking"
|
||||||
|
return level
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { resolveKey } from "../config/load.ts"
|
|||||||
import { advertisedEfforts, applyEffort, effortRefused, effortsFor, stripEffort } from "./effort.ts"
|
import { advertisedEfforts, applyEffort, effortRefused, effortsFor, stripEffort } from "./effort.ts"
|
||||||
import { authHeaders, request, joinUrl, tlsFor } from "./http.ts"
|
import { authHeaders, request, joinUrl, tlsFor } from "./http.ts"
|
||||||
import { learned, learnEfforts, learnNoProgress, learnNoStreamOptions } from "./learned.ts"
|
import { learned, learnEfforts, learnNoProgress, learnNoStreamOptions } from "./learned.ts"
|
||||||
import { historyArgs, Parts, record, recordFailure, retrying, statusOf, SwapBanner } from "./common.ts"
|
import { historyArgs, Parts, record, recordFailure, retrying, statusOf, SwapBanner, turnReasoning } from "./common.ts"
|
||||||
import { sseJson } from "./sse.ts"
|
import { sseJson } from "./sse.ts"
|
||||||
import { ThinkSplitter } from "./think.ts"
|
import { ThinkSplitter } from "./think.ts"
|
||||||
import { estimateTokens, estimateValue } from "./tokens.ts"
|
import { estimateTokens, estimateValue } from "./tokens.ts"
|
||||||
@@ -30,7 +30,7 @@ type Assistant = Extract<Message, { role: "assistant" }>
|
|||||||
export function toOpenAIMessages(system: string, messages: Message[], vision: boolean): unknown[] {
|
export function toOpenAIMessages(system: string, messages: Message[], vision: boolean): unknown[] {
|
||||||
const out: unknown[] = []
|
const out: unknown[] = []
|
||||||
if (system) out.push({ role: "system", content: system })
|
if (system) out.push({ role: "system", content: system })
|
||||||
for (const m of messages) {
|
for (const m of turnReasoning(messages)) {
|
||||||
if (m.role === "user") {
|
if (m.role === "user") {
|
||||||
const images = m.parts.filter((p) => p.type === "image")
|
const images = m.parts.filter((p) => p.type === "image")
|
||||||
if (images.length === 0 || !vision) {
|
if (images.length === 0 || !vision) {
|
||||||
@@ -50,6 +50,10 @@ export function toOpenAIMessages(system: string, messages: Message[], vision: bo
|
|||||||
const text = m.parts.filter((p): p is TextPart => p.type === "text").map((p) => p.text).join("")
|
const text = m.parts.filter((p): p is TextPart => p.type === "text").map((p) => p.text).join("")
|
||||||
const calls = m.parts.filter((p): p is ToolCallPart => p.type === "tool_call")
|
const calls = m.parts.filter((p): p is ToolCallPart => p.type === "tool_call")
|
||||||
const msg: Record<string, unknown> = { role: "assistant", content: text || null }
|
const msg: Record<string, unknown> = { role: "assistant", content: text || null }
|
||||||
|
// The current turn's reasoning goes back as the model gave it (context.md §8) — inline <think>
|
||||||
|
// tags were read as reasoning, and go back here, never as text. None: no field.
|
||||||
|
const reasoning = m.parts.filter((p): p is ReasoningPart => p.type === "reasoning").map((p) => p.text).join("")
|
||||||
|
if (reasoning) msg.reasoning_content = reasoning
|
||||||
if (calls.length)
|
if (calls.length)
|
||||||
msg.tool_calls = calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: historyArgs(c.args) } }))
|
msg.tool_calls = calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: historyArgs(c.args) } }))
|
||||||
out.push(msg)
|
out.push(msg)
|
||||||
|
|||||||
+15
-4
@@ -13,7 +13,17 @@ export type ToolCallPart = { type: "tool_call"; id: string; name: string; args:
|
|||||||
export type Message =
|
export type Message =
|
||||||
/** `turnId`: the turn a prompt started, kept with it in the store for
|
/** `turnId`: the turn a prompt started, kept with it in the store for
|
||||||
* `_lembas/session/history`; never sent to a model. */
|
* `_lembas/session/history`; never sent to a model. */
|
||||||
| { role: "user"; parts: (TextPart | ImagePart)[]; turnId?: string; /** The harness wrote it (a job's note), not the person: shown as such. */ machine?: true }
|
| {
|
||||||
|
role: "user"
|
||||||
|
parts: (TextPart | ImagePart)[]
|
||||||
|
turnId?: string
|
||||||
|
/** The harness wrote it (a job's note), not the person: shown as such. */
|
||||||
|
machine?: true
|
||||||
|
/** false: it does not start a turn — delivered at a step boundary while a turn runs (a job's
|
||||||
|
* note, a team message, words the person added), or the harness's own nudge within the turn.
|
||||||
|
* Absent: it does (harness context.md §8). */
|
||||||
|
turn?: false
|
||||||
|
}
|
||||||
| { role: "assistant"; parts: (TextPart | ReasoningPart | ToolCallPart)[] }
|
| { role: "assistant"; parts: (TextPart | ReasoningPart | ToolCallPart)[] }
|
||||||
| { role: "tool"; callId: string; name: string; content: string; isError?: boolean }
|
| { role: "tool"; callId: string; name: string; content: string; isError?: boolean }
|
||||||
|
|
||||||
@@ -44,14 +54,15 @@ export type StreamEvent =
|
|||||||
| { type: "progress"; total: number; cache: number; processed: number; ms: number }
|
| { type: "progress"; total: number; cache: number; processed: number; ms: number }
|
||||||
| { type: "finish"; reason: string; message: Extract<Message, { role: "assistant" }> }
|
| { type: "finish"; reason: string; message: Extract<Message, { role: "assistant" }> }
|
||||||
/** Something worth telling the user that is not an error, e.g. a retry. */
|
/** Something worth telling the user that is not an error, e.g. a retry. */
|
||||||
| { type: "notice"; message: string }
|
| { type: "notice"; message: string; retry?: { reason: string; in: number; attempt?: number } }
|
||||||
|
|
||||||
export interface ChatRequest {
|
export interface ChatRequest {
|
||||||
system: string
|
system: string
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
tools: ToolSpec[]
|
tools: ToolSpec[]
|
||||||
/** null = off */
|
/** The effort level (provider/effort.ts); null or absent: nothing sent (a small job of the
|
||||||
effort?: Effort | null
|
* harness's own). */
|
||||||
|
effort?: Effort | "off" | "default" | null
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -7,10 +7,11 @@
|
|||||||
// lembas remote enable | disable [--json] remote.enabled
|
// lembas remote enable | disable [--json] remote.enabled
|
||||||
// lembas remote mode <manual|edit|auto|plan> remote.max_mode
|
// lembas remote mode <manual|edit|auto|plan> remote.max_mode
|
||||||
// lembas remote terminal <on|off> remote.terminal
|
// lembas remote terminal <on|off> remote.terminal
|
||||||
|
// lembas remote scheduled <on|off> remote.scheduled: Accept scheduled work (spec 3.5.0)
|
||||||
//
|
//
|
||||||
// Every change goes through the global config writer, which keeps the file's comments. A running
|
// Every change goes through the global config writer, which keeps the file's comments. A running
|
||||||
// `lembas service` reads the block again whenever it uses it, so a change applies to it within a few
|
// `lembas service` reads the block again whenever it uses it, so a change applies to it within a few
|
||||||
// seconds, no restart (acp/link.ts). --json prints one object: {enabled, roots, max_mode, terminal}.
|
// seconds, no restart (acp/link.ts). --json prints one object: {enabled, roots, max_mode, terminal, scheduled}.
|
||||||
import { resolve } from "node:path"
|
import { resolve } from "node:path"
|
||||||
import { ConfigError } from "./config/load.ts"
|
import { ConfigError } from "./config/load.ts"
|
||||||
import { expandHome } from "./config/paths.ts"
|
import { expandHome } from "./config/paths.ts"
|
||||||
@@ -18,7 +19,7 @@ import { coveredBy, realOrResolved, remoteBlock, remoteState, rootFor, type Remo
|
|||||||
import { MODES, type Mode } from "./config/schema.ts"
|
import { MODES, type Mode } from "./config/schema.ts"
|
||||||
import { updateGlobalConfig } from "./config/write.ts"
|
import { updateGlobalConfig } from "./config/write.ts"
|
||||||
|
|
||||||
export const REMOTE_USAGE = `usage: lembas remote list | add <dir>… | remove <dir>… | enable | disable | mode <${MODES.join("|")}> | terminal <on|off> [--json]`
|
export const REMOTE_USAGE = `usage: lembas remote list | add <dir>… | remove <dir>… | enable | disable | mode <${MODES.join("|")}> | terminal <on|off> | scheduled <on|off> [--json]`
|
||||||
|
|
||||||
export interface Io {
|
export interface Io {
|
||||||
out: (line: string) => void
|
out: (line: string) => void
|
||||||
@@ -34,6 +35,7 @@ function describe(s: RemoteState): string[] {
|
|||||||
s.roots.length ? `roots:\n${s.roots.map((r) => ` ${r}`).join("\n")}` : "roots: (none: no directory accepts remote work)",
|
s.roots.length ? `roots:\n${s.roots.map((r) => ` ${r}`).join("\n")}` : "roots: (none: no directory accepts remote work)",
|
||||||
`max mode: ${s.max_mode}`,
|
`max mode: ${s.max_mode}`,
|
||||||
`terminal: ${s.terminal ? "on" : "off"}`,
|
`terminal: ${s.terminal ? "on" : "off"}`,
|
||||||
|
`accept scheduled work: ${s.scheduled ? "on" : "off"}`,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +148,14 @@ export function remoteCommand(argv: string[], io: Io = stdio): number {
|
|||||||
return show()
|
return show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sub === "scheduled") {
|
||||||
|
const v = rest[0]
|
||||||
|
if (rest.length !== 1 || (v !== "on" && v !== "off")) return io.err("usage: lembas remote scheduled <on|off>"), 2
|
||||||
|
write([[["scheduled"], v === "on"]])
|
||||||
|
if (v === "on" && block.enabled !== true) said.push("remote work is off: scheduled work needs it too — lembas remote add <dir> or enable")
|
||||||
|
return show()
|
||||||
|
}
|
||||||
|
|
||||||
io.err(REMOTE_USAGE)
|
io.err(REMOTE_USAGE)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
+28
-12
@@ -1,7 +1,8 @@
|
|||||||
// Whether a subagent may run on a model beside the session's own, judged by what its server can do
|
// Whether a subagent may run on a model beside the session's own, judged by what its server can do
|
||||||
// at once — the harness spec's capacity rule, after LLeMbas services/helpers.py (© Jaroslav Beneš,
|
// at once — the harness spec's capacity rule, after LLeMbas services/helpers.py (© Jaroslav Beneš,
|
||||||
// MIT). Both flags name the restrictive state and default off, so a configuration that
|
// MIT). Both flags name the restrictive state and default off, so a configuration that
|
||||||
// never sets them works exactly as before.
|
// never sets them works exactly as before. From spec 3.2.0 a model says how many requests it serves
|
||||||
|
// at once (`parallel`; single_session is 1).
|
||||||
//
|
//
|
||||||
// - A connection that holds one model at a time (llama-swap in front of one GPU): a subagent on
|
// - A connection that holds one model at a time (llama-swap in front of one GPU): a subagent on
|
||||||
// another of its models unloads the session's model, and the session's cached prompt with it,
|
// another of its models unloads the session's model, and the session's cached prompt with it,
|
||||||
@@ -11,16 +12,29 @@
|
|||||||
// minutes, on a long session with a small GPU.
|
// minutes, on a long session with a small GPU.
|
||||||
import type { ResolvedModel } from "../provider/types.ts"
|
import type { ResolvedModel } from "../provider/types.ts"
|
||||||
|
|
||||||
export function capacityRefusal(main: ResolvedModel, helper: ResolvedModel): string {
|
/** How many requests a model's server serves at once (models/schema.json capacity, spec 3.2.0):
|
||||||
if (helper.ref === main.ref) {
|
* `parallel` when given, else 1 for single_session, else no limit (a hosted API). */
|
||||||
if (helper.spec.single_session)
|
export function parallelOf(m: Pick<ResolvedModel, "spec">): number {
|
||||||
return `${helper.ref} serves one request at a time (single_session), 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.`
|
const p = m.spec.parallel
|
||||||
|
if (Number.isInteger(p) && p! >= 1) return p!
|
||||||
|
return m.spec.single_session ? 1 : Infinity
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `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, sub: ResolvedModel, busy = 0): string {
|
||||||
|
if (sub.ref === main.ref) {
|
||||||
|
const n = parallelOf(sub)
|
||||||
|
if (n === 1)
|
||||||
|
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 `${sub.ref} serves ${n} requests at once, and this session already has ${n} on it; another subagent would wait, or push this session's cached prompt out.`
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
const shared = groupOf(main)
|
const shared = groupOf(main)
|
||||||
if (shared !== undefined && shared === groupOf(helper)) {
|
if (shared !== undefined && shared === groupOf(sub)) {
|
||||||
const where = `${main.ref}'s server (group ${main.spec.group})`
|
const where = `${main.ref}'s server (group ${main.spec.group})`
|
||||||
return `${where} holds one model at a time, so a subagent on ${helper.ref} would unload ${main.ref}, and this session's cached prompt with it, while its turn waits.`
|
return `${where} holds one model at a time, so a subagent on ${sub.ref} would unload ${main.ref}, and this session's cached prompt with it, while its turn waits.`
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -28,15 +42,17 @@ export function capacityRefusal(main: ResolvedModel, helper: ResolvedModel): str
|
|||||||
/** The same rule between two subagents: a new one is not started where its model would unload, or
|
/** The same rule between two subagents: a new one is not started where its model would unload, or
|
||||||
* take the only slot of, a subagent already running (the spec's "beside the model of a subagent
|
* take the only slot of, a subagent already running (the spec's "beside the model of a subagent
|
||||||
* already running"). */
|
* already running"). */
|
||||||
export function capacityBeside(running: ResolvedModel, helper: ResolvedModel): string {
|
export function capacityBeside(running: ResolvedModel, sub: ResolvedModel, busy = 1): string {
|
||||||
if (helper.ref === running.ref) {
|
if (sub.ref === running.ref) {
|
||||||
if (helper.spec.single_session) return `${helper.ref} serves one request at a time (single_session), and a subagent is already running on it; a second would wait for the first, or push it out.`
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
const shared = groupOf(running)
|
const shared = groupOf(running)
|
||||||
if (shared !== undefined && shared === groupOf(helper)) {
|
if (shared !== undefined && shared === groupOf(sub)) {
|
||||||
const where = `${running.ref}'s server (group ${running.spec.group})`
|
const where = `${running.ref}'s server (group ${running.spec.group})`
|
||||||
return `${where} holds one model at a time, and a subagent is already running on ${running.ref} there; one on ${helper.ref} would unload it.`
|
return `${where} holds one model at a time, and a subagent is already running on ${running.ref} there; one on ${sub.ref} would unload it.`
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-3
@@ -16,6 +16,10 @@ import { customCommands, expandCommand } from "../project/commands.ts"
|
|||||||
import { fill, promptText } from "../prompt/assemble.ts"
|
import { fill, promptText } from "../prompt/assemble.ts"
|
||||||
import { skillMessage, slug, type Skill } from "../skill/index.ts"
|
import { skillMessage, slug, type Skill } from "../skill/index.ts"
|
||||||
import { CONTINUE_PROMPT } from "./engine.ts"
|
import { CONTINUE_PROMPT } from "./engine.ts"
|
||||||
|
import { TEAM, Team } from "../team/team.ts"
|
||||||
|
import { SUBAGENTS } from "../team/subagents.ts"
|
||||||
|
import { progressLine } from "./progress.ts"
|
||||||
|
import { fillPrompt } from "../prompt/fill.ts"
|
||||||
|
|
||||||
export type CommandKind = "custom" | "skill" | "builtin"
|
export type CommandKind = "custom" | "skill" | "builtin"
|
||||||
|
|
||||||
@@ -34,7 +38,7 @@ export const TUI_COMMAND_NAMES = [
|
|||||||
"agents", "continue", "help", "model", "effort", "mode", "plan", "plans", "prototype", "review", "changelog", "init", "release", "tasks", "decisions", "decide",
|
"agents", "continue", "help", "model", "effort", "mode", "plan", "plans", "prototype", "review", "changelog", "init", "release", "tasks", "decisions", "decide",
|
||||||
"jobs", "sessions", "resume", "remote", "delete", "new", "clear", "copy", "undo", "redo", "diff", "checkpoint", "checkpoints", "branch", "commit", "context",
|
"jobs", "sessions", "resume", "remote", "delete", "new", "clear", "copy", "undo", "redo", "diff", "checkpoint", "checkpoints", "branch", "commit", "context",
|
||||||
"compact", "memory", "skills", "skill", "personality", "voice", "speak", "mcp", "login", "logout", "settings", "usage", "kb", "theme", "skin", "terminal",
|
"compact", "memory", "skills", "skill", "personality", "voice", "speak", "mcp", "login", "logout", "settings", "usage", "kb", "theme", "skin", "terminal",
|
||||||
"icons", "trust", "reload", "restart", "quit", "exit", "q",
|
"icons", "trust", "reload", "restart", "quit", "exit", "q", "team", "rename", "schedule", "schedules", "search",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** The built-ins that work without a screen of their own. Descriptions as the TUI's (a test). */
|
/** The built-ins that work without a screen of their own. Descriptions as the TUI's (a test). */
|
||||||
@@ -47,8 +51,91 @@ export const SHARED_BUILTINS: { name: string; description: string; hint?: string
|
|||||||
{ name: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" },
|
{ name: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" },
|
||||||
{ name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" },
|
{ name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" },
|
||||||
{ name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" },
|
{ name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" },
|
||||||
|
{ name: "team", description: "subagents: the tree and the room; how many; stop one; their findings", hint: "size <min> <max> | stop <name> | findings <name>" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** The status bar's word for a team with members: its size of the most it may have, and how many
|
||||||
|
* work now; "" without members. */
|
||||||
|
export function teamLabel(team: Team): string {
|
||||||
|
if (!team.hasMembers()) return ""
|
||||||
|
const n = team.active()
|
||||||
|
return `agents ${team.size()}/${team.max}${n ? ` · ${n} subagent${n === 1 ? "" : "s"} working` : ""}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spec 3.4.0 (subagents.md §12): the tree as the person sees it — every agent with its position,
|
||||||
|
* state, model, mode, context use, progress line and messages; open slots; the ended ones. */
|
||||||
|
export function treeText(team: Team, now = Date.now()): string {
|
||||||
|
const info = team.info()
|
||||||
|
const R = TEAM.read
|
||||||
|
const depth = (a: { parent: string | null }): number => (a.parent ? 1 + depth(info.agents.find((x) => x.name === a.parent) ?? { parent: null }) : 0)
|
||||||
|
const lines = [`${SUBAGENTS.setting.label} (${info.size} of ${info.max} agents; ${SUBAGENTS.setting.min} ${info.min}, ${SUBAGENTS.setting.max} ${info.max}):`]
|
||||||
|
for (const a of info.agents) {
|
||||||
|
const bits = [
|
||||||
|
`${" ".repeat(depth(a))}- ${a.name}${a.id === a.name ? "" : ` (${a.id})`} — ${a.position}`,
|
||||||
|
R.states[a.state],
|
||||||
|
a.model,
|
||||||
|
a.request.length ? `${a.mode} + ${a.request.join(", ")}` : a.mode,
|
||||||
|
a.context && a.context.window ? `${Math.floor((a.context.used * 100) / a.context.window)}% context` : "",
|
||||||
|
a.progress ? progressLine(a.progress, now) : "",
|
||||||
|
a.messages ? `${a.messages} message${a.messages === 1 ? "" : "s"}` : "",
|
||||||
|
a.findings ? `${a.findings} finding${a.findings === 1 ? "" : "s"}` : "",
|
||||||
|
].filter(Boolean)
|
||||||
|
lines.push(bits.join(" · "))
|
||||||
|
}
|
||||||
|
if (info.open_slots) lines.push(fillPrompt(R.slots, { n: String(info.open_slots) }))
|
||||||
|
if (info.ended.length) {
|
||||||
|
lines.push(R.ended)
|
||||||
|
for (const a of info.ended) lines.push(`- ${a.name} (${a.id}) — ${a.position} · ${R.states[a.state]}${a.findings ? ` · ${a.findings} finding${a.findings === 1 ? "" : "s"}` : ""}`)
|
||||||
|
}
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/team [size <min> <max> | stop <name>]`: the team's tree and room, as the lead sees them (the
|
||||||
|
* person sees everything, team.md §5); its size for this session; a member stopped by the person,
|
||||||
|
* which the lead is told in a note. */
|
||||||
|
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 ${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
|
||||||
|
try {
|
||||||
|
app.settings.set("agents.min", Math.min(min, t.max))
|
||||||
|
app.settings.set("agents.max", max)
|
||||||
|
app.settings.set("agents.min", min)
|
||||||
|
} catch (e) {
|
||||||
|
return (e as Error).message
|
||||||
|
}
|
||||||
|
return `${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} — ${S.short}. Set for this session; the agent is told with your next message.`
|
||||||
|
}
|
||||||
|
if (verb === "findings") {
|
||||||
|
const name = rest[0]
|
||||||
|
if (!name) return "/team findings <name or id>"
|
||||||
|
return t.readFindings("lead", name)
|
||||||
|
}
|
||||||
|
if (verb === "stop") {
|
||||||
|
const name = rest[0]
|
||||||
|
if (!name) return "/team stop <name> — a member and everything under it"
|
||||||
|
const gone = rt.personStop(name)
|
||||||
|
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 subagents in this session: ${app.engine.model.ref} serves one request at a time, and every other model is on its one-model server.`
|
||||||
|
: "No subagents in this session: they are switched off (subagents: false)."
|
||||||
|
const since = Math.max(0, t.room.length - TEAM.read_max)
|
||||||
|
const own = t.couldServe().own ? "" : `\n${S.own}`
|
||||||
|
// The tree as the person sees it (spec 3.4.0 §12), then the room as team_read shows it.
|
||||||
|
const read = t.read("lead", since)
|
||||||
|
const room = read.slice(read.indexOf("\n\n") + 2)
|
||||||
|
return `${treeText(t)}\n\n${room}\n\n${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} — ${S.short} (/team size <min> <max>); /team stop <name> stops a subagent; /team open <name> shows its session, read-only; /team findings <name>; /context <name>.${own}`
|
||||||
|
}
|
||||||
|
|
||||||
/** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and
|
/** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and
|
||||||
* a client over ACP show. Making one (`new`) and handing one a task need a screen: the TUI's. */
|
* a client over ACP show. Making one (`new`) and handing one a task need a screen: the TUI's. */
|
||||||
export function agentsText(app: App, arg: string): string {
|
export function agentsText(app: App, arg: string): string {
|
||||||
@@ -56,7 +143,7 @@ export function agentsText(app: App, arg: string): string {
|
|||||||
const describe = (a: (typeof list)[number]) =>
|
const describe = (a: (typeof list)[number]) =>
|
||||||
[
|
[
|
||||||
`${a.name} (${a.source}) — ${a.description}`,
|
`${a.name} (${a.source}) — ${a.description}`,
|
||||||
` model ${a.model ?? "the session's"} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`,
|
` model the session's, or one you name${a.model ? ` (its own model: ${a.model} is not used)` : ""} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`,
|
||||||
].join("\n")
|
].join("\n")
|
||||||
const name = arg.trim().split(/\s+/)[0]
|
const name = arg.trim().split(/\s+/)[0]
|
||||||
if (name) {
|
if (name) {
|
||||||
@@ -151,7 +238,6 @@ export function changelogPrompt(app: App, arg: string): { prompt: string } | { m
|
|||||||
/** `/init [focus]`: make or update AGENTS.md. */
|
/** `/init [focus]`: make or update AGENTS.md. */
|
||||||
export function initPrompt(app: App, arg: string): string {
|
export function initPrompt(app: App, arg: string): string {
|
||||||
return fill(promptText("tasks/init.md"), {
|
return fill(promptText("tasks/init.md"), {
|
||||||
root: app.project.root,
|
|
||||||
focus: arg.trim() ? `What the user wants it to focus on: ${arg.trim()}` : "",
|
focus: arg.trim() ? `What the user wants it to focus on: ${arg.trim()}` : "",
|
||||||
}).replace(/\n{3,}/g, "\n\n")
|
}).replace(/\n{3,}/g, "\n\n")
|
||||||
}
|
}
|
||||||
@@ -176,6 +262,8 @@ export function expandSlash(app: App, text: string): Expanded | undefined {
|
|||||||
// List and describe anywhere; `new` and `<name> <task>` are the TUI's own.
|
// List and describe anywhere; `new` and `<name> <task>` are the TUI's own.
|
||||||
if (/^(new)\b/.test(arg) || arg.trim().split(/\s+/).length > 1) return { kind: "message", name, text: "/agents here lists and describes the subagents; making one or handing one a task is done at the terminal." }
|
if (/^(new)\b/.test(arg) || arg.trim().split(/\s+/).length > 1) return { kind: "message", name, text: "/agents here lists and describes the subagents; making one or handing one a task is done at the terminal." }
|
||||||
return { kind: "message", name, text: agentsText(app, arg) }
|
return { kind: "message", name, text: agentsText(app, arg) }
|
||||||
|
case "team":
|
||||||
|
return { kind: "message", name, text: teamText(app, arg) }
|
||||||
case "init":
|
case "init":
|
||||||
// The prompt is ours; only what was typed after the name may attach files.
|
// The prompt is ours; only what was typed after the name may attach files.
|
||||||
return { kind: "prompt", name, prompt: initPrompt(app, arg), shown: t, attachFrom: arg }
|
return { kind: "prompt", name, prompt: initPrompt(app, arg), shown: t, attachFrom: arg }
|
||||||
|
|||||||
+39
-13
@@ -41,23 +41,49 @@ export function contextBreakdown(engine: Engine, system: string, instructions: {
|
|||||||
if (imageCount) rows.push({ label: `images (${imageCount}, rough)`, tokens: by.images })
|
if (imageCount) rows.push({ label: `images (${imageCount}, rough)`, tokens: by.images })
|
||||||
const estimated = rows.filter((r) => !r.indent).reduce((n, r) => n + r.tokens, 0)
|
const estimated = rows.filter((r) => !r.indent).reduce((n, r) => n + r.tokens, 0)
|
||||||
const reported = engine.usage ? engine.contextUsed() : undefined
|
const reported = engine.usage ? engine.contextUsed() : undefined
|
||||||
return { rows, estimated, reported, window: contextWindow(engine.model), model: engine.model.ref, last: engine.lastRequest }
|
// Where automatic compaction starts (EngineOptions compaction.autoAt): the reference's "N until
|
||||||
|
// auto-compact". None without a window, or when it is switched off (1 or more).
|
||||||
|
const autoAt = engine.o.compaction?.autoAt ?? 0.85
|
||||||
|
return { rows, estimated, reported, window: contextWindow(engine.model), autoAt: autoAt < 1 ? autoAt : undefined, model: engine.model.ref, last: engine.lastRequest }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Plain text, for the transcript. */
|
/** One glyph per category in the segmented bar, in the order the categories are listed. */
|
||||||
|
const SEGMENTS = ["█", "▓", "▒", "░", "▞", "▚", "▙", "▟"]
|
||||||
|
|
||||||
|
/** Plain text, for the transcript: what is used of the window and how much, one segmented bar of
|
||||||
|
* every category, each category with its tokens and its share of the window, and how far it is to
|
||||||
|
* automatic compaction (/compact compacts now). */
|
||||||
export function renderBreakdown(b: ReturnType<typeof contextBreakdown>): string {
|
export function renderBreakdown(b: ReturnType<typeof contextBreakdown>): string {
|
||||||
const width = 24
|
const width = 40
|
||||||
const total = b.window ?? Math.max(b.estimated, b.reported ?? 0)
|
|
||||||
const bar = (n: number) => {
|
|
||||||
const cells = total ? Math.max(n > 0 ? 1 : 0, Math.round((n / total) * width)) : 0
|
|
||||||
return "█".repeat(Math.min(width, cells)).padEnd(width, "·")
|
|
||||||
}
|
|
||||||
const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
|
|
||||||
const lines = [`context — ${b.model}${b.window ? `, window ${fmt(b.window)}` : ", window unknown"}`]
|
|
||||||
for (const r of b.rows) lines.push(`${r.indent ? " ↳ " : " "}${r.label.padEnd(r.indent ? 30 : 33)} ${fmt(r.tokens).padStart(7)} ${r.indent ? "" : bar(r.tokens)}`)
|
|
||||||
const used = b.reported ?? b.estimated
|
const used = b.reported ?? b.estimated
|
||||||
lines.push(` ${"in use".padEnd(33)} ${fmt(used).padStart(7)} ${bar(used)}${b.window ? ` ${Math.round((used / b.window) * 100)}%` : ""}`)
|
const total = b.window ?? Math.max(b.estimated, used)
|
||||||
lines.push(b.reported !== undefined ? " (the server's own count from the last reply, plus an estimate since)" : " (estimated: ≈4 characters a token — no reply yet to count from)")
|
const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
|
||||||
|
const pct = (n: number) => (b.window ? `${Math.round((n / b.window) * 100)}%` : "")
|
||||||
|
const cats = b.rows.filter((r) => !r.indent)
|
||||||
|
// The categories' shares of the bar, scaled to what is in use (the server's count when it gave one).
|
||||||
|
const scale = b.estimated ? used / b.estimated : 1
|
||||||
|
let bar = ""
|
||||||
|
cats.forEach((r, i) => {
|
||||||
|
const cells = total && r.tokens > 0 ? Math.max(1, Math.round(((r.tokens * scale) / total) * width)) : 0
|
||||||
|
bar += SEGMENTS[i % SEGMENTS.length]!.repeat(cells)
|
||||||
|
})
|
||||||
|
bar = bar.slice(0, width).padEnd(width, "·")
|
||||||
|
const lines = [
|
||||||
|
`context — ${b.model}: ${fmt(used)}${b.window ? ` / ${fmt(b.window)} tokens · ${pct(used)} used` : " tokens, window unknown"}`,
|
||||||
|
` ${bar}`,
|
||||||
|
]
|
||||||
|
let i = 0
|
||||||
|
for (const r of b.rows) {
|
||||||
|
const mark = r.indent ? " ↳" : ` ${SEGMENTS[i++ % SEGMENTS.length]} `
|
||||||
|
lines.push(`${mark} ${r.label.padEnd(r.indent ? 30 : 31)} ${fmt(r.tokens).padStart(7)} ${r.indent ? "" : pct(r.tokens * scale).padStart(4)}`.trimEnd())
|
||||||
|
}
|
||||||
|
if (b.window) lines.push(` · ${"free".padEnd(31)} ${fmt(Math.max(0, b.window - used)).padStart(7)} ${pct(Math.max(0, b.window - used)).padStart(4)}`)
|
||||||
|
lines.push(` ${"in use".padEnd(33)} ${fmt(used).padStart(7)} ${pct(used).padStart(4)}`.trimEnd())
|
||||||
|
if (b.window && b.autoAt !== undefined) {
|
||||||
|
const until = Math.round(b.window * b.autoAt) - used
|
||||||
|
lines.push(until > 0 ? ` ${fmt(until)} until auto-compact (at ${Math.round(b.autoAt * 100)}%) — /compact to compact now` : ` past auto-compact (${Math.round(b.autoAt * 100)}%): the next step compacts — /compact to compact now`)
|
||||||
|
} else lines.push(b.window ? " automatic compaction is off — /compact to compact now" : " no automatic compaction without a window — /compact to compact now")
|
||||||
|
lines.push(b.reported !== undefined ? " (the server's own count from the last reply, plus an estimate since; the categories are estimates scaled to it)" : " (estimated: ≈4 characters a token — no reply yet to count from)")
|
||||||
// What the server reused of the last request (harness context.md §10 rule 6): a slow reply with
|
// What the server reused of the last request (harness context.md §10 rule 6): a slow reply with
|
||||||
// little of it cached was a re-read.
|
// little of it cached was a re-read.
|
||||||
const last = b.last?.usage
|
const last = b.last?.usage
|
||||||
|
|||||||
+138
-30
@@ -6,8 +6,9 @@
|
|||||||
import continueText from "../../harness/prompts/tasks/continue.md" with { type: "text" }
|
import continueText from "../../harness/prompts/tasks/continue.md" with { type: "text" }
|
||||||
import { isAbsolute } from "node:path"
|
import { isAbsolute } from "node:path"
|
||||||
import type { Asker, AttachmentInfo, Bus } from "../bus/index.ts"
|
import type { Asker, AttachmentInfo, Bus } from "../bus/index.ts"
|
||||||
import type { Effort, Mode } from "../config/schema.ts"
|
import type { Mode } from "../config/schema.ts"
|
||||||
import { evaluate, shadowsDeny, type Context as PermContext, type Rule } from "../permission/evaluate.ts"
|
import type { Level } from "../provider/effort.ts"
|
||||||
|
import { evaluate, shadowsDeny, type Context as PermContext, type PermissionRequest, type Rule } from "../permission/evaluate.ts"
|
||||||
import { ProviderError, type Client, type ImagePart, type Message, type ResolvedModel, type ToolCallPart, type Usage } from "../provider/types.ts"
|
import { ProviderError, type Client, type ImagePart, type Message, type ResolvedModel, type ToolCallPart, type Usage } from "../provider/types.ts"
|
||||||
import type { Plan } from "../plan/plan.ts"
|
import type { Plan } from "../plan/plan.ts"
|
||||||
import { LOOP } from "../harness.ts"
|
import { LOOP } from "../harness.ts"
|
||||||
@@ -21,6 +22,9 @@ import { estimateTokens, estimateValue } from "../provider/tokens.ts"
|
|||||||
import { duration } from "../duration.ts"
|
import { duration } from "../duration.ts"
|
||||||
import { memoryResults, presentIds, ResultContext } from "./results.ts"
|
import { memoryResults, presentIds, ResultContext } from "./results.ts"
|
||||||
import { render } from "../plan/plan.ts"
|
import { render } from "../plan/plan.ts"
|
||||||
|
import { personTexts } from "../team/subagents.ts"
|
||||||
|
import { ProgressTracker } from "./progress.ts"
|
||||||
|
import type { Verdict } from "../team/delegation.ts"
|
||||||
import { compactAsk, isNote, NO_CHANGING, noteFor, offeredFor, PREFIX_META, summaryOf, today, toldAt, unavailable, type Changing, type Kept, type Now, type Requirement, type Told } from "./prefix.ts"
|
import { compactAsk, isNote, NO_CHANGING, noteFor, offeredFor, PREFIX_META, summaryOf, today, toldAt, unavailable, type Changing, type Kept, type Now, type Requirement, type Told } from "./prefix.ts"
|
||||||
|
|
||||||
export interface EngineOptions {
|
export interface EngineOptions {
|
||||||
@@ -46,7 +50,8 @@ export interface EngineOptions {
|
|||||||
/** Today's date, as the note says it (tests set it). */
|
/** Today's date, as the note says it (tests set it). */
|
||||||
today?: () => string
|
today?: () => string
|
||||||
mode: Mode
|
mode: Mode
|
||||||
effort: Effort | null
|
/** The effort level (provider/effort.ts): an effort, `off` or `default`. */
|
||||||
|
effort: Level
|
||||||
perm: Omit<PermContext, "mode">
|
perm: Omit<PermContext, "mode">
|
||||||
toolCtx: Omit<ToolContext, "signal">
|
toolCtx: Omit<ToolContext, "signal">
|
||||||
maxSteps?: number
|
maxSteps?: number
|
||||||
@@ -61,6 +66,30 @@ export interface EngineOptions {
|
|||||||
/** What a message sent mid-turn starts with: the user's words for a session, the main agent's for
|
/** What a message sent mid-turn starts with: the user's words for a session, the main agent's for
|
||||||
* a subagent (loop.json jobs.marks to_subagent). */
|
* a subagent (loop.json jobs.marks to_subagent). */
|
||||||
steerMark?: string
|
steerMark?: string
|
||||||
|
/** The team this engine's agent is on (spec 3.2.0, team.md): whether it is offered (decided as
|
||||||
|
* the prompt is made, for `model`), what a call answers before its permission check (the team
|
||||||
|
* tools' requirement, task's size, a manager's work), the lead's note lines, and — the lead —
|
||||||
|
* a slot of the model before a turn runs. */
|
||||||
|
team?: {
|
||||||
|
/** Decide whether the team is offered for a new prompt on `model` — or, for a prompt the
|
||||||
|
* session kept (a resume), take what it decided then (`offered`). */
|
||||||
|
fix(model: string, offered?: boolean): boolean
|
||||||
|
offered(): boolean
|
||||||
|
refuse(tool: Tool, args: unknown): string | undefined
|
||||||
|
note?(): string[]
|
||||||
|
/** What the lead was told of the team is to be said again: a new prompt, or a rewind. */
|
||||||
|
forget?(): void
|
||||||
|
beforeTurn?(signal: AbortSignal): Promise<boolean>
|
||||||
|
/** Spec 3.4.0 (subagents.md §7), a subagent: who decides its call — itself, the main agent
|
||||||
|
* (through a request), or the person. Replaces the plain evaluation. */
|
||||||
|
permit?(request: PermissionRequest, ctx: Omit<PermContext, "mode">): Verdict
|
||||||
|
/** Spec 3.4.0 §9, a subagent: its context reached `pause_at` of its window before a step.
|
||||||
|
* "stop": its turn ends now (it is paused; its final turn follows); "go": carry on without
|
||||||
|
* compacting (it is in its final turn). Undefined: not a subagent of the tree. */
|
||||||
|
contextFull?(used: number, window: number): "stop" | "go" | undefined
|
||||||
|
/** Spec 3.4.0, a subagent: it ended by its own call (team_finish): the turn stops after this step. */
|
||||||
|
ended?(): boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a prompt carries besides its text. */
|
/** What a prompt carries besides its text. */
|
||||||
@@ -69,6 +98,8 @@ export interface PromptMeta {
|
|||||||
attachments?: AttachmentInfo[]
|
attachments?: AttachmentInfo[]
|
||||||
/** The harness started this turn — a job's note — not the person. */
|
/** The harness started this turn — a job's note — not the person. */
|
||||||
machine?: true
|
machine?: true
|
||||||
|
/** Spec 3.4.0: the steps this turn may take, when fewer than the engine's (a subagent's final turn). */
|
||||||
|
maxSteps?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_PARALLEL = LOOP.parallel.max
|
const MAX_PARALLEL = LOOP.parallel.max
|
||||||
@@ -89,7 +120,8 @@ export function mergeUsers(messages: Message[]): Message[] {
|
|||||||
const out: Message[] = []
|
const out: Message[] = []
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
const last = out[out.length - 1]
|
const last = out[out.length - 1]
|
||||||
if (m.role === "user" && last?.role === "user") out[out.length - 1] = { role: "user", parts: [...last.parts, ...m.parts] }
|
// One that starts a turn makes the pair start one (context.md §8).
|
||||||
|
if (m.role === "user" && last?.role === "user") out[out.length - 1] = { role: "user", parts: [...last.parts, ...m.parts], ...(last.turn === false && m.turn === false ? { turn: false as const } : {}) }
|
||||||
else out.push(m)
|
else out.push(m)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -109,7 +141,7 @@ export const PLAN_META = "plan"
|
|||||||
export class Engine {
|
export class Engine {
|
||||||
messages: Message[] = []
|
messages: Message[] = []
|
||||||
mode: Mode
|
mode: Mode
|
||||||
private _effort: Effort | null
|
private _effort: Level
|
||||||
usage: Usage | undefined
|
usage: Usage | undefined
|
||||||
/** messages.length when `usage` was reported: everything after it is not yet counted. */
|
/** messages.length when `usage` was reported: everything after it is not yet counted. */
|
||||||
usageAt = 0
|
usageAt = 0
|
||||||
@@ -165,8 +197,14 @@ export class Engine {
|
|||||||
private keptBase: Told | undefined
|
private keptBase: Told | undefined
|
||||||
/** The last request's usage, cached input tokens included, and what it was for. */
|
/** The last request's usage, cached input tokens included, and what it was for. */
|
||||||
lastRequest: { usage: Usage; kind: "step" | "compaction" } | undefined
|
lastRequest: { usage: Usage; kind: "step" | "compaction" } | undefined
|
||||||
|
/** The prompt the team's offer was last decided for. */
|
||||||
|
private teamFixed: Kept | undefined
|
||||||
|
|
||||||
|
/** Spec 3.4.0 (harness/progress.md): what this agent is doing, said as `phase` events. */
|
||||||
|
readonly progress: ProgressTracker
|
||||||
|
|
||||||
constructor(readonly o: EngineOptions) {
|
constructor(readonly o: EngineOptions) {
|
||||||
|
this.progress = new ProgressTracker(o.bus)
|
||||||
this.mode = o.mode
|
this.mode = o.mode
|
||||||
this._effort = o.effort
|
this._effort = o.effort
|
||||||
this.model = o.model
|
this.model = o.model
|
||||||
@@ -198,6 +236,9 @@ export class Engine {
|
|||||||
if (this.model.spec.vision === true) could.add("vision")
|
if (this.model.spec.vision === true) could.add("vision")
|
||||||
if (this.o.toolCtx.projectDir !== undefined) could.add("project")
|
if (this.o.toolCtx.projectDir !== undefined) could.add("project")
|
||||||
if (this.o.toolCtx.show !== undefined) could.add("display")
|
if (this.o.toolCtx.show !== undefined) could.add("display")
|
||||||
|
// The team tools' requirement: offered as the prompt is made; whether it is met is the team's
|
||||||
|
// to answer at each call (team.md §2: one_agent or one_request).
|
||||||
|
if (this.o.team?.offered()) could.add("team")
|
||||||
const L = this.o.toolCtx.library
|
const L = this.o.toolCtx.library
|
||||||
const have = L?.lib.bases().filter((b) => b.documents > 0).map((b) => b.name) ?? []
|
const have = L?.lib.bases().filter((b) => b.documents > 0).map((b) => b.name) ?? []
|
||||||
if (have.some((b) => !L!.bases || L!.bases.includes(b))) could.add("knowledge")
|
if (have.some((b) => !L!.bases || L!.bases.includes(b))) could.add("knowledge")
|
||||||
@@ -220,7 +261,20 @@ export class Engine {
|
|||||||
|
|
||||||
/** The session's prefix, made now if there is none yet (or it was made for another model). */
|
/** The session's prefix, made now if there is none yet (or it was made for another model). */
|
||||||
prefix(): Kept {
|
prefix(): Kept {
|
||||||
if (this.kept && this.kept.model === this.model.ref) return this.kept
|
if (this.kept && this.kept.model === this.model.ref) {
|
||||||
|
// A kept prompt (a resumed session) decided whether the team is offered when it was made: its
|
||||||
|
// tool list says so (team.md §2: fixed for as long as the prompt is).
|
||||||
|
if (this.teamFixed !== this.kept) {
|
||||||
|
this.teamFixed = this.kept
|
||||||
|
this.o.team?.fix(this.model.ref, !this.kept.left.includes("team_add"))
|
||||||
|
}
|
||||||
|
return this.kept
|
||||||
|
}
|
||||||
|
// Whether a team is offered is decided here, with the prompt and the tool list (team.md §2).
|
||||||
|
this.o.team?.fix(this.model.ref)
|
||||||
|
// After a compaction the size is said again, as the date is: neither is in the prompt. A model
|
||||||
|
// switch keeps the conversation, and what it said.
|
||||||
|
if (!this.kept) this.o.team?.forget?.()
|
||||||
const changing = this.o.changing?.() ?? NO_CHANGING
|
const changing = this.o.changing?.() ?? NO_CHANGING
|
||||||
const now = this.now(changing)
|
const now = this.now(changing)
|
||||||
const could = this.could()
|
const could = this.could()
|
||||||
@@ -232,6 +286,7 @@ export class Engine {
|
|||||||
if (this.kept?.told.date) told.date = this.kept.told.date
|
if (this.kept?.told.date) told.date = this.kept.told.date
|
||||||
this.keptBase = { ...structuredClone(told), date: undefined }
|
this.keptBase = { ...structuredClone(told), date: undefined }
|
||||||
this.kept = { system: this.o.system(this.mode, this.model, this.plan, changing), left, model: this.model.ref, told }
|
this.kept = { system: this.o.system(this.mode, this.model, this.plan, changing), left, model: this.model.ref, told }
|
||||||
|
this.teamFixed = this.kept
|
||||||
this.saveKept()
|
this.saveKept()
|
||||||
return this.kept
|
return this.kept
|
||||||
}
|
}
|
||||||
@@ -258,7 +313,7 @@ export class Engine {
|
|||||||
* the model's from then on. */
|
* the model's from then on. */
|
||||||
private takeNote(): string {
|
private takeNote(): string {
|
||||||
const kept = this.prefix()
|
const kept = this.prefix()
|
||||||
const { note, told } = noteFor(kept.told, this.now(), (m) => this.o.modeText?.(m) ?? fillPrompt(promptText(`modes/${m}.md`), { plan_dir: this.o.toolCtx.planDir ?? "" }))
|
const { note, told } = noteFor(kept.told, this.now(), (m) => this.o.modeText?.(m) ?? fillPrompt(promptText(`modes/${m}.md`), { plan_dir: this.o.toolCtx.planDir ?? "" }), this.o.team?.note?.() ?? [])
|
||||||
if (note) {
|
if (note) {
|
||||||
kept.told = told
|
kept.told = told
|
||||||
this.saveKept()
|
this.saveKept()
|
||||||
@@ -279,6 +334,7 @@ export class Engine {
|
|||||||
rewound() {
|
rewound() {
|
||||||
if (!this.kept || !this.keptBase) return
|
if (!this.kept || !this.keptBase) return
|
||||||
this.kept.told = structuredClone(this.keptBase)
|
this.kept.told = structuredClone(this.keptBase)
|
||||||
|
this.o.team?.forget?.()
|
||||||
this.saveKept()
|
this.saveKept()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,25 +370,25 @@ export class Engine {
|
|||||||
this.o.bus.emit({ type: "plan", plan: this.plan })
|
this.o.bus.emit({ type: "plan", plan: this.plan })
|
||||||
}
|
}
|
||||||
|
|
||||||
get effort(): Effort | null {
|
get effort(): Level {
|
||||||
return this._effort
|
return this._effort
|
||||||
}
|
}
|
||||||
/** Said on the bus when it changes: the web UI's chat shows the device's model and effort,
|
/** Said on the bus when it changes: the web UI's chat shows the device's model and effort,
|
||||||
* whoever changed them. */
|
* whoever changed them. */
|
||||||
set effort(v: Effort | null) {
|
set effort(v: Level) {
|
||||||
if (v === this._effort) return
|
if (v === this._effort) return
|
||||||
this._effort = v
|
this._effort = v
|
||||||
this.o.bus.emit({ type: "model", ref: this.model.ref, effort: v ?? "off", connection: this.model.connectionName })
|
this.o.bus.emit({ type: "model", ref: this.model.ref, effort: v, connection: this.model.connectionName })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Switch model mid-session. The conversation carries over; the effort resets to the new model's.
|
/** Switch model mid-session. The conversation carries over; the effort resets to the new model's.
|
||||||
* The prompt is made again for it at the next request (prefix(): it was made for another). */
|
* The prompt is made again for it at the next request (prefix(): it was made for another). */
|
||||||
setModel(model: ResolvedModel, client: Client, effort: Effort | null) {
|
setModel(model: ResolvedModel, client: Client, effort: Level) {
|
||||||
const changed = model.ref !== this.model.ref || effort !== this._effort
|
const changed = model.ref !== this.model.ref || effort !== this._effort
|
||||||
this.model = model
|
this.model = model
|
||||||
this.client = client
|
this.client = client
|
||||||
this._effort = effort
|
this._effort = effort
|
||||||
if (changed) this.o.bus.emit({ type: "model", ref: model.ref, effort: effort ?? "off", connection: model.connectionName })
|
if (changed) this.o.bus.emit({ type: "model", ref: model.ref, effort, connection: model.connectionName })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Start over: a fresh conversation (and session row). Approvals given "for the session" end. */
|
/** Start over: a fresh conversation (and session row). Approvals given "for the session" end. */
|
||||||
@@ -405,9 +461,10 @@ export class Engine {
|
|||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tokens in use now: the last reported usage plus an estimate of what was added since. The
|
/** Tokens in use now: the last reported usage plus an estimate of what was added since. A final
|
||||||
* reply's thinking is left out where the dialect never sends it back (openai-chat): it is in the
|
* reply's thinking is left out: it is in its output, but the next request starts a turn and
|
||||||
* last reply's output but will not be in the next request. */
|
* sends no earlier turn's reasoning (context.md §8). A reply that called tools keeps it: its turn
|
||||||
|
* goes on, and its reasoning goes back. */
|
||||||
contextUsed(): number | undefined {
|
contextUsed(): number | undefined {
|
||||||
if (!this.usage) return undefined
|
if (!this.usage) return undefined
|
||||||
return this.usage.input + this.usage.output - this.droppedThinking() + estimateValue(this.messages.slice(this.usageAt))
|
return this.usage.input + this.usage.output - this.droppedThinking() + estimateValue(this.messages.slice(this.usageAt))
|
||||||
@@ -416,7 +473,7 @@ export class Engine {
|
|||||||
private droppedThinking(): number {
|
private droppedThinking(): number {
|
||||||
if (!this.usage) return 0
|
if (!this.usage) return 0
|
||||||
const reply = this.messages[this.usageAt - 1]
|
const reply = this.messages[this.usageAt - 1]
|
||||||
if (reply?.role !== "assistant") return 0
|
if (reply?.role !== "assistant" || reply.parts.some((p) => p.type === "tool_call")) return 0
|
||||||
const estimate = reply.parts.reduce((n, p) => n + (p.type === "reasoning" ? estimateTokens(p.text) : 0), 0)
|
const estimate = reply.parts.reduce((n, p) => n + (p.type === "reasoning" ? estimateTokens(p.text) : 0), 0)
|
||||||
return Math.min(this.usage.output, this.usage.reasoning ?? estimate)
|
return Math.min(this.usage.output, this.usage.reasoning ?? estimate)
|
||||||
}
|
}
|
||||||
@@ -424,8 +481,19 @@ export class Engine {
|
|||||||
/** Before a step: past compaction.auto_at of the window, compact. Old tool results are never
|
/** Before a step: past compaction.auto_at of the window, compact. Old tool results are never
|
||||||
* pruned in place (harness context.md: nothing already sent is rewritten — it would throw the
|
* pruned in place (harness context.md: nothing already sent is rewritten — it would throw the
|
||||||
* prompt cache away from that point); compaction is the one deliberate break. */
|
* prompt cache away from that point); compaction is the one deliberate break. */
|
||||||
private async manageContext(signal: AbortSignal) {
|
private async manageContext(signal: AbortSignal): Promise<"stop" | void> {
|
||||||
const window = contextWindow(this.model)
|
const window = contextWindow(this.model)
|
||||||
|
// Spec 3.4.0 §9: a subagent of the tree does not compact; at pause_at it is paused instead.
|
||||||
|
const full = this.o.team?.contextFull
|
||||||
|
if (full && window) {
|
||||||
|
const used = this.contextUsed()
|
||||||
|
if (used !== undefined && used >= window * LOOP.team.context.pause_at) {
|
||||||
|
const r = full(used, window)
|
||||||
|
if (r === "stop") return "stop"
|
||||||
|
if (r === "go") return
|
||||||
|
}
|
||||||
|
if (full(0, 0) !== undefined) return
|
||||||
|
}
|
||||||
const autoAt = this.o.compaction?.autoAt ?? 0.85
|
const autoAt = this.o.compaction?.autoAt ?? 0.85
|
||||||
if (!window || autoAt >= 1) return
|
if (!window || autoAt >= 1) return
|
||||||
const limit = window * autoAt
|
const limit = window * autoAt
|
||||||
@@ -442,6 +510,7 @@ export class Engine {
|
|||||||
}
|
}
|
||||||
this.autoCompactions++
|
this.autoCompactions++
|
||||||
this.o.bus.emit({ type: "notice", message: `context at ${Math.round((used / window) * 100)}% of ${window}: compacting the conversation` })
|
this.o.bus.emit({ type: "notice", message: `context at ${Math.round((used / window) * 100)}% of ${window}: compacting the conversation` })
|
||||||
|
this.progress.set({ phase: "compacting" })
|
||||||
try {
|
try {
|
||||||
await this.summarise(signal)
|
await this.summarise(signal)
|
||||||
// The task is never left to the summary: the prompt that started this turn comes back word
|
// The task is never left to the summary: the prompt that started this turn comes back word
|
||||||
@@ -468,11 +537,13 @@ export class Engine {
|
|||||||
|
|
||||||
/** Time spent waiting for the user does not count against the wall-clock budget: somebody who
|
/** Time spent waiting for the user does not count against the wall-clock budget: somebody who
|
||||||
* thinks for ten minutes about one command has not spent the agent's time. */
|
* thinks for ten minutes about one command has not spent the agent's time. */
|
||||||
private async waiting<T>(p: Promise<T>): Promise<T> {
|
private async waiting<T>(p: Promise<T>, what: "approval" | "question" | "plan" = "approval", tool?: string): Promise<T> {
|
||||||
const t0 = performance.now()
|
const t0 = performance.now()
|
||||||
|
const back = this.progress.hold({ phase: "waiting_for_person", what, ...(tool ? { tool } : {}) })
|
||||||
try {
|
try {
|
||||||
return await p
|
return await p
|
||||||
} finally {
|
} finally {
|
||||||
|
back()
|
||||||
this.turnWaitedMs += performance.now() - t0
|
this.turnWaitedMs += performance.now() - t0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -553,7 +624,7 @@ export class Engine {
|
|||||||
private deliverInbox(): boolean {
|
private deliverInbox(): boolean {
|
||||||
const notes = this.notes.splice(0)
|
const notes = this.notes.splice(0)
|
||||||
if (notes.length) {
|
if (notes.length) {
|
||||||
this.push({ role: "user", parts: [{ type: "text", text: notes.join("\n\n") }] })
|
this.push({ role: "user", parts: [{ type: "text", text: notes.join("\n\n") }], turn: false })
|
||||||
// Said as what happened, not as its mark: the line after it.
|
// Said as what happened, not as its mark: the line after it.
|
||||||
this.o.bus.emit({ type: "notice", message: notes.map((n) => n.split("\n").find((l, i) => i > 0 && l.trim()) ?? n.split("\n")[0]!).join("; ") })
|
this.o.bus.emit({ type: "notice", message: notes.map((n) => n.split("\n").find((l, i) => i > 0 && l.trim()) ?? n.split("\n")[0]!).join("; ") })
|
||||||
}
|
}
|
||||||
@@ -561,7 +632,7 @@ export class Engine {
|
|||||||
const texts = this.inbox.splice(0)
|
const texts = this.inbox.splice(0)
|
||||||
const ids = this.inboxIds.splice(0).filter((i): i is string => typeof i === "string")
|
const ids = this.inboxIds.splice(0).filter((i): i is string => typeof i === "string")
|
||||||
this.turnSteers.push(...texts)
|
this.turnSteers.push(...texts)
|
||||||
this.push({ role: "user", parts: [{ type: "text", text: `${this.o.steerMark ?? STEER_MARK}\n${texts.join("\n\n")}` }] })
|
this.push({ role: "user", parts: [{ type: "text", text: `${this.o.steerMark ?? STEER_MARK}\n${texts.join("\n\n")}` }], turn: false })
|
||||||
this.o.bus.emit({ type: "steered", texts, ...(ids.length ? { ids } : {}) })
|
this.o.bus.emit({ type: "steered", texts, ...(ids.length ? { ids } : {}) })
|
||||||
this.o.bus.emit({ type: "inbox", texts: [], mode: this.busyInput })
|
this.o.bus.emit({ type: "inbox", texts: [], mode: this.busyInput })
|
||||||
return true
|
return true
|
||||||
@@ -572,6 +643,17 @@ export class Engine {
|
|||||||
if (this.o.store && this.sessionId) this.lastRowId = this.o.store.append(this.sessionId, m)
|
if (this.o.store && this.sessionId) this.lastRowId = this.o.store.append(this.sessionId, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the person typed in this session, oldest first (subagents.md §2) — compacted messages
|
||||||
|
* included, as the store keeps them; never a note, a marked message or a machine turn. */
|
||||||
|
personSaid(): string[] {
|
||||||
|
const all = this.o.store && this.sessionId ? this.o.store.messages(this.sessionId) : this.messages
|
||||||
|
return [...this.saidBefore, ...personTexts(all, this.o.steerMark ?? STEER_MARK)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spec 3.4.0 (acp.md, *Subagents in an agent chat*): the person's earlier words in a chat this
|
||||||
|
* device has not seen, as the client sent them (`said` on session/new). */
|
||||||
|
saidBefore: string[] = []
|
||||||
|
|
||||||
/** `extra`: attachments (@files, images) and the like — sent to the model after the text, never shown as typed.
|
/** `extra`: attachments (@files, images) and the like — sent to the model after the text, never shown as typed.
|
||||||
* `meta`: the turn's id and what was attached, said on the `prompt` event. */
|
* `meta`: the turn's id and what was attached, said on the `prompt` event. */
|
||||||
async prompt(text: string, extra: (string | ImagePart)[] = [], shown = text, meta: PromptMeta = {}): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> {
|
async prompt(text: string, extra: (string | ImagePart)[] = [], shown = text, meta: PromptMeta = {}): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> {
|
||||||
@@ -579,19 +661,24 @@ export class Engine {
|
|||||||
this.busy = true
|
this.busy = true
|
||||||
this.o.bus.emit({ type: "prompt", text: shown, ...(meta.turnId ? { turnId: meta.turnId } : {}), ...(meta.attachments?.length ? { attachments: meta.attachments } : {}), ...(meta.machine ? { machine: true as const } : {}) })
|
this.o.bus.emit({ type: "prompt", text: shown, ...(meta.turnId ? { turnId: meta.turnId } : {}), ...(meta.attachments?.length ? { attachments: meta.attachments } : {}), ...(meta.machine ? { machine: true as const } : {}) })
|
||||||
try {
|
try {
|
||||||
return await this.run(text, [...this.pendingContext.splice(0), ...extra], meta.turnId, meta.machine)
|
return await this.run(text, [...this.pendingContext.splice(0), ...extra], meta.turnId, meta.machine, meta.maxSteps)
|
||||||
} finally {
|
} finally {
|
||||||
this.busy = false
|
this.busy = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async run(text: string, extra: (string | ImagePart)[], turnId?: string, machine?: true): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> {
|
private async run(text: string, extra: (string | ImagePart)[], turnId?: string, machine?: true, stepCap?: number): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> {
|
||||||
const { bus } = this.o
|
const { bus } = this.o
|
||||||
this.abort = new AbortController()
|
this.abort = new AbortController()
|
||||||
const signal = this.abort.signal
|
const signal = this.abort.signal
|
||||||
// Before a session's first request: the configured MCP servers get a moment to connect, so
|
// Before a session's first request: the configured MCP servers get a moment to connect, so
|
||||||
// their tools are in the first list (§10 rule 4).
|
// their tools are in the first list (§10 rule 4).
|
||||||
if (!this.kept || this.kept.model !== this.model.ref) await this.o.beforeFirst?.()
|
if (!this.kept || this.kept.model !== this.model.ref) await this.o.beforeFirst?.()
|
||||||
|
// A team's lead holds a slot of its model while its turn runs (team.md §8): it may wait for one.
|
||||||
|
if (this.o.team?.beforeTurn && !(await this.o.team.beforeTurn(signal))) {
|
||||||
|
bus.emit({ type: "done", reason: "cancelled" })
|
||||||
|
return "cancelled"
|
||||||
|
}
|
||||||
// What changed since the prompt was made goes after the person's words, as a note (§10 rule 3).
|
// What changed since the prompt was made goes after the person's words, as a note (§10 rule 3).
|
||||||
const note = this.takeNote()
|
const note = this.takeNote()
|
||||||
this.push({
|
this.push({
|
||||||
@@ -604,7 +691,7 @@ export class Engine {
|
|||||||
this.turnSteers = []
|
this.turnSteers = []
|
||||||
this.autoCompactions = 0
|
this.autoCompactions = 0
|
||||||
const toolMap = new Map(this.o.tools.map((t) => [t.name, t]))
|
const toolMap = new Map(this.o.tools.map((t) => [t.name, t]))
|
||||||
const maxSteps = this.o.maxSteps ?? LOOP.steps.default
|
const maxSteps = Math.min(this.o.maxSteps ?? LOOP.steps.default, stepCap ?? Infinity)
|
||||||
const started = performance.now()
|
const started = performance.now()
|
||||||
this.turnWaitedMs = 0
|
this.turnWaitedMs = 0
|
||||||
this.turnOutputBytes = 0
|
this.turnOutputBytes = 0
|
||||||
@@ -622,7 +709,10 @@ export class Engine {
|
|||||||
if (ranOut) bus.emit({ type: "notice", message: `stopped ${ranOut.said} — the model answers from what it has; send a message to carry on` })
|
if (ranOut) bus.emit({ type: "notice", message: `stopped ${ranOut.said} — the model answers from what it has; send a message to carry on` })
|
||||||
}
|
}
|
||||||
const wrapUp = step > maxSteps || ranOut !== undefined
|
const wrapUp = step > maxSteps || ranOut !== undefined
|
||||||
if (!wrapUp) await this.manageContext(signal)
|
if (!wrapUp && (await this.manageContext(signal)) === "stop") {
|
||||||
|
bus.emit({ type: "done", reason: "stop" })
|
||||||
|
return "stop"
|
||||||
|
}
|
||||||
// After compacting, so what was just sent is never folded into a summary.
|
// After compacting, so what was just sent is never folded into a summary.
|
||||||
if (!wrapUp) this.deliverInbox()
|
if (!wrapUp) this.deliverInbox()
|
||||||
bus.emit({ type: "step", n: step })
|
bus.emit({ type: "step", n: step })
|
||||||
@@ -638,7 +728,7 @@ export class Engine {
|
|||||||
// The same system prompt and tool list on every request of the session (§10).
|
// The same system prompt and tool list on every request of the session (§10).
|
||||||
const stream = this.client.stream({
|
const stream = this.client.stream({
|
||||||
system: this.systemPrompt(),
|
system: this.systemPrompt(),
|
||||||
messages: mergeUsers(wrapUp ? [...this.messages, { role: "user", parts: [{ type: "text", text: wrapUpText(ranOut ? ranOut.limit : "step limit") }] }] : this.messages),
|
messages: mergeUsers(wrapUp ? [...this.messages, { role: "user", parts: [{ type: "text", text: wrapUpText(ranOut ? ranOut.limit : "step limit") }], turn: false }] : this.messages),
|
||||||
// The wrap-up too (loop.json wrap_up): its message says the tools are withdrawn, and a
|
// The wrap-up too (loop.json wrap_up): its message says the tools are withdrawn, and a
|
||||||
// call in its reply is not run.
|
// call in its reply is not run.
|
||||||
tools: this.toolSpecs(),
|
tools: this.toolSpecs(),
|
||||||
@@ -721,7 +811,7 @@ export class Engine {
|
|||||||
? `the reply was cut off at the output limit${limit}; asking it to carry on`
|
? `the reply was cut off at the output limit${limit}; asking it to carry on`
|
||||||
: `the model used the whole output limit${limit} thinking; asking it to act`,
|
: `the model used the whole output limit${limit} thinking; asking it to act`,
|
||||||
})
|
})
|
||||||
this.push({ role: "user", parts: [{ type: "text", text: !cut ? NO_ANSWER : said ? CUT_TEXT : CUT_THINKING }] })
|
this.push({ role: "user", parts: [{ type: "text", text: !cut ? NO_ANSWER : said ? CUT_TEXT : CUT_THINKING }], turn: false })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
bus.emit({
|
bus.emit({
|
||||||
@@ -765,11 +855,11 @@ export class Engine {
|
|||||||
...this.o.toolCtx,
|
...this.o.toolCtx,
|
||||||
signal,
|
signal,
|
||||||
backgroundNow: bgNow.signal,
|
backgroundNow: bgNow.signal,
|
||||||
question: this.o.asker.question ? (req, id) => this.waiting(this.o.asker.question!(req, id)) : undefined,
|
question: this.o.asker.question ? (req, id) => this.waiting(this.o.asker.question!(req, id), "question") : undefined,
|
||||||
plan: {
|
plan: {
|
||||||
get: () => this.plan,
|
get: () => this.plan,
|
||||||
set: (p) => this.setPlan(p, "model"),
|
set: (p) => this.setPlan(p, "model"),
|
||||||
ask: this.o.asker.plan ? (req, id) => this.waiting(this.o.asker.plan!(req, id)) : undefined,
|
ask: this.o.asker.plan ? (req, id) => this.waiting(this.o.asker.plan!(req, id), "plan") : undefined,
|
||||||
},
|
},
|
||||||
setMode: (m) => {
|
setMode: (m) => {
|
||||||
this.mode = m
|
this.mode = m
|
||||||
@@ -828,7 +918,12 @@ export class Engine {
|
|||||||
}
|
}
|
||||||
// No dialect takes an image inside a tool result the same way; a user turn right after the
|
// No dialect takes an image inside a tool result the same way; a user turn right after the
|
||||||
// results works everywhere.
|
// results works everywhere.
|
||||||
if (images.length) this.push({ role: "user", parts: [{ type: "text", text: "The image(s) you asked to view:" }, ...images] })
|
if (images.length) this.push({ role: "user", parts: [{ type: "text", text: "The image(s) you asked to view:" }, ...images], turn: false })
|
||||||
|
// Spec 3.4.0: a subagent that called team_finish has ended — nothing runs after that call.
|
||||||
|
if (this.o.team?.ended?.()) {
|
||||||
|
bus.emit({ type: "done", reason: "stop" })
|
||||||
|
return "stop"
|
||||||
|
}
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
bus.emit({ type: "done", reason: "cancelled" })
|
bus.emit({ type: "done", reason: "cancelled" })
|
||||||
return "cancelled"
|
return "cancelled"
|
||||||
@@ -874,6 +969,7 @@ export class Engine {
|
|||||||
const resolved = resolveCall(call.name, raw)
|
const resolved = resolveCall(call.name, raw)
|
||||||
tool = toolMap.get(resolved.name) ?? tool
|
tool = toolMap.get(resolved.name) ?? tool
|
||||||
raw = resolved.raw
|
raw = resolved.raw
|
||||||
|
const team = this.o.team
|
||||||
// Offered for the whole session; while what it needs is not met, the spec's sentence (§10 rule 4).
|
// Offered for the whole session; while what it needs is not met, the spec's sentence (§10 rule 4).
|
||||||
const no = unavailable(tool.name, tool.requires ? [tool.requires] : undefined, this.met())
|
const no = unavailable(tool.name, tool.requires ? [tool.requires] : undefined, this.met())
|
||||||
if (no) return { result: { output: no, isError: true } }
|
if (no) return { result: { output: no, isError: true } }
|
||||||
@@ -884,15 +980,27 @@ export class Engine {
|
|||||||
const { args: own, purpose } = splitPurpose(tool, raw)
|
const { args: own, purpose } = splitPurpose(tool, raw)
|
||||||
const parsed = tool.schema.safeParse(own)
|
const parsed = tool.schema.safeParse(own)
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
const spoken = tool.invalid?.(own, { ...this.o.toolCtx, signal: new AbortController().signal })
|
||||||
|
if (spoken) return { result: { output: spoken, isError: true } }
|
||||||
const why = parsed.error.issues.map((i) => `${i.path.join(".") || "(arguments)"}: ${i.message}`).join("; ")
|
const why = parsed.error.issues.map((i) => `${i.path.join(".") || "(arguments)"}: ${i.message}`).join("; ")
|
||||||
return { result: { output: `Invalid arguments for ${tool.name}: ${why}`, isError: true } }
|
return { result: { output: `Invalid arguments for ${tool.name}: ${why}`, isError: true } }
|
||||||
}
|
}
|
||||||
let args = parsed.data
|
let args = parsed.data
|
||||||
|
// A team's answers come before the permission check — nothing is asked (team.md §6).
|
||||||
|
const byTeam = team?.refuse(tool, args)
|
||||||
|
if (byTeam) return { result: { output: byTeam, isError: true } }
|
||||||
const toolCtx: ToolContext = { ...this.o.toolCtx, signal: new AbortController().signal }
|
const toolCtx: ToolContext = { ...this.o.toolCtx, signal: new AbortController().signal }
|
||||||
let request = tool.permission(args, toolCtx)
|
let request = tool.permission(args, toolCtx)
|
||||||
let edited: string | undefined
|
let edited: string | undefined
|
||||||
const ctx: PermContext = { ...this.o.perm, mode: this.mode, rules: [...this.o.perm.rules, ...this.sessionRules] }
|
const ctx: PermContext = { ...this.o.perm, mode: this.mode, rules: [...this.o.perm.rules, ...this.sessionRules] }
|
||||||
let decision = evaluate(request, ctx)
|
let decision = evaluate(request, ctx)
|
||||||
|
// Spec 3.4.0 (subagents.md §7): a subagent of the tree — itself, the main agent, or the person.
|
||||||
|
if (team?.permit) {
|
||||||
|
const { mode: _m, ...rest } = ctx
|
||||||
|
const v = team.permit(request, rest)
|
||||||
|
if (v.answer === "refused") return { result: { output: v.text, isError: true }, denied: true }
|
||||||
|
decision = { ...v.decision, action: v.answer === "run" ? "allow" : "ask" }
|
||||||
|
}
|
||||||
|
|
||||||
// Doom-loop guard: the same call three times running is asked about, whatever the mode.
|
// Doom-loop guard: the same call three times running is asked about, whatever the mode.
|
||||||
const key = `${call.name} ${JSON.stringify(args)}`
|
const key = `${call.name} ${JSON.stringify(args)}`
|
||||||
@@ -909,7 +1017,7 @@ export class Engine {
|
|||||||
(diff) => ({ diff }),
|
(diff) => ({ diff }),
|
||||||
(e) => ({ error: (e as Error).message }),
|
(e) => ({ error: (e as Error).message }),
|
||||||
)
|
)
|
||||||
const reply = await this.waiting(this.o.asker.ask({ tool: tool.name, args, request, decision, preview, purpose }))
|
const reply = await this.waiting(this.o.asker.ask({ tool: tool.name, args, request, decision, preview, purpose }), "approval", tool.name)
|
||||||
if (reply.kind === "deny" && looping)
|
if (reply.kind === "deny" && looping)
|
||||||
return {
|
return {
|
||||||
result: {
|
result: {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function toldAt(now: Now): Told {
|
|||||||
/** The note for a user message: a line for each thing that differs from what the model was told,
|
/** The note for a user message: a line for each thing that differs from what the model was told,
|
||||||
* in loop.json's order, inside the note's tags — "" when nothing does. `modeText` is the mode's
|
* in loop.json's order, inside the note's tags — "" when nothing does. `modeText` is the mode's
|
||||||
* text (modes/<mode>.md, filled as the prompt fills it). Returns what is told after it. */
|
* text (modes/<mode>.md, filled as the prompt fills it). Returns what is told after it. */
|
||||||
export function noteFor(told: Told, now: Now, modeText: (mode: Mode) => string): { note: string; told: Told } {
|
export function noteFor(told: Told, now: Now, modeText: (mode: Mode) => string, extra: string[] = []): { note: string; told: Told } {
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
const say = (text: string, vars: Record<string, string>) => lines.push(fillPrompt(text, vars))
|
const say = (text: string, vars: Record<string, string>) => lines.push(fillPrompt(text, vars))
|
||||||
const or = (s: string) => (s.trim() ? s : NOTHING)
|
const or = (s: string) => (s.trim() ? s : NOTHING)
|
||||||
@@ -88,6 +88,8 @@ export function noteFor(told: Told, now: Now, modeText: (mode: Mode) => string):
|
|||||||
// Only a server that has connected or changed since: one that went away takes its tools with it.
|
// Only a server that has connected or changed since: one that went away takes its tools with it.
|
||||||
const arrived = Object.keys(now.mcp).filter((name) => told.mcp[name] !== now.mcp[name])
|
const arrived = Object.keys(now.mcp).filter((name) => told.mcp[name] !== now.mcp[name])
|
||||||
if (arrived.length) say(NOTE.mcp, { instructions: arrived.map((name) => mcpBlock(name, now.mcp[name]!)).join("\n") })
|
if (arrived.length) say(NOTE.mcp, { instructions: arrived.map((name) => mcpBlock(name, now.mcp[name]!)).join("\n") })
|
||||||
|
// Spec 3.2.0: the team's size and members the person stopped (team.md §10), last.
|
||||||
|
lines.push(...extra)
|
||||||
return { note: lines.length ? noteBlock(lines) : "", told: { ...toldAt(now), date: now.date } }
|
return { note: lines.length ? noteBlock(lines) : "", told: { ...toldAt(now), date: now.date } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ export function today(at = new Date()): string {
|
|||||||
|
|
||||||
/** A requirement a tool can have (the spec's `requires`, and the CLI's own: vision, project,
|
/** A requirement a tool can have (the spec's `requires`, and the CLI's own: vision, project,
|
||||||
* knowledge). */
|
* knowledge). */
|
||||||
export type Requirement = "vision" | "plan" | "project" | "knowledge" | "display" | "page" | "forge"
|
export type Requirement = "vision" | "plan" | "project" | "knowledge" | "display" | "page" | "forge" | "team"
|
||||||
|
|
||||||
/** Rule 4: the tool list is fixed for the session. A tool is offered when every requirement it has
|
/** Rule 4: the tool list is fixed for the session. A tool is offered when every requirement it has
|
||||||
* is one the session could meet at some point (`could`), decided once as the prompt is made; a
|
* is one the session could meet at some point (`could`), decided once as the prompt is made; a
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// Progress (harness spec 3.4.0, harness/progress.md): one fixed set of phases every client shows
|
||||||
|
// the same way. The object, its text from loop.json `progress.texts` and the one line drawn for it.
|
||||||
|
import { LOOP } from "../harness.ts"
|
||||||
|
import { fillPrompt } from "../prompt/fill.ts"
|
||||||
|
import { duration } from "../duration.ts"
|
||||||
|
|
||||||
|
const P = LOOP.progress
|
||||||
|
|
||||||
|
export type Phase =
|
||||||
|
| "queued"
|
||||||
|
| "waiting_for_model"
|
||||||
|
| "loading_model"
|
||||||
|
| "reading_prompt"
|
||||||
|
| "thinking"
|
||||||
|
| "writing"
|
||||||
|
| "running_tool"
|
||||||
|
| "waiting_for_person"
|
||||||
|
| "waiting_for_subagents"
|
||||||
|
| "compacting"
|
||||||
|
| "retrying"
|
||||||
|
| "stopped"
|
||||||
|
| "failed"
|
||||||
|
|
||||||
|
/** The structured progress object: the phase, when it began (Unix ms), and its data. */
|
||||||
|
export type Progress = { phase: Phase; since: number } & {
|
||||||
|
ahead?: number
|
||||||
|
model?: string
|
||||||
|
reason?: string
|
||||||
|
done?: number
|
||||||
|
total?: number
|
||||||
|
rate?: number
|
||||||
|
tool?: string
|
||||||
|
what?: string
|
||||||
|
running?: number
|
||||||
|
in?: number
|
||||||
|
attempt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PHASES = P.phases as readonly Phase[]
|
||||||
|
|
||||||
|
const fill = (t: string, v: Record<string, string | number>) => fillPrompt(t, Object.fromEntries(Object.entries(v).map(([k, x]) => [k, String(x)])))
|
||||||
|
|
||||||
|
/** A call's short title as `what`: one line, at most what_max characters. */
|
||||||
|
export function shortWhat(s: string | undefined): string | undefined {
|
||||||
|
if (!s) return undefined
|
||||||
|
const line = s.split("\n")[0]!.trim()
|
||||||
|
if (!line) return undefined
|
||||||
|
return line.length > P.what_max ? `${line.slice(0, P.what_max - 1)}…` : line
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The phase's text (progress.md §2), its fields filled; `now` for retrying's countdown. */
|
||||||
|
export function progressText(p: Progress, now = Date.now()): string {
|
||||||
|
const T = P.texts as Record<string, string>
|
||||||
|
switch (p.phase) {
|
||||||
|
case "queued":
|
||||||
|
return p.ahead !== undefined ? fill(T["queued.ahead"]!, { ahead: p.ahead }) : T.queued!
|
||||||
|
case "waiting_for_model":
|
||||||
|
return fill(T.waiting_for_model!, { model: p.model ?? "", reason: (P.reason as Record<string, string>)[p.reason ?? ""] ?? p.reason ?? "" })
|
||||||
|
case "loading_model":
|
||||||
|
return fill(T.loading_model!, { model: p.model ?? "" })
|
||||||
|
case "reading_prompt":
|
||||||
|
if (p.total === undefined || p.done === undefined || p.total <= 0) return T["reading_prompt.bare"]!
|
||||||
|
return fill(T.reading_prompt!, { done: p.done, total: p.total, percent: Math.floor((p.done * 100) / p.total) })
|
||||||
|
case "writing":
|
||||||
|
return p.rate !== undefined && Number.isFinite(p.rate) ? fill(T.writing!, { rate: p.rate.toFixed(1) }) : T["writing.bare"]!
|
||||||
|
case "running_tool":
|
||||||
|
return p.what ? fill(T.running_tool!, { tool: p.tool ?? "", what: p.what }) : fill(T["running_tool.bare"]!, { tool: p.tool ?? "" })
|
||||||
|
case "waiting_for_person": {
|
||||||
|
const W = P.what as Record<string, string>
|
||||||
|
const what = p.what === "approval" ? (p.tool ? fill(W.approval!, { tool: p.tool }) : W["approval.bare"]!) : (W[p.what ?? ""] ?? p.what ?? "")
|
||||||
|
return fill(T.waiting_for_person!, { what })
|
||||||
|
}
|
||||||
|
case "waiting_for_subagents":
|
||||||
|
return fill(T.waiting_for_subagents!, { running: p.running ?? 0 })
|
||||||
|
case "retrying": {
|
||||||
|
const left = Math.max(0, (p.in ?? 0) - Math.floor((now - p.since) / 1000))
|
||||||
|
return fill(T.retrying!, { in: left, reason: p.reason ?? "" })
|
||||||
|
}
|
||||||
|
case "stopped":
|
||||||
|
return p.reason ? fill(T["stopped.reason"]!, { reason: (P.stopped_reason as Record<string, string>)[p.reason] ?? p.reason }) : T.stopped!
|
||||||
|
case "failed":
|
||||||
|
return fill(T.failed!, { reason: p.reason ?? "" })
|
||||||
|
default:
|
||||||
|
return T[p.phase] ?? p.phase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The line a client draws (progress.md §3): the text and the time since it began, or the text
|
||||||
|
* alone for stopped and failed. */
|
||||||
|
export function progressLine(p: Progress, now = Date.now()): string {
|
||||||
|
const text = progressText(p, now)
|
||||||
|
if ((P.no_elapsed as string[]).includes(p.phase)) return text
|
||||||
|
return fill(P.line, { text, elapsed: duration(now - p.since, true) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether two objects are the same phase for the same thing (so `since` is kept). */
|
||||||
|
export function samePhase(a: Progress | null | undefined, b: Omit<Progress, "since">): boolean {
|
||||||
|
if (!a || a.phase !== b.phase) return false
|
||||||
|
if (b.phase === "running_tool") return a.tool === b.tool && a.what === b.what
|
||||||
|
if (b.phase === "waiting_for_model" || b.phase === "loading_model") return a.model === b.model && a.reason === b.reason
|
||||||
|
if (b.phase === "waiting_for_person") return a.what === b.what && a.tool === b.tool
|
||||||
|
if (b.phase === "retrying") return a.attempt === b.attempt && a.reason === b.reason
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a tool call does, in a line (`what` of running_tool): the command, the path, the query. */
|
||||||
|
export function callWhat(args: unknown): string | undefined {
|
||||||
|
if (!args || typeof args !== "object") return undefined
|
||||||
|
const a = args as Record<string, unknown>
|
||||||
|
for (const k of ["command", "path", "file_path", "pattern", "query", "url", "description", "name"]) if (typeof a[k] === "string" && a[k]) return shortWhat(a[k] as string)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
type BusLike = { on(fn: (e: any) => void): () => void; emit(e: any): void }
|
||||||
|
|
||||||
|
/** The progress of one engine, derived from the events it emits (progress.md §4), and said on its
|
||||||
|
* bus as `phase` events: a new phase at once, a change of its data at most pace_per_second times a
|
||||||
|
* second. Explicit phases (compacting, waiting for the person or a model, queued) are `set` by
|
||||||
|
* whoever knows them. */
|
||||||
|
export class ProgressTracker {
|
||||||
|
current: Progress | null = null
|
||||||
|
private rate: number | undefined
|
||||||
|
private running = new Map<string, Omit<Progress, "since">>()
|
||||||
|
private lastEmit = 0
|
||||||
|
private pendingTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
private error: string | undefined
|
||||||
|
private held: Progress | null = null
|
||||||
|
private off: () => void
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private bus: BusLike,
|
||||||
|
private now: () => number = Date.now,
|
||||||
|
) {
|
||||||
|
this.off = bus.on((e) => this.on(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.off()
|
||||||
|
if (this.pendingTimer) clearTimeout(this.pendingTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the phase (or null). Same phase for the same thing keeps `since`. */
|
||||||
|
set(next: Omit<Progress, "since"> | null) {
|
||||||
|
if (next === null) {
|
||||||
|
if (this.current === null) return
|
||||||
|
this.current = null
|
||||||
|
return this.emit(true)
|
||||||
|
}
|
||||||
|
if (samePhase(this.current, next)) {
|
||||||
|
const merged = { ...this.current!, ...next, since: this.current!.since } as Progress
|
||||||
|
if (JSON.stringify(merged) === JSON.stringify(this.current)) return
|
||||||
|
this.current = merged
|
||||||
|
return this.emit(false)
|
||||||
|
}
|
||||||
|
this.current = { ...next, since: this.now() } as Progress
|
||||||
|
this.emit(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A phase that waits on something outside the turn (the person, a model): set now, and the
|
||||||
|
* phase before it put back by the function returned. */
|
||||||
|
hold(next: Omit<Progress, "since">): () => void {
|
||||||
|
const before = this.current
|
||||||
|
this.held = before
|
||||||
|
this.set(next)
|
||||||
|
return () => {
|
||||||
|
if (this.current && this.current.phase === next.phase) {
|
||||||
|
this.current = before ? { ...before } : null
|
||||||
|
this.emit(true)
|
||||||
|
}
|
||||||
|
this.held = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(now: boolean) {
|
||||||
|
const t = this.now()
|
||||||
|
const gap = 1000 / LOOP.progress.pace_per_second
|
||||||
|
if (!now && t - this.lastEmit < gap) {
|
||||||
|
this.pendingTimer ??= setTimeout(() => {
|
||||||
|
this.pendingTimer = undefined
|
||||||
|
this.emit(true)
|
||||||
|
}, gap - (t - this.lastEmit))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.pendingTimer) {
|
||||||
|
clearTimeout(this.pendingTimer)
|
||||||
|
this.pendingTimer = undefined
|
||||||
|
}
|
||||||
|
this.lastEmit = t
|
||||||
|
this.bus.emit({ type: "phase", progress: this.current ? { ...this.current } : null })
|
||||||
|
}
|
||||||
|
|
||||||
|
private on(e: any) {
|
||||||
|
switch (e.type) {
|
||||||
|
case "task":
|
||||||
|
if (e.state === "start") {
|
||||||
|
this.rate = undefined
|
||||||
|
this.running.clear()
|
||||||
|
this.error = undefined
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case "step":
|
||||||
|
this.running.clear()
|
||||||
|
return this.set({ phase: "reading_prompt" })
|
||||||
|
case "progress":
|
||||||
|
return this.set({ phase: "reading_prompt", done: (e.cache ?? 0) + (e.processed ?? 0), total: e.total })
|
||||||
|
case "reasoning":
|
||||||
|
return this.set({ phase: "thinking" })
|
||||||
|
case "text":
|
||||||
|
case "tool_call_delta":
|
||||||
|
return this.set(this.rate !== undefined ? { phase: "writing", rate: this.rate } : { phase: "writing" })
|
||||||
|
case "usage":
|
||||||
|
if (!e.child && typeof e.rate === "number" && Number.isFinite(e.rate)) this.rate = e.rate
|
||||||
|
return
|
||||||
|
case "tool_start": {
|
||||||
|
const a = (e.args ?? {}) as Record<string, unknown>
|
||||||
|
const p: Omit<Progress, "since"> = e.name === "task" && a.background !== true ? { phase: "waiting_for_subagents", running: 1 } : { phase: "running_tool", tool: e.name, ...(callWhat(e.args) ? { what: callWhat(e.args) } : {}) }
|
||||||
|
this.running.set(e.id, p)
|
||||||
|
if (this.running.size === 1) this.set(p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "tool_end": {
|
||||||
|
this.running.delete(e.id)
|
||||||
|
const next = this.running.values().next()
|
||||||
|
if (!next.done) this.set(next.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "notice":
|
||||||
|
if (e.retry) this.set({ phase: "retrying", reason: shortWhat(e.retry.reason) ?? "", in: e.retry.in, ...(e.retry.attempt ? { attempt: e.retry.attempt } : {}) })
|
||||||
|
return
|
||||||
|
case "error":
|
||||||
|
this.error = shortWhat(String(e.message ?? "")) ?? "error"
|
||||||
|
return
|
||||||
|
case "done":
|
||||||
|
this.running.clear()
|
||||||
|
if (e.reason === "stop") return this.set(null)
|
||||||
|
if (e.reason === "error") return this.set({ phase: "failed", reason: this.error ?? "error" })
|
||||||
|
return this.set({ phase: "stopped", reason: e.reason === "cancelled" ? "person" : e.reason })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Delegation (harness spec 3.4.0, subagents.md §7): a subagent's mode is its parent's or stricter,
|
||||||
|
// request options go with a stricter mode, and who decides a subagent's call — itself, the main
|
||||||
|
// agent (through a request), or the person. Pure rules over permission/evaluate.ts.
|
||||||
|
import { LOOP } from "../harness.ts"
|
||||||
|
import { fillPrompt } from "../prompt/fill.ts"
|
||||||
|
import type { Mode } from "../config/schema.ts"
|
||||||
|
import { evaluate, type Context as PermContext, type Decision, type PermissionRequest, type Rule, type ToolClass } from "../permission/evaluate.ts"
|
||||||
|
import MODES_JSON from "../../harness/permission/modes.json"
|
||||||
|
|
||||||
|
const TEXTS = LOOP.team.texts
|
||||||
|
const fill = (t: string, v: Record<string, string>) => fillPrompt(t, v)
|
||||||
|
|
||||||
|
/** Loosest first (permission/modes.json `order`). */
|
||||||
|
export const MODE_ORDER = MODES_JSON.order as Mode[]
|
||||||
|
const rank = (m: Mode) => MODE_ORDER.indexOf(m)
|
||||||
|
|
||||||
|
/** a is looser than b. */
|
||||||
|
export const looser = (a: Mode, b: Mode) => rank(a) < rank(b)
|
||||||
|
/** a is stricter than b. */
|
||||||
|
export const stricter = (a: Mode, b: Mode) => rank(a) > rank(b)
|
||||||
|
/** The stricter of the two. */
|
||||||
|
export const strictest = (a: Mode, b: Mode): Mode => (stricter(a, b) ? a : b)
|
||||||
|
|
||||||
|
export const REQUEST_KINDS = LOOP.team.request.kinds as ToolClass[]
|
||||||
|
|
||||||
|
/** team_add's / team_assign's mode and request options for a parent in `parentMode` (§7): the
|
||||||
|
* refusal, or the mode and options the subagent gets. */
|
||||||
|
export function checkMode(parentMode: Mode, mode: Mode | undefined, request: string[] | undefined): { error: string } | { mode: Mode; request: ToolClass[] } {
|
||||||
|
const m = mode ?? parentMode
|
||||||
|
if (looser(m, parentMode)) return { error: fill(TEXTS.wider, { parent_mode: parentMode }) }
|
||||||
|
const req = request ?? []
|
||||||
|
if (req.length && !stricter(m, parentMode)) return { error: TEXTS.request_not_narrower }
|
||||||
|
const bad = req.find((k) => !(REQUEST_KINDS as string[]).includes(k))
|
||||||
|
if (bad !== undefined) return { error: fill(TEXTS.request_kind, { kind: bad }) }
|
||||||
|
return { mode: m, request: [...new Set(req)] as ToolClass[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A grant the main agent gave (team_decide): a rule for this subagent alone. */
|
||||||
|
export interface Grant {
|
||||||
|
tool: string
|
||||||
|
/** The permission key of the tool (edit and write share `edit`). */
|
||||||
|
permission?: string
|
||||||
|
pattern: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Verdict = { answer: "run"; decision: Decision } | { answer: "person"; decision: Decision } | { answer: "refused"; text: string; decision?: Decision }
|
||||||
|
|
||||||
|
/** Who decides a subagent's call (§7). `ctx` is the session's permission context (its rules and
|
||||||
|
* approvals); `mode` the subagent's, `main` the main agent's. A grant applies only where the main
|
||||||
|
* agent's own mode would allow the call. */
|
||||||
|
export function whoDecides(
|
||||||
|
req: PermissionRequest,
|
||||||
|
o: { ctx: Omit<PermContext, "mode">; mode: Mode; main: Mode; request: readonly string[]; grants: readonly Grant[]; unattended?: boolean },
|
||||||
|
): Verdict {
|
||||||
|
const mainD = evaluate(req, { ...o.ctx, mode: o.main })
|
||||||
|
const grantRules: Rule[] = mainD.action === "allow" ? o.grants.map((g) => ({ permission: g.permission ?? g.tool, pattern: g.pattern, action: "allow" as const })) : []
|
||||||
|
const own = evaluate(req, { ...o.ctx, mode: o.mode, rules: [...o.ctx.rules, ...grantRules] })
|
||||||
|
if (own.action === "allow") return { answer: "run", decision: own }
|
||||||
|
if (own.action === "deny") return { answer: "refused", text: `Denied: ${own.reason}`, decision: own }
|
||||||
|
if (mainD.action === "allow") return { answer: "refused", text: o.request.includes(req.class) ? TEXTS.requestable : TEXTS.not_delegated }
|
||||||
|
if (mainD.action === "deny") return { answer: "refused", text: `Denied: ${mainD.reason}`, decision: mainD }
|
||||||
|
if (o.unattended) return { answer: "refused", text: "Refused: nobody can approve this in an unattended session.", decision: own }
|
||||||
|
return { answer: "person", decision: own }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_request's own check: the tool's risk class must be one of the options. */
|
||||||
|
export function requestRefusal(tool: string, cls: ToolClass | undefined, request: readonly string[]): string | undefined {
|
||||||
|
if (cls && request.includes(cls)) return undefined
|
||||||
|
return fill(TEXTS.not_requestable, { tool, kinds: request.join(", ") || "none" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The main agent made stricter: every subagent looser than its parent is made as strict as it,
|
||||||
|
* down the tree (§7). `agents` in tree order; returns each one's mode after. */
|
||||||
|
export function tighten(main: Mode, agents: { name: string; parent: string; mode: Mode }[], lead = LOOP.team.names.lead): Record<string, Mode> {
|
||||||
|
const modes: Record<string, Mode> = { [lead]: main }
|
||||||
|
const out: Record<string, Mode> = {}
|
||||||
|
const pending = [...agents]
|
||||||
|
// Parents first: an agent's parent is the lead or one listed before it, but tolerate any order.
|
||||||
|
for (let guard = 0; pending.length && guard < 1000; guard++) {
|
||||||
|
const i = pending.findIndex((a) => modes[a.parent] !== undefined)
|
||||||
|
if (i < 0) break
|
||||||
|
const a = pending.splice(i, 1)[0]!
|
||||||
|
modes[a.name] = out[a.name] = strictest(a.mode, modes[a.parent]!)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
// A lead session's team at work (harness spec 3.2.0, team.md; 3.4.0, subagents.md §5–§13): the
|
||||||
|
// rules of team.ts driven by real turns. The lead is the session's own engine; each member is a subagent engine of its own (the job
|
||||||
|
// machinery, app.ts spawn), started when the team gives it a slot. What the rules decide happens
|
||||||
|
// through three effects: a turn starts (a member's brief, a woken agent's messages), a message reaches
|
||||||
|
// a working agent at its next step, an agent is stopped.
|
||||||
|
import type { Bus } from "../bus/index.ts"
|
||||||
|
import { TOOL_SPECS } from "../harness.ts"
|
||||||
|
import type { Engine } from "../session/engine.ts"
|
||||||
|
import type { Tool } from "../tool/tool.ts"
|
||||||
|
import { parallelFrom, Team, TEAM, type TeamAgent } from "./team.ts"
|
||||||
|
import type { Mode } from "../config/schema.ts"
|
||||||
|
import type { ToolClass } from "../permission/evaluate.ts"
|
||||||
|
|
||||||
|
/** A member's engine, as the app made it. */
|
||||||
|
export interface MemberHandle {
|
||||||
|
engine: Engine
|
||||||
|
/** Its one final turn after its context filled (spec 3.4.0 §9), with this text, few steps. */
|
||||||
|
final?(text: string): void
|
||||||
|
/** Its next turn, with this text as the prompt (its context intact). */
|
||||||
|
run(text: string): void
|
||||||
|
/** Stop it where it is; nothing more comes from it. */
|
||||||
|
cancel(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeOptions {
|
||||||
|
team: Team
|
||||||
|
/** The lead's bus: where the team's news is said (`team` events). */
|
||||||
|
bus: Bus
|
||||||
|
/** Make a member's engine (attached here with `attach`) and start its first turn with `text`. */
|
||||||
|
spawnMember(member: TeamAgent, text: string): void
|
||||||
|
/** The lead: a message at its next step (`note`), or — idle — a turn of its own (`wake`). */
|
||||||
|
lead: { note(text: string): void; wake(text: string): void; /** Spec 3.4.0: its progress while it waits for a slot. */ waiting?(model: string): void }
|
||||||
|
/** An unknown agent or model, answered as task answers it; undefined when both are known. The
|
||||||
|
* model's ref as the session knows it. */
|
||||||
|
resolve?(agent: string, model: string | undefined): { error: string } | { model: string }
|
||||||
|
/** A tool's risk class and permission key, by its name (team_request, team_decide). */
|
||||||
|
tools?: () => readonly Tool[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TeamRuntime {
|
||||||
|
team: Team
|
||||||
|
private members = new Map<string, MemberHandle>()
|
||||||
|
/** The lead's turn waiting for a slot: resolved when it is given one. */
|
||||||
|
private leadWaiter: (() => void) | undefined
|
||||||
|
/** Spec 3.4.0: subagents that ended by their own team_finish (their turn stops after the call),
|
||||||
|
* and the final turn waiting for one whose context filled. */
|
||||||
|
private ended = new Set<string>()
|
||||||
|
private finals = new Map<string, string>()
|
||||||
|
private pace: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
constructor(private o: RuntimeOptions) {
|
||||||
|
this.team = o.team
|
||||||
|
this.wire()
|
||||||
|
}
|
||||||
|
|
||||||
|
private wire() {
|
||||||
|
this.team.effects = {
|
||||||
|
start: (name, text) => this.start(name, text),
|
||||||
|
deliver: (name, text) => this.deliver(name, text),
|
||||||
|
stop: (name) => this.stopped(name),
|
||||||
|
end: (name) => void this.ended.add(name),
|
||||||
|
final: (name, text) => void this.finals.set(name, text),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Another session: its members are stopped, and the team starts again, empty. */
|
||||||
|
reset(team: Team) {
|
||||||
|
this.stopAll()
|
||||||
|
this.leadWaiter = undefined
|
||||||
|
this.team = team
|
||||||
|
this.wire()
|
||||||
|
this.changed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Said on the lead's bus as `team`, at most four times a second (acp.md `team` event). */
|
||||||
|
private changed() {
|
||||||
|
// A change inside the pace window is said once at its end, so the last state always goes out.
|
||||||
|
if (this.pace) return void (this.trailing = true)
|
||||||
|
this.o.bus.emit({ type: "team" })
|
||||||
|
this.pace = setTimeout(() => {
|
||||||
|
this.pace = undefined
|
||||||
|
if (this.trailing) {
|
||||||
|
this.trailing = false
|
||||||
|
this.changed()
|
||||||
|
}
|
||||||
|
}, 1000 / 4)
|
||||||
|
}
|
||||||
|
private trailing = false
|
||||||
|
|
||||||
|
/** Something changed that a reader must see now (the tools' answers, tests): said at once. */
|
||||||
|
private changedNow() {
|
||||||
|
if (this.pace) clearTimeout(this.pace)
|
||||||
|
this.pace = undefined
|
||||||
|
this.trailing = false
|
||||||
|
this.o.bus.emit({ type: "team" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tool's risk class, for team_request. */
|
||||||
|
classOf(tool: string): ToolClass | undefined {
|
||||||
|
const spec = TOOL_SPECS[tool]?.risk
|
||||||
|
if (spec) return spec as ToolClass
|
||||||
|
const t = this.o.tools?.().find((x) => x.name === tool)
|
||||||
|
try {
|
||||||
|
return t ? (t.permission({} as never, {} as never).class as ToolClass) : undefined
|
||||||
|
} catch {
|
||||||
|
return "execute"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tool's permission key (edit and write share `edit`), for a grant. */
|
||||||
|
permissionKeyOf(tool: string): string | undefined {
|
||||||
|
return this.o.tools?.().find((x) => x.name === tool)?.access ?? tool
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every subagent's engine in its team's mode (spec 3.4.0 §7: the main agent made stricter, an
|
||||||
|
* assignment). */
|
||||||
|
syncModes() {
|
||||||
|
for (const [name, h] of this.members) {
|
||||||
|
const m = this.team.get(name)?.mode
|
||||||
|
if (m && h.engine.mode !== m) h.engine.mode = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The main agent's mode changed (the person, plan approved): stricter reaches down the tree. */
|
||||||
|
mainMode(mode: Mode) {
|
||||||
|
this.team.setMainMode(mode)
|
||||||
|
this.syncModes()
|
||||||
|
this.changed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spec 3.4.0: whether this subagent ended by its own call (its engine stops after the step). */
|
||||||
|
hasEnded(name: string): boolean {
|
||||||
|
return this.ended.has(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spec 3.4.0 §9: its context reached pause_at. "stop" the first time (it is paused, its final
|
||||||
|
* turn follows), "go" in that final turn (no compaction, it carries on). */
|
||||||
|
contextFull(name: string): "stop" | "go" {
|
||||||
|
const a = this.team.get(name)
|
||||||
|
if (!a || a.state === "paused") return "go"
|
||||||
|
this.team.pause(name)
|
||||||
|
this.changedNow()
|
||||||
|
return "stop"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a member's run should do when its turn ended: its final turn (context full), or report. */
|
||||||
|
takeFinal(name: string): string | undefined {
|
||||||
|
const t = this.finals.get(name)
|
||||||
|
this.finals.delete(name)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Something the tools did changed the team: said, for whoever shows it. */
|
||||||
|
notify() {
|
||||||
|
this.changed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_add for `as`: an unknown agent or model is answered as task answers it, after the
|
||||||
|
* team's own refusals (the name, the goal, the size), which come first. */
|
||||||
|
add(as: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string; parent?: string; mode?: Mode; request?: string[] }): { text: string; added?: TeamAgent } {
|
||||||
|
const agent = a.agent ?? "general"
|
||||||
|
const r = this.o.resolve?.(agent, a.model) ?? { model: a.model ?? this.team.get(as)?.model ?? this.team.model }
|
||||||
|
// The team's refusals first; an added member needs a known agent and model.
|
||||||
|
const dry = this.team.wouldRefuse(as, a)
|
||||||
|
if (dry) return { text: dry }
|
||||||
|
if ("error" in r) return { text: r.error }
|
||||||
|
const out = this.team.add(as, { ...a, agent, model: r.model })
|
||||||
|
this.changed()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private start(name: string, text: string) {
|
||||||
|
if (name === TEAM.names.lead) {
|
||||||
|
const waiter = this.leadWaiter
|
||||||
|
this.leadWaiter = undefined
|
||||||
|
if (waiter) {
|
||||||
|
// The person's turn was waiting: it goes now, and what woke it meanwhile goes in at its first step.
|
||||||
|
if (text) this.o.lead.note(text)
|
||||||
|
waiter()
|
||||||
|
} else if (text) this.o.lead.wake(text)
|
||||||
|
this.changed()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const known = this.members.get(name)
|
||||||
|
if (known) known.run(text)
|
||||||
|
else {
|
||||||
|
const m = this.team.get(name)
|
||||||
|
if (m) this.o.spawnMember(m, text)
|
||||||
|
}
|
||||||
|
this.changed()
|
||||||
|
}
|
||||||
|
|
||||||
|
private deliver(name: string, text: string) {
|
||||||
|
if (name === TEAM.names.lead) this.o.lead.note(text)
|
||||||
|
else this.members.get(name)?.engine.note(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopped(name: string) {
|
||||||
|
const h = this.members.get(name)
|
||||||
|
this.members.delete(name)
|
||||||
|
this.finals.delete(name)
|
||||||
|
h?.cancel()
|
||||||
|
this.changed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lead's turn is about to run: it holds a slot, or waits for one (the lead first). False
|
||||||
|
* when it was stopped while it waited. */
|
||||||
|
async beforeLeadTurn(signal: AbortSignal): Promise<boolean> {
|
||||||
|
const lead = this.team.get(TEAM.names.lead)!
|
||||||
|
if (lead.state === "working") return true
|
||||||
|
if (this.team.leadWants()) {
|
||||||
|
this.changed()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
this.o.bus.emit({ type: "notice", message: `waiting for a free slot: the model serves ${parallelFrom(this.team.capacityOf(this.team.model))} requests at once and the team's members hold them` })
|
||||||
|
this.o.lead.waiting?.(this.team.model)
|
||||||
|
this.changed()
|
||||||
|
return await new Promise<boolean>((resolve) => {
|
||||||
|
const onAbort = () => {
|
||||||
|
this.leadWaiter = undefined
|
||||||
|
this.team.leadIdle()
|
||||||
|
this.changed()
|
||||||
|
resolve(false)
|
||||||
|
}
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true })
|
||||||
|
this.leadWaiter = () => {
|
||||||
|
signal.removeEventListener("abort", onAbort)
|
||||||
|
resolve(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lead's turn ended. */
|
||||||
|
leadDone() {
|
||||||
|
this.team.finish(TEAM.names.lead)
|
||||||
|
this.changed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A member's turn ended: its reply is its report; messages it was given too late wake it again.
|
||||||
|
* Spec 3.4.0: one that ended by its own team_finish is let go; one whose context filled gets its
|
||||||
|
* final turn first. */
|
||||||
|
memberDone(name: string, reply: string, reason: string, leftover: string[]) {
|
||||||
|
if (this.ended.delete(name)) {
|
||||||
|
this.members.delete(name)
|
||||||
|
this.changed()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const final = this.takeFinal(name)
|
||||||
|
if (final !== undefined && this.team.get(name)?.state === "paused") {
|
||||||
|
this.members.get(name)?.final?.(final)
|
||||||
|
this.changed()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.team.get(name)) return
|
||||||
|
this.team.finish(name, reply, reason)
|
||||||
|
if (leftover.length) this.team.undelivered(name, leftover)
|
||||||
|
this.changed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a call answers before its permission check, for the agent `as`. */
|
||||||
|
refuse(as: string, tool: Tool, args: unknown): string | undefined {
|
||||||
|
// A tool the spec does not have (an MCP server's) by the class its call carries: read when it
|
||||||
|
// says readOnlyHint, interact for the server's memory, else execute.
|
||||||
|
let risk: string | undefined
|
||||||
|
if (!TOOL_SPECS[tool.name]) {
|
||||||
|
try {
|
||||||
|
risk = tool.permission(args as never, {} as never).class
|
||||||
|
} catch {
|
||||||
|
risk = "execute"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.team.refusal(as, tool.name, args, risk)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The person stopped a member in the UI (/team stop). */
|
||||||
|
personStop(name: string): string[] {
|
||||||
|
const gone = this.team.personStop(name)
|
||||||
|
this.changed()
|
||||||
|
return gone
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop every member: the lead's session ends or is left. */
|
||||||
|
stopAll() {
|
||||||
|
for (const m of this.team.membersOf(TEAM.names.lead)) this.team.personStop(m.name)
|
||||||
|
this.team.forgetStopped()
|
||||||
|
for (const h of this.members.values()) h.cancel()
|
||||||
|
this.members.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A member's engine, made. */
|
||||||
|
attach(name: string, h: MemberHandle) {
|
||||||
|
this.members.set(name, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
handle(name: string): MemberHandle | undefined {
|
||||||
|
return this.members.get(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,990 @@
|
|||||||
|
// Teams (harness spec 3.2.0, harness/team.md; 3.4.0, harness/subagents.md §5–§13): the main agent
|
||||||
|
// (`lead`) and its subagents, their room, who receives what, the size the person allows, a
|
||||||
|
// manager's refusals, the slots of the model they run on — and from 3.4.0 the tree only the main
|
||||||
|
// agent builds, modes and request options, findings, and a subagent's end.
|
||||||
|
// This file is the rules alone, without an engine: the conformance area `team` drives it step by
|
||||||
|
// step, and the app (team/runtime.ts) drives it from real turns — the effects say when an agent's
|
||||||
|
// turn starts (it was given a slot), when a message reaches an agent that is working, and when an
|
||||||
|
// agent is stopped.
|
||||||
|
import { LOOP, TOOL_SPECS } from "../harness.ts"
|
||||||
|
import { fillPrompt } from "../prompt/fill.ts"
|
||||||
|
import { DEFAULT_RULES, evaluate, toRules } from "../permission/evaluate.ts"
|
||||||
|
import { BUILTIN_HARDLINE } from "../permission/hardline.ts"
|
||||||
|
import { duration } from "../duration.ts"
|
||||||
|
import type { Mode } from "../config/schema.ts"
|
||||||
|
import type { ToolClass } from "../permission/evaluate.ts"
|
||||||
|
import { checkMode, strictest, type Grant } from "./delegation.ts"
|
||||||
|
import type { Progress } from "../session/progress.ts"
|
||||||
|
|
||||||
|
export const TEAM = LOOP.team
|
||||||
|
const TEXTS = TEAM.texts
|
||||||
|
const LEAD = TEAM.names.lead
|
||||||
|
export const TEAM_TOOLS = ["team_add", "team_assign", "team_decide", "team_findings", "team_finish", "team_read", "team_replace", "team_request", "team_send", "team_stop", "team_whoami"] as const
|
||||||
|
/** Spec 3.4.0 (subagents.md §8): the main agent's alone. */
|
||||||
|
export const MAIN_ONLY = TEAM.main_only.tools as readonly string[]
|
||||||
|
/** The main agent's team tools, and a subagent's (with team_request only when it has request options). */
|
||||||
|
export const LEAD_TOOLS = ["team_add", "team_assign", "team_decide", "team_findings", "team_read", "team_replace", "team_send", "team_stop", "team_whoami"] as const
|
||||||
|
export const MEMBER_TOOLS = ["team_findings", "team_finish", "team_read", "team_send", "team_whoami"] as const
|
||||||
|
export function memberTools(a: { request: readonly string[] }): string[] {
|
||||||
|
return [...MEMBER_TOOLS, ...(a.request.length ? ["team_request"] : [])].sort()
|
||||||
|
}
|
||||||
|
const NAME = new RegExp(TEAM.names.pattern)
|
||||||
|
|
||||||
|
export type AgentState = "working" | "waiting" | "finished" | "idle" | "paused" | "done" | "out_of_context" | "stopped"
|
||||||
|
export type EndState = "done" | "out_of_context" | "stopped"
|
||||||
|
|
||||||
|
export interface Finding {
|
||||||
|
n: number
|
||||||
|
text: string
|
||||||
|
time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Request {
|
||||||
|
id: string
|
||||||
|
from: string
|
||||||
|
tool: string
|
||||||
|
pattern: string
|
||||||
|
why: string
|
||||||
|
open: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a model's server can do at once (models/schema.json capacity). */
|
||||||
|
export interface Capacity {
|
||||||
|
parallel?: number
|
||||||
|
single_session?: boolean
|
||||||
|
group?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many requests a model serves at once: `parallel`, else 1 for single_session, else no limit. */
|
||||||
|
export function parallelFrom(c: Capacity | undefined): number {
|
||||||
|
if (c && Number.isInteger(c.parallel) && c.parallel! >= 1) return c.parallel!
|
||||||
|
return c?.single_session ? 1 : Infinity
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamAgent {
|
||||||
|
/** `lead` for the main agent; s1, s2, … for subagents, never reused (spec 3.4.0). */
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
/** Its position (team_add's role). */
|
||||||
|
role: string
|
||||||
|
/** Its task, as given and as assigned since. */
|
||||||
|
task: string
|
||||||
|
mode: Mode
|
||||||
|
/** Request options (spec 3.4.0 §7). */
|
||||||
|
request: ToolClass[]
|
||||||
|
/** What the main agent allowed it (team_decide). */
|
||||||
|
grants: Grant[]
|
||||||
|
findings: Finding[]
|
||||||
|
/** Its context use, as its engine last said it. */
|
||||||
|
context?: { used: number; window: number }
|
||||||
|
/** Its current progress (progress.md). */
|
||||||
|
progress?: Progress | null
|
||||||
|
/** Who added it; null for the lead. */
|
||||||
|
manager: string | null
|
||||||
|
/** A sub-manager (team_add's `lead`), or the main agent: subagents may be placed under it. */
|
||||||
|
leads: boolean
|
||||||
|
/** The team's goal as it was told it (the lead's: the last one it gave). */
|
||||||
|
goal: string
|
||||||
|
/** The model it runs on (its ref). */
|
||||||
|
model: string
|
||||||
|
agent: string
|
||||||
|
state: AgentState
|
||||||
|
/** Messages that reached it while it was not working: its next turn's text. */
|
||||||
|
inbox: string[]
|
||||||
|
/** Its brief, until its first turn starts. */
|
||||||
|
brief?: string
|
||||||
|
/** When it began waiting for a slot (the queue's order). */
|
||||||
|
waitSince?: number
|
||||||
|
/** When its last turn ended. */
|
||||||
|
ended?: number
|
||||||
|
/** Added in this order (tree order among siblings). */
|
||||||
|
seq: number
|
||||||
|
/** How it ended, and when (an ended subagent stays in the tree's `ended`). */
|
||||||
|
endState?: EndState
|
||||||
|
endedAt?: number
|
||||||
|
/** The sub-manager it was under before that one ended (team_replace puts it back). */
|
||||||
|
movedFrom?: string
|
||||||
|
/** The device's job that runs it (its transcript, read-only). */
|
||||||
|
jobId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomMessage {
|
||||||
|
n: number
|
||||||
|
from: string
|
||||||
|
/** As addressed: names, or ["all"]. */
|
||||||
|
to: string[]
|
||||||
|
recipients: string[]
|
||||||
|
text: string
|
||||||
|
time: number
|
||||||
|
report: boolean
|
||||||
|
/** Spec 3.4.0: what it is — a message, a report, an assignment, a request, a decision. */
|
||||||
|
kind: RoomKind
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoomKind = "message" | "report" | "assignment" | "request" | "decision"
|
||||||
|
|
||||||
|
/** A row of the tree (subagents.md §12). */
|
||||||
|
export interface TreeAgent {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
position: string
|
||||||
|
parent: string | null
|
||||||
|
state: AgentState
|
||||||
|
model: string
|
||||||
|
mode: Mode
|
||||||
|
request: string[]
|
||||||
|
manager: boolean
|
||||||
|
task?: string
|
||||||
|
context?: { used: number; window: number }
|
||||||
|
progress: Progress | null
|
||||||
|
findings: number
|
||||||
|
messages: number
|
||||||
|
ended?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeInfo {
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
size: number
|
||||||
|
open_slots: number
|
||||||
|
agents: TreeAgent[]
|
||||||
|
ended: TreeAgent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamEffects {
|
||||||
|
/** The agent was given a slot: its turn starts now with `text` (a member's brief, or the messages
|
||||||
|
* that woke it; for the lead, what woke it — "" when the person's own message did). */
|
||||||
|
start(name: string, text: string): void
|
||||||
|
/** A message to an agent whose turn runs: at its next step boundary. */
|
||||||
|
deliver(name: string, text: string): void
|
||||||
|
/** It left the team: its turn stops where it is, its background commands with it. */
|
||||||
|
stop(name: string): void
|
||||||
|
/** Spec 3.4.0: it ended by its own team_finish — its turn stops after that call. */
|
||||||
|
end?(name: string): void
|
||||||
|
/** Spec 3.4.0: its context is full — its turn stops after this step, and its one final turn
|
||||||
|
* starts with `text`. */
|
||||||
|
final?(name: string, text: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamOptions {
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
cap?: number
|
||||||
|
/** Subagents are on for the session (config `subagents`). */
|
||||||
|
subagents?: boolean
|
||||||
|
/** 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
|
||||||
|
now?: () => number
|
||||||
|
/** The lead's state as the team begins: working (its first prompt runs) or idle. */
|
||||||
|
leadState?: "working" | "idle"
|
||||||
|
/** The main agent's mode (the session's). Default: auto. */
|
||||||
|
mode?: Mode
|
||||||
|
/** Steps a turn may take: the main agent's and a subagent's (for team_whoami). */
|
||||||
|
steps?: { main: number; sub: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The default rules' answer for a command in manual mode, with no rules of the person's. */
|
||||||
|
export function readingBashByDefault(command: string, root = "/project"): boolean {
|
||||||
|
const req = { permission: "bash", class: "execute" as const, patterns: [command], command, paths: [root] }
|
||||||
|
return evaluate(req, { mode: "manual", rules: toRules(DEFAULT_RULES, "default"), hardline: BUILTIN_HARDLINE, root, planDir: `${root}/.agent/plans`, projectDir: `${root}/.agent` }).action === "allow"
|
||||||
|
}
|
||||||
|
|
||||||
|
const fill = (text: string, vars: Record<string, string | number>) => fillPrompt(text, Object.fromEntries(Object.entries(vars).map(([k, v]) => [k, String(v)])))
|
||||||
|
|
||||||
|
export class Team {
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
readonly cap: number
|
||||||
|
subagents: boolean
|
||||||
|
/** The model the chat runs on (the lead's). */
|
||||||
|
model: string
|
||||||
|
private agents = new Map<string, TeamAgent>()
|
||||||
|
room: RoomMessage[] = []
|
||||||
|
/** `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 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. */
|
||||||
|
private told: { min: number; max: number } | undefined
|
||||||
|
/** Members the person stopped, not yet said to the lead. */
|
||||||
|
private stoppedByPerson: string[] = []
|
||||||
|
private waits = 0
|
||||||
|
/** Subagents that ended (stopped, done, out of context), in the order they ended. */
|
||||||
|
ended: TeamAgent[] = []
|
||||||
|
private idSeq = 0
|
||||||
|
private reqSeq = 0
|
||||||
|
requests: Request[] = []
|
||||||
|
|
||||||
|
constructor(private o: TeamOptions) {
|
||||||
|
this.cap = o.cap ?? TEAM.agents.cap
|
||||||
|
this.subagents = o.subagents !== false
|
||||||
|
this.model = o.model
|
||||||
|
this.min = TEAM.agents.min
|
||||||
|
this.max = TEAM.agents.max
|
||||||
|
this.setSetting(o.min ?? TEAM.agents.min, o.max ?? TEAM.agents.max)
|
||||||
|
this.agents.set(LEAD, { id: LEAD, name: LEAD, role: TEAM.read.lead_role, task: "", mode: o.mode ?? "auto", request: [], grants: [], findings: [], manager: null, leads: true, goal: "", model: o.model, agent: "", state: o.leadState ?? "working", inbox: [], seq: this.seq++ })
|
||||||
|
this.fix()
|
||||||
|
}
|
||||||
|
|
||||||
|
set effects(e: TeamEffects | undefined) {
|
||||||
|
this.o.effects = e
|
||||||
|
}
|
||||||
|
|
||||||
|
private now() {
|
||||||
|
return (this.o.now ?? Date.now)()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the setting ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A stored setting under the cap is read as max = min(max, cap), min = min(min, max). */
|
||||||
|
setSetting(min: number, max: number) {
|
||||||
|
this.max = Math.max(1, Math.min(Math.floor(max), this.cap))
|
||||||
|
this.min = Math.max(1, Math.min(Math.floor(min), this.max))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 `Subagents: 1 ≤ Min ≤ Max ≤ ${cap} (the cap), whole numbers, this agent included; Min ${min}, Max ${max} is outside`
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
capacityOf(model: string): Capacity {
|
||||||
|
return this.o.capacity(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many requests the lead's model serves at once now. */
|
||||||
|
parallelNow(): number {
|
||||||
|
return parallelFrom(this.o.capacity(this.model))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.couldServe().any)
|
||||||
|
return this.offered
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the tree ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
get(name: string): TeamAgent | undefined {
|
||||||
|
return this.agents.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every agent, in tree order: an agent, then its members in the order added, depth first. */
|
||||||
|
tree(): TeamAgent[] {
|
||||||
|
const out: TeamAgent[] = []
|
||||||
|
const walk = (a: TeamAgent) => {
|
||||||
|
out.push(a)
|
||||||
|
for (const m of this.membersOf(a.name)) walk(m)
|
||||||
|
}
|
||||||
|
walk(this.agents.get(LEAD)!)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
membersOf(name: string): TeamAgent[] {
|
||||||
|
return [...this.agents.values()].filter((a) => a.manager === name).sort((a, b) => a.seq - b.seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The agent and everything under it, in tree order. */
|
||||||
|
subtree(name: string): TeamAgent[] {
|
||||||
|
const a = this.agents.get(name)
|
||||||
|
if (!a) return []
|
||||||
|
return [a, ...this.membersOf(name).flatMap((m) => this.subtree(m.name))]
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMembers(): boolean {
|
||||||
|
return this.agents.size > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §3: the lead, every member, and every running `task` subagent. */
|
||||||
|
size(): number {
|
||||||
|
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. */
|
||||||
|
isManager(name: string): boolean {
|
||||||
|
if (this.membersOf(name).length) return true
|
||||||
|
return name === LEAD && this.offered && this.min >= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── what a call answers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** §2: the team tools' requirement `team`; the text a call answers while it is not met. */
|
||||||
|
unavailable(): string | undefined {
|
||||||
|
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). */
|
||||||
|
taskRefusal(): string | undefined {
|
||||||
|
if (!this.offered) return undefined
|
||||||
|
if (this.max === 1 && !this.hasMembers()) return TEAM.unavailable.one_agent
|
||||||
|
if (this.size() + 1 > this.max) return fill(TEXTS.full, { max: this.max })
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §6: whether a call is work a manager gives away. `risk`: the tool's class where the spec does
|
||||||
|
* not have the tool (an MCP tool: read when it says readOnlyHint, else execute). */
|
||||||
|
isWork(tool: string, args: unknown, risk?: string): boolean {
|
||||||
|
if ((TEAM.work.kept as string[]).includes(tool)) return false
|
||||||
|
const r = TOOL_SPECS[tool]?.risk ?? risk ?? "execute"
|
||||||
|
if (r !== "write" && r !== "execute") return false
|
||||||
|
if (tool === "bash") {
|
||||||
|
const command = (args as { command?: unknown } | undefined)?.command
|
||||||
|
return !(typeof command === "string" && (this.o.readingBash ?? readingBashByDefault)(command))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a call by `by` answers before it runs — the team tools' requirement, task's size, a
|
||||||
|
* manager's work — or undefined when it goes on to its own checks (permission first). */
|
||||||
|
refusal(by: string, tool: string, args: unknown, risk?: string): string | undefined {
|
||||||
|
if ((TEAM_TOOLS as readonly string[]).includes(tool)) {
|
||||||
|
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||||
|
if (no) return no
|
||||||
|
// Spec 3.4.0 (subagents.md §6): only the main agent adds, assigns, replaces, stops, decides.
|
||||||
|
if (by !== LEAD && MAIN_ONLY.includes(tool)) return TEXTS.only_main
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (tool === "task") return by !== LEAD ? TEXTS.only_main : this.taskRefusal()
|
||||||
|
if (this.isManager(by) && this.isWork(tool, args, risk)) return TEXTS.manager
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── slots ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private busyOn(model: string): number {
|
||||||
|
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.tasks) if (h.model !== model && this.o.capacity(h.model).group === group) return false
|
||||||
|
}
|
||||||
|
return this.busyOn(model) < parallelFrom(this.o.capacity(model))
|
||||||
|
}
|
||||||
|
|
||||||
|
private want(a: TeamAgent) {
|
||||||
|
if (a.state === "working" || a.state === "waiting") return
|
||||||
|
a.state = "waiting"
|
||||||
|
a.waitSince = this.waits++
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §8: waiting agents start in order — the lead first, then as they began waiting. */
|
||||||
|
private fillSlots() {
|
||||||
|
const queue = [...this.agents.values()].filter((a) => a.state === "waiting").sort((a, b) => Number(b.name === LEAD) - Number(a.name === LEAD) || a.waitSince! - b.waitSince!)
|
||||||
|
for (const a of queue) {
|
||||||
|
if (!this.free(a.model)) continue
|
||||||
|
a.state = "working"
|
||||||
|
a.waitSince = undefined
|
||||||
|
const text = a.brief ?? a.inbox.join("\n\n")
|
||||||
|
a.brief = undefined
|
||||||
|
a.inbox = []
|
||||||
|
this.o.effects?.start(a.name, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The person's message to the lead: it works, or waits for a slot (§8). True when it works. */
|
||||||
|
leadWants(): boolean {
|
||||||
|
const lead = this.agents.get(LEAD)!
|
||||||
|
this.want(lead)
|
||||||
|
this.fillSlots()
|
||||||
|
return lead.state === "working"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
|
||||||
|
taskDone(id: number) {
|
||||||
|
this.tasks = this.tasks.filter((h) => h.id !== id)
|
||||||
|
this.fillSlots()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** §7: to a working agent at its next step; otherwise it wakes (and may wait for a slot). */
|
||||||
|
private deliver(name: string, text: string) {
|
||||||
|
const a = this.agents.get(name)
|
||||||
|
if (!a) return
|
||||||
|
// Spec 3.4.0 §9: a paused subagent is given nothing new; the message stays in the room.
|
||||||
|
if (a.state === "paused") return
|
||||||
|
this.delivered.push({ name, text })
|
||||||
|
if (a.state === "working") return this.o.effects?.deliver(name, text)
|
||||||
|
a.inbox.push(text)
|
||||||
|
this.want(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What was delivered, to whom, since this was last read (for a front end and the conformance). */
|
||||||
|
delivered: { name: string; text: string }[] = []
|
||||||
|
|
||||||
|
private post(from: string, to: string[], recipients: string[], text: string, report: boolean, kind: RoomKind = report ? "report" : "message", id = ""): RoomMessage {
|
||||||
|
const m: RoomMessage = { n: this.room.length + 1, from, to, recipients, text, time: this.now(), report, kind }
|
||||||
|
this.room.push(m)
|
||||||
|
const marks = TEAM.marks as Record<string, string>
|
||||||
|
const mark = fill(marks[kind]!, { from, to: to.join(", "), id })
|
||||||
|
for (const r of recipients) this.deliver(r, `${mark}\n${text}`)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the tools ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** team_add's refusals, in the spec's order (§4), or undefined when it would add. */
|
||||||
|
wouldRefuse(by: string, a: { name: string; goal?: string; parent?: string; mode?: Mode; request?: string[] }): string | undefined {
|
||||||
|
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||||
|
if (no) return no
|
||||||
|
if (by !== LEAD) return TEXTS.only_main
|
||||||
|
if (a.name === "all") return TEXTS.all_name
|
||||||
|
if (!NAME.test(a.name)) return fill(TEXTS.bad_name, { name: a.name })
|
||||||
|
if (this.agents.has(a.name) || a.name === TEAM.names.parent) return fill(TEXTS.taken, { name: a.name })
|
||||||
|
if (!this.agents.get(LEAD)!.goal && !a.goal?.trim()) return TEXTS.no_goal
|
||||||
|
if (this.size() + 1 > this.max) return fill(TEXTS.full, { max: this.max })
|
||||||
|
return this.placeRefusal(a.parent, a.mode, a.request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spec 3.4.0 (§6, §7): `parent` must be the lead or a sub-manager; the mode no looser than its. */
|
||||||
|
private placeRefusal(parent: string | undefined, mode: Mode | undefined, request: string[] | undefined): string | undefined {
|
||||||
|
const p = this.agents.get(parent ?? LEAD)
|
||||||
|
if (!p) return fill(TEXTS.unknown, { name: parent!, names: this.names() })
|
||||||
|
if (!p.leads) return fill(TEXTS.not_manager, { name: p.name })
|
||||||
|
const m = checkMode(p.mode, mode, request)
|
||||||
|
return "error" in m ? m.error : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_add (§4). `brief` is what the member's first turn is given. */
|
||||||
|
add(by: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string; parent?: string; mode?: Mode; request?: string[] }): { text: string; added?: TeamAgent } {
|
||||||
|
const no = this.wouldRefuse(by, a)
|
||||||
|
if (no) return { text: no }
|
||||||
|
const lead = this.agents.get(LEAD)!
|
||||||
|
const goal = a.goal?.trim()
|
||||||
|
const parent = this.agents.get(a.parent ?? LEAD)!
|
||||||
|
if (goal) lead.goal = goal
|
||||||
|
const placed = checkMode(parent.mode, a.mode, a.request) as { mode: Mode; request: ToolClass[] }
|
||||||
|
const member: TeamAgent = {
|
||||||
|
id: `${TEAM.names.id_prefix}${++this.idSeq}`,
|
||||||
|
name: a.name,
|
||||||
|
role: a.role.trim().split("\n")[0]!,
|
||||||
|
task: a.task,
|
||||||
|
mode: placed.mode,
|
||||||
|
request: placed.request,
|
||||||
|
grants: [],
|
||||||
|
findings: [],
|
||||||
|
manager: parent.name,
|
||||||
|
leads: a.lead === true,
|
||||||
|
goal: goal || parent.goal || lead.goal,
|
||||||
|
model: a.model ?? lead.model,
|
||||||
|
agent: a.agent ?? "general",
|
||||||
|
state: "finished",
|
||||||
|
inbox: [],
|
||||||
|
brief: a.task,
|
||||||
|
seq: this.seq++,
|
||||||
|
}
|
||||||
|
this.agents.set(member.name, member)
|
||||||
|
this.want(member)
|
||||||
|
this.fillSlots()
|
||||||
|
const vars = { name: member.name, role: member.role, size: this.size(), max: this.max, parallel: parallelFrom(this.o.capacity(member.model)), parent: parent.name }
|
||||||
|
// Spec 3.5.0: placed under a sub-manager, its report goes there — said so, not "to you".
|
||||||
|
const under = parent.name !== LEAD
|
||||||
|
const text = member.state === "working" ? (under ? TEXTS.added_under : TEXTS.added) : under ? TEXTS.added_waiting_under : TEXTS.added_waiting
|
||||||
|
return { text: fill(text, vars), added: member }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_send (§5). */
|
||||||
|
send(by: string, a: { to: string[] | string; message: string }): string {
|
||||||
|
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||||
|
if (no) return no
|
||||||
|
let list = [...new Set(typeof a.to === "string" ? [a.to] : a.to)]
|
||||||
|
// Spec 3.4.0: `parent` is the sender's parent.
|
||||||
|
if (list.includes(TEAM.names.parent)) {
|
||||||
|
const mgr = this.agents.get(by)?.manager
|
||||||
|
if (!mgr) return TEXTS.no_parent
|
||||||
|
list = [...new Set(list.map((n) => (n === TEAM.names.parent ? mgr : n)))]
|
||||||
|
}
|
||||||
|
const all = list.includes("all")
|
||||||
|
const others = [...this.agents.keys()].filter((n) => n !== by)
|
||||||
|
if (all) {
|
||||||
|
if (!others.length) return TEXTS.nobody
|
||||||
|
const recipients = this.tree().map((x) => x.name).filter((n) => n !== by)
|
||||||
|
this.post(by, ["all"], recipients, a.message, false)
|
||||||
|
this.fillSlots()
|
||||||
|
return fill(TEXTS.sent, { to: "all" })
|
||||||
|
}
|
||||||
|
const unknown = list.find((n) => !this.agents.has(n))
|
||||||
|
if (unknown !== undefined) return fill(TEXTS.unknown, { name: unknown, names: this.names() })
|
||||||
|
if (list.includes(by)) return TEXTS.self
|
||||||
|
this.post(by, list, list, a.message, false)
|
||||||
|
this.fillSlots()
|
||||||
|
return fill(TEXTS.sent, { to: list.join(", ") })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A member's `report`: a message to its manager before its final reply (§4). */
|
||||||
|
message(by: string, text: string): string | undefined {
|
||||||
|
const a = this.agents.get(by)
|
||||||
|
if (!a?.manager) return undefined
|
||||||
|
this.post(by, [a.manager], [a.manager], text, false)
|
||||||
|
this.fillSlots()
|
||||||
|
return fill(TEXTS.sent, { to: a.manager })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who may see a room message (§5): the lead every one; another agent those whose sender or a
|
||||||
|
* recipient is in its subtree. */
|
||||||
|
visible(by: string, m: RoomMessage): boolean {
|
||||||
|
if (by === LEAD) return true
|
||||||
|
const mine = new Set(this.subtree(by).map((a) => a.name))
|
||||||
|
return mine.has(m.from) || m.recipients.some((r) => mine.has(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_read (§5): the messages above `since` the caller may see, at most read_max. */
|
||||||
|
readRoom(by: string, since = 0): { messages: RoomMessage[]; more: boolean } {
|
||||||
|
const all = this.room.filter((m) => m.n > since && this.visible(by, m))
|
||||||
|
return { messages: all.slice(0, TEAM.read_max), more: all.length > TEAM.read_max }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_read's answer as text (loop.json team.read). */
|
||||||
|
read(by: string, since = 0): string {
|
||||||
|
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||||
|
if (no) return no
|
||||||
|
const R = TEAM.read
|
||||||
|
const lines = [fill(R.tree, { size: this.size(), max: this.max })]
|
||||||
|
const depth = (a: TeamAgent): number => (a.manager ? 1 + depth(this.agents.get(a.manager)!) : 0)
|
||||||
|
for (const a of this.tree())
|
||||||
|
lines.push(
|
||||||
|
fill(R.agent, {
|
||||||
|
indent: " ".repeat(depth(a)),
|
||||||
|
name: a.name,
|
||||||
|
id: a.id,
|
||||||
|
role: a.role,
|
||||||
|
state: R.states[a.state],
|
||||||
|
model: a.model,
|
||||||
|
mode: a.request.length ? `${a.mode} ${fill(R.request, { kinds: a.request.join(", ") })}` : a.mode,
|
||||||
|
context: a.context && a.context.window > 0 ? fill(R.context, { percent: Math.floor((a.context.used * 100) / a.context.window) }) : "—",
|
||||||
|
last: a.state === "working" ? R.active : a.ended !== undefined ? `${duration(this.now() - a.ended)} ago` : "—",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const open = this.openSlots()
|
||||||
|
if (open) lines.push(fill(R.slots, { n: open }))
|
||||||
|
if (this.ended.length) {
|
||||||
|
lines.push(R.ended)
|
||||||
|
for (const a of this.ended) lines.push(fill(R.ended_agent, { name: a.name, id: a.id, role: a.role, state: R.states[a.state], findings: a.findings.length }))
|
||||||
|
}
|
||||||
|
const { messages, more } = this.readRoom(by, since)
|
||||||
|
lines.push("")
|
||||||
|
if (!messages.length) lines.push(fill(R.none, { since }))
|
||||||
|
else {
|
||||||
|
lines.push(fill(R.room, { since }))
|
||||||
|
for (const m of messages) {
|
||||||
|
const [first, ...rest] = m.text.split("\n")
|
||||||
|
const text = [first ?? "", ...rest.map((l) => ` ${l}`)].join("\n")
|
||||||
|
lines.push(fill(m.report ? R.report : R.message, { n: m.n, from: m.from, to: m.to.join(", "), text }))
|
||||||
|
}
|
||||||
|
if (more) lines.push(fill(R.more, { last: messages[messages.length - 1]!.n }))
|
||||||
|
}
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_stop (§9). */
|
||||||
|
stopCmd(by: string, name: string): string {
|
||||||
|
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||||
|
if (no) return no
|
||||||
|
if (by !== LEAD) return TEXTS.only_main
|
||||||
|
if (name === "all") {
|
||||||
|
const under = this.membersOf(by)
|
||||||
|
if (!under.length) return TEXTS.nobody_to_stop
|
||||||
|
const gone = under.flatMap((m) => this.subtree(m.name))
|
||||||
|
this.remove(gone)
|
||||||
|
return fill(TEXTS.stopped, { names: gone.map((a) => a.name).join(", "), size: this.size(), max: this.max })
|
||||||
|
}
|
||||||
|
if (!this.agents.has(name)) return fill(TEXTS.unknown, { name, names: this.names() })
|
||||||
|
if (name === by || !this.subtree(by).some((a) => a.name === name)) return fill(TEXTS.not_yours, { name })
|
||||||
|
const gone = this.subtree(name)
|
||||||
|
this.remove(gone)
|
||||||
|
return fill(TEXTS.stopped, { names: gone.map((a) => a.name).join(", "), size: this.size(), max: this.max })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The person stopped a member in the UI: it and its subtree leave; the lead is told in a note. */
|
||||||
|
personStop(name: string): string[] {
|
||||||
|
if (name === LEAD || !this.agents.has(name)) return []
|
||||||
|
const gone = this.subtree(name)
|
||||||
|
this.remove(gone)
|
||||||
|
this.stoppedByPerson.push(...gone.map((a) => a.name))
|
||||||
|
return gone.map((a) => a.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private remove(gone: TeamAgent[], end: EndState = "stopped") {
|
||||||
|
for (const a of gone) {
|
||||||
|
this.agents.delete(a.name)
|
||||||
|
a.state = end
|
||||||
|
a.endState = end
|
||||||
|
a.endedAt = this.now()
|
||||||
|
a.inbox = []
|
||||||
|
a.brief = undefined
|
||||||
|
this.ended.push(a)
|
||||||
|
for (const r of this.requests) if (r.from === a.name) r.open = false
|
||||||
|
if (end === "done") this.o.effects?.end?.(a.name)
|
||||||
|
else if (end === "stopped") this.o.effects?.stop(a.name)
|
||||||
|
// Spec 3.4.0: the subagents under one that ended by itself move under its parent until the
|
||||||
|
// main agent replaces it (team_replace moves them back under the replacement).
|
||||||
|
if (end !== "stopped")
|
||||||
|
for (const c of this.agents.values())
|
||||||
|
if (c.manager === a.name) {
|
||||||
|
c.manager = a.manager ?? LEAD
|
||||||
|
c.movedFrom = a.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.fillSlots()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An agent's turn ended (§5): a member's final reply is its report to its manager. `reason`:
|
||||||
|
* stop, steps, budget, or `error — <text>`. */
|
||||||
|
finish(name: string, reply = "", reason = "stop") {
|
||||||
|
const a = this.agents.get(name)
|
||||||
|
if (!a) return
|
||||||
|
// Spec 3.4.0 §9: the final turn after its context filled has ended — it is out of context.
|
||||||
|
if (a.state === "paused") {
|
||||||
|
const gone = fill(TEXTS.gone, { name: a.name, id: a.id })
|
||||||
|
const text = reply.trim() ? `${gone}\n\n${reply.trim()}` : gone
|
||||||
|
if (a.manager) this.post(name, [a.manager], [a.manager], text, true)
|
||||||
|
this.remove([a], "out_of_context")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (a.state !== "working") return
|
||||||
|
a.state = name === LEAD ? "idle" : "finished"
|
||||||
|
a.ended = this.now()
|
||||||
|
if (a.manager) {
|
||||||
|
let text = reply.trim() || TEXTS.no_report
|
||||||
|
if (reason !== "stop") text += `\n\n${fill(TEXTS.ended, { reason })}`
|
||||||
|
this.post(name, [a.manager], [a.manager], text, true)
|
||||||
|
}
|
||||||
|
// Messages it was sent at its last step, too late for it: they wake it again.
|
||||||
|
if (a.inbox.length) this.want(a)
|
||||||
|
this.fillSlots()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Messages delivered to a working agent that its turn ended before taking in: back to its
|
||||||
|
* inbox, so they wake it. */
|
||||||
|
undelivered(name: string, texts: string[]) {
|
||||||
|
const a = this.agents.get(name)
|
||||||
|
if (!a || !texts.length) return
|
||||||
|
a.inbox.push(...texts)
|
||||||
|
this.want(a)
|
||||||
|
this.fillSlots()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lead stopped waiting (the person pressed Esc): idle, out of the queue. */
|
||||||
|
leadIdle() {
|
||||||
|
const lead = this.agents.get(LEAD)!
|
||||||
|
if (lead.state === "waiting") lead.state = "idle"
|
||||||
|
lead.waitSince = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nothing of the stopped members is said: the session they were in is gone. */
|
||||||
|
forgetStopped() {
|
||||||
|
this.stoppedByPerson = []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── spec 3.4.0: the main agent's tree (subagents.md §5–§13) ─────────────────────
|
||||||
|
|
||||||
|
/** A subagent by name (live first, else the latest that ended) or by id. */
|
||||||
|
find(nameOrId: string): TeamAgent | undefined {
|
||||||
|
return this.agents.get(nameOrId) ?? [...this.agents.values()].find((a) => a.id === nameOrId) ?? [...this.ended].reverse().find((a) => a.name === nameOrId || a.id === nameOrId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open slots (§6): min − 1 places for subagents not added yet, while min ≥ 2 and offered. */
|
||||||
|
openSlots(): number {
|
||||||
|
if (!this.offered || this.min < 2) return 0
|
||||||
|
return Math.max(0, this.min - 1 - (this.agents.size - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The main agent's mode changed: stricter reaches down the tree, looser changes nobody (§7). */
|
||||||
|
setMainMode(mode: Mode) {
|
||||||
|
this.agents.get(LEAD)!.mode = mode
|
||||||
|
for (const a of this.tree()) if (a.manager) a.mode = strictest(a.mode, this.agents.get(a.manager)?.mode ?? mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Its engine's context use, and its progress, as last said. */
|
||||||
|
setContext(name: string, used: number, window: number) {
|
||||||
|
const a = this.agents.get(name)
|
||||||
|
if (a) a.context = { used, window }
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress(name: string, p: Progress | null) {
|
||||||
|
const a = this.agents.get(name)
|
||||||
|
if (a) a.progress = p
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_whoami (§8). */
|
||||||
|
whoami(by: string): string {
|
||||||
|
const a = this.agents.get(by)
|
||||||
|
if (!a) return fill(TEXTS.unknown, { name: by, names: this.names() })
|
||||||
|
const W = TEAM.whoami.lines
|
||||||
|
const steps = by === LEAD ? (this.o.steps?.main ?? LOOP.steps.default) : (this.o.steps?.sub ?? LOOP.jobs.subagent_steps.default)
|
||||||
|
const lines = [fill(W.who, { name: a.name, id: a.id, role: a.role })]
|
||||||
|
if (a.manager) lines.push(fill(W.parent, { parent: a.manager }))
|
||||||
|
if (a.manager) lines.push(fill(W.task, { task: a.task.split("\n")[0]! }))
|
||||||
|
lines.push(fill(W.mode, { mode: a.mode }))
|
||||||
|
if (a.request.length) lines.push(fill(W.request, { kinds: a.request.join(", ") }))
|
||||||
|
lines.push(fill(W.model, { model: a.model }))
|
||||||
|
lines.push(fill(W.limits, { size: this.size(), max: this.max, steps, spawn: by === LEAD ? W.spawn_yes : W.spawn_no }))
|
||||||
|
if (a.context && a.context.window > 0) lines.push(fill(W.context, { used: a.context.used, window: a.context.window, percent: Math.floor((a.context.used * 100) / a.context.window) }))
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_findings write (§8). */
|
||||||
|
noteFinding(by: string, text: string): string {
|
||||||
|
const a = this.agents.get(by)
|
||||||
|
if (!a) return fill(TEXTS.unknown, { name: by, names: this.names() })
|
||||||
|
const n = a.findings.length + 1
|
||||||
|
a.findings.push({ n, text: text.trim(), time: this.now() })
|
||||||
|
return fill(TEXTS.noted, { n })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `by` may read `target`'s findings: its own, one in its subtree, or the lead anyone's. */
|
||||||
|
private mayRead(by: string, target: TeamAgent): boolean {
|
||||||
|
if (by === LEAD || target.name === by) return true
|
||||||
|
let up: string | null | undefined = target.endState ? (target.movedFrom ?? target.manager) : target.manager
|
||||||
|
for (let guard = 0; up && guard < 1000; guard++) {
|
||||||
|
if (up === by) return true
|
||||||
|
up = this.agents.get(up)?.manager ?? this.find(up)?.manager
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_findings read (§8). */
|
||||||
|
readFindings(by: string, of?: string): string {
|
||||||
|
const target = of ? this.find(of) : this.agents.get(by)
|
||||||
|
if (!target) return fill(TEXTS.unknown, { name: of ?? by, names: this.names() })
|
||||||
|
if (!this.mayRead(by, target)) return TEXTS.not_yours_findings
|
||||||
|
const lines = [fill(TEAM.findings.header, { name: target.name, id: target.id })]
|
||||||
|
if (!target.findings.length) lines.push(TEXTS.no_findings)
|
||||||
|
for (const f of target.findings) lines.push(fill(TEAM.findings.item, { n: f.n, text: f.text }))
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_finish (§8): the report to its parent, and it ends (done). */
|
||||||
|
finishTask(by: string, report: string): string {
|
||||||
|
const a = this.agents.get(by)
|
||||||
|
if (!a || !a.manager) return TEXTS.no_parent
|
||||||
|
this.post(by, [a.manager], [a.manager], report.trim() || TEXTS.no_report, true)
|
||||||
|
const to = a.manager
|
||||||
|
this.remove([a], "done")
|
||||||
|
return fill(TEXTS.finished, { to })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Its context is full (§9): paused, and given one final turn. The text of that turn. */
|
||||||
|
pause(name: string): string | undefined {
|
||||||
|
const a = this.agents.get(name)
|
||||||
|
if (!a || a.name === LEAD || a.state === "paused") return undefined
|
||||||
|
a.state = "paused"
|
||||||
|
a.inbox = []
|
||||||
|
const text = `${fill(TEAM.marks.context_full, { to: name })}\n${fill(TEXTS.context_full, { steps: TEAM.context.final_steps })}`
|
||||||
|
this.delivered.push({ name, text })
|
||||||
|
this.o.effects?.final?.(name, text)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_assign (§8). */
|
||||||
|
assign(by: string, a: { name: string; task: string; role?: string; parent?: string; mode?: Mode }): string {
|
||||||
|
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||||
|
if (no) return no
|
||||||
|
if (by !== LEAD) return TEXTS.only_main
|
||||||
|
const t = this.agents.get(a.name)
|
||||||
|
if (!t || t.name === LEAD) return this.find(a.name)?.endState ? fill(TEXTS.has_ended, { name: a.name }) : fill(TEXTS.unknown, { name: a.name, names: this.names() })
|
||||||
|
const parentName = a.parent ?? t.manager!
|
||||||
|
const p = this.agents.get(parentName)
|
||||||
|
if (!p) return fill(TEXTS.unknown, { name: parentName, names: this.names() })
|
||||||
|
if (!p.leads) return fill(TEXTS.not_manager, { name: p.name })
|
||||||
|
if (this.subtree(t.name).some((x) => x.name === p.name)) return fill(TEXTS.cycle, { name: t.name, parent: p.name })
|
||||||
|
let mode = strictest(t.mode, p.mode)
|
||||||
|
let request = t.request
|
||||||
|
if (a.mode) {
|
||||||
|
const m = checkMode(p.mode, a.mode, t.request.length && a.mode !== p.mode ? t.request : undefined)
|
||||||
|
if ("error" in m) return m.error
|
||||||
|
mode = m.mode
|
||||||
|
request = m.mode === p.mode ? [] : t.request
|
||||||
|
}
|
||||||
|
t.task = a.task
|
||||||
|
if (a.role?.trim()) t.role = a.role.trim().split("\n")[0]!
|
||||||
|
t.manager = p.name
|
||||||
|
t.movedFrom = undefined
|
||||||
|
t.mode = mode
|
||||||
|
t.request = request
|
||||||
|
for (const c of this.subtree(t.name).slice(1)) c.mode = strictest(c.mode, this.agents.get(c.manager!)!.mode)
|
||||||
|
this.post(LEAD, [t.name], [t.name], a.task, false, "assignment")
|
||||||
|
this.fillSlots()
|
||||||
|
return fill(TEXTS.assigned, { name: t.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_replace (§8): the same name, position, parent, mode and model; a new id; briefed with
|
||||||
|
* the task and the old one's findings. */
|
||||||
|
replace(by: string, a: { name: string; task?: string }): { text: string; added?: TeamAgent } {
|
||||||
|
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||||
|
if (no) return { text: no }
|
||||||
|
if (by !== LEAD) return { text: TEXTS.only_main }
|
||||||
|
const old = this.agents.get(a.name) ?? [...this.ended].reverse().find((x) => x.name === a.name)
|
||||||
|
if (!old || old.name === LEAD) return { text: fill(TEXTS.unknown, { name: a.name, names: this.names() }) }
|
||||||
|
const live = this.agents.get(a.name) === old
|
||||||
|
if (!live && this.size() + 1 > this.max) return { text: fill(TEXTS.full, { max: this.max }) }
|
||||||
|
if (live) {
|
||||||
|
// It alone ends: the subagents under it move to the replacement.
|
||||||
|
for (const c of this.membersOf(old.name)) c.movedFrom = old.name
|
||||||
|
for (const c of this.membersOf(old.name)) c.manager = old.manager ?? LEAD
|
||||||
|
this.remove([old], "stopped")
|
||||||
|
}
|
||||||
|
const manager = old.movedFrom ?? old.manager
|
||||||
|
const parent = manager && this.agents.get(manager) ? manager : LEAD
|
||||||
|
const findings = old.findings.length ? old.findings.map((f) => fill(TEAM.findings.item, { n: f.n, text: f.text })).join("\n") : TEXTS.no_findings
|
||||||
|
const brief = fill(TEXTS.replace_brief, { task: a.task?.trim() || old.task, name: old.name, id: old.id, findings })
|
||||||
|
const p = this.agents.get(parent)!
|
||||||
|
const fresh: TeamAgent = {
|
||||||
|
...old,
|
||||||
|
id: `${TEAM.names.id_prefix}${++this.idSeq}`,
|
||||||
|
task: a.task?.trim() || old.task,
|
||||||
|
mode: strictest(old.mode, p.mode),
|
||||||
|
grants: [],
|
||||||
|
findings: [],
|
||||||
|
context: undefined,
|
||||||
|
progress: undefined,
|
||||||
|
manager: parent,
|
||||||
|
state: "finished",
|
||||||
|
inbox: [],
|
||||||
|
brief,
|
||||||
|
seq: this.seq++,
|
||||||
|
endState: undefined,
|
||||||
|
endedAt: undefined,
|
||||||
|
movedFrom: undefined,
|
||||||
|
ended: undefined,
|
||||||
|
waitSince: undefined,
|
||||||
|
jobId: undefined,
|
||||||
|
}
|
||||||
|
this.agents.set(fresh.name, fresh)
|
||||||
|
for (const c of this.agents.values())
|
||||||
|
if (c.movedFrom === old.name) {
|
||||||
|
c.manager = fresh.name
|
||||||
|
c.movedFrom = undefined
|
||||||
|
}
|
||||||
|
this.want(fresh)
|
||||||
|
this.fillSlots()
|
||||||
|
return { text: fill(TEXTS.replaced, { name: fresh.name, id: fresh.id, size: this.size(), max: this.max }), added: fresh }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_request (§7): `cls` is the tool's risk class. */
|
||||||
|
request(by: string, a: { tool: string; pattern: string; why: string }, cls: ToolClass | undefined): string {
|
||||||
|
const t = this.agents.get(by)
|
||||||
|
if (!t || !t.manager) return TEXTS.no_parent
|
||||||
|
if (!cls || !t.request.includes(cls)) return fill(TEXTS.not_requestable, { tool: a.tool, kinds: t.request.join(", ") || "none" })
|
||||||
|
const id = `${TEAM.request.id_prefix}${++this.reqSeq}`
|
||||||
|
this.requests.push({ id, from: by, tool: a.tool, pattern: a.pattern, why: a.why, open: true })
|
||||||
|
this.post(by, [LEAD], [LEAD], fill(TEXTS.request_body, { tool: a.tool, pattern: a.pattern, why: a.why }), false, "request", id)
|
||||||
|
this.fillSlots()
|
||||||
|
return fill(TEXTS.requested, { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_decide (§7): a grant for the subagent when allowed; it is told either way. `permission`:
|
||||||
|
* the tool's permission key. */
|
||||||
|
decide(by: string, a: { id: string; allow: boolean; note?: string }, permission?: (tool: string) => string | undefined): string {
|
||||||
|
if (by !== LEAD) return TEXTS.only_main
|
||||||
|
const r = this.requests.find((x) => x.id === a.id && x.open)
|
||||||
|
const t = r && this.agents.get(r.from)
|
||||||
|
if (!r || !t) return fill(TEXTS.no_request, { id: a.id })
|
||||||
|
r.open = false
|
||||||
|
if (a.allow) t.grants.push({ tool: r.tool, permission: permission?.(r.tool) ?? r.tool, pattern: r.pattern })
|
||||||
|
const head = fill(a.allow ? TEXTS.decision_allowed : TEXTS.decision_refused, { tool: r.tool, pattern: r.pattern })
|
||||||
|
this.post(LEAD, [t.name], [t.name], a.note?.trim() ? `${head}\n\n${a.note.trim()}` : head, false, "decision", r.id)
|
||||||
|
this.fillSlots()
|
||||||
|
return fill(TEXTS.decided, { decision: a.allow ? TEXTS.allowed : TEXTS.refused, id: r.id, name: t.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tree as clients show it (§12). */
|
||||||
|
info(): TreeInfo {
|
||||||
|
const row = (a: TeamAgent): TreeAgent => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
position: a.role,
|
||||||
|
parent: a.manager,
|
||||||
|
state: a.state,
|
||||||
|
model: a.model,
|
||||||
|
mode: a.mode,
|
||||||
|
request: [...a.request],
|
||||||
|
manager: a.name === LEAD ? true : a.leads,
|
||||||
|
...(a.manager ? { task: a.task } : {}),
|
||||||
|
...(a.context ? { context: { ...a.context } } : {}),
|
||||||
|
progress: a.progress ?? null,
|
||||||
|
findings: a.findings.length,
|
||||||
|
messages: this.room.filter((m) => m.from === a.name || m.recipients.includes(a.name)).length,
|
||||||
|
...(a.endedAt !== undefined ? { ended: a.endedAt } : {}),
|
||||||
|
})
|
||||||
|
return { min: this.min, max: this.max, size: this.size(), open_slots: this.openSlots(), agents: this.tree().map(row), ended: this.ended.map(row) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── what the lead is told ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** §10: the note's entries for the lead's next user message — the size when it was not said yet
|
||||||
|
* or changed (one block), and the members the person stopped (a block of its own). */
|
||||||
|
noteLines(): string[] {
|
||||||
|
if (!this.offered) return []
|
||||||
|
const N = LOOP.context.note
|
||||||
|
const out: string[] = []
|
||||||
|
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 })] : []), ...(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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A note said once already is said again after a rewind or a new prompt (compaction). */
|
||||||
|
forgetTold() {
|
||||||
|
this.told = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The team, in tree order: name, manager, state. */
|
||||||
|
state(): { name: string; manager: string | null; state: AgentState }[] {
|
||||||
|
return this.tree().map((a) => ({ name: a.name, manager: a.manager, state: a.state }))
|
||||||
|
}
|
||||||
|
|
||||||
|
private names(): string {
|
||||||
|
return this.tree()
|
||||||
|
.map((a) => a.name)
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Members working or waiting now (for a status line). */
|
||||||
|
active(): number {
|
||||||
|
return [...this.agents.values()].filter((a) => a.name !== LEAD && (a.state === "working" || a.state === "waiting")).length
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-2
@@ -17,7 +17,8 @@ function jobsOf(ctx: ToolContext) {
|
|||||||
function find(ctx: ToolContext, id: string): Job {
|
function find(ctx: ToolContext, id: string): Job {
|
||||||
const { registry, owner } = jobsOf(ctx)
|
const { registry, owner } = jobsOf(ctx)
|
||||||
const job = registry.get(owner().sessionId, id.trim())
|
const job = registry.get(owner().sessionId, id.trim())
|
||||||
if (!job) throw new ToolError(`No background job ${id}. job_list shows this session's jobs.`)
|
// A team member is not a job to the model: the team tools take it (team.md §4).
|
||||||
|
if (!job || job.team) throw new ToolError(`No background job ${id}. job_list shows this session's jobs.`)
|
||||||
return job
|
return job
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ export const jobListTool = defineTool({
|
|||||||
permission: () => ({ permission: "job_list", class: "read", patterns: ["*"] }),
|
permission: () => ({ permission: "job_list", class: "read", patterns: ["*"] }),
|
||||||
async run(_a, ctx) {
|
async run(_a, ctx) {
|
||||||
const { registry, owner } = jobsOf(ctx)
|
const { registry, owner } = jobsOf(ctx)
|
||||||
const jobs = registry.list(owner().sessionId)
|
const jobs = registry.list(owner().sessionId).filter((j) => !j.team)
|
||||||
if (!jobs.length) return { output: "No background jobs.", title: "0 jobs" }
|
if (!jobs.length) return { output: "No background jobs.", title: "0 jobs" }
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const lines = jobs.map((j) => {
|
const lines = jobs.map((j) => {
|
||||||
@@ -101,6 +102,9 @@ export const jobSendTool = defineTool({
|
|||||||
job.subagent.send(a.text)
|
job.subagent.send(a.text)
|
||||||
return { output: `Sent to ${job.id} (${whatWords(job)}); it is given to it at its next step.`, title: `${job.id} · message sent` }
|
return { output: `Sent to ${job.id} (${whatWords(job)}); it is given to it at its next step.`, title: `${job.id} · message sent` }
|
||||||
}
|
}
|
||||||
|
// Starting a finished helper again counts toward a team's size as task does (team.md §3).
|
||||||
|
const no = ctx.team?.runtime.team.taskRefusal()
|
||||||
|
if (no) throw new ToolError(no)
|
||||||
registry.restartSubagent(job)
|
registry.restartSubagent(job)
|
||||||
void job.subagent.restart(a.text)
|
void job.subagent.restart(a.text)
|
||||||
return { output: `${job.id} (${whatWords(job)}) is running again with your message as its new prompt, its context intact. Its report comes to you when it ends.`, title: `${job.id} · started again` }
|
return { output: `${job.id} (${whatWords(job)}) is running again with your message as its new prompt, its context intact. Its report comes to you when it ends.`, title: `${job.id} · started again` }
|
||||||
@@ -114,6 +118,12 @@ export const reportTool = defineTool({
|
|||||||
schema: z.object({ text: z.string().min(1) }),
|
schema: z.object({ text: z.string().min(1) }),
|
||||||
permission: () => ({ permission: "report", class: "interact", patterns: ["*"] }),
|
permission: () => ({ permission: "report", class: "interact", patterns: ["*"] }),
|
||||||
async run(a, ctx) {
|
async run(a, ctx) {
|
||||||
|
// A team member's report goes through the room, to its manager (team.md §4).
|
||||||
|
if (ctx.team && ctx.team.as !== "lead") {
|
||||||
|
const sent = ctx.team.runtime.team.message(ctx.team.as, a.text)
|
||||||
|
ctx.team.runtime.notify()
|
||||||
|
if (sent) return { output: `${sent} An answer, if one comes, arrives as a message to you; carry on meanwhile.`, title: `message to ${ctx.team.runtime.team.get(ctx.team.as)?.manager ?? "your manager"}` }
|
||||||
|
}
|
||||||
const { registry, owner } = jobsOf(ctx)
|
const { registry, owner } = jobsOf(ctx)
|
||||||
const job = owner().job
|
const job = owner().job
|
||||||
if (!job) throw new ToolError("report is for a subagent: you are the main agent, so write to the user directly.")
|
if (!job) throw new ToolError("report is for a subagent: you are the main agent, so write to the user directly.")
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export function resolveCall(name: string, raw: unknown): { name: string; raw: un
|
|||||||
const plan = fromTodos(obj.todos)
|
const plan = fromTodos(obj.todos)
|
||||||
args = isPlan(plan) ? { title: plan.title, phases: plan.phases.map((p) => ({ title: p.title, status: p.status, tasks: p.tasks.map((t) => ({ text: t.text, status: t.status })) })) } : { title: "Work", phases: [] }
|
args = isPlan(plan) ? { title: plan.title, phases: plan.phases.map((p) => ({ title: p.title, status: p.status, tasks: p.tasks.map((t) => ({ text: t.text, status: t.status })) })) } : { title: "Work", phases: [] }
|
||||||
}
|
}
|
||||||
|
// team_send's `to` as one name: read as a list of one (team.md §5).
|
||||||
|
if (to === "team_send" && obj && typeof obj.to === "string") args = { ...obj, to: [obj.to] }
|
||||||
const renamed = ARGUMENTS_RENAMED[to]
|
const renamed = ARGUMENTS_RENAMED[to]
|
||||||
if (renamed && args && typeof args === "object" && !Array.isArray(args)) {
|
if (renamed && args && typeof args === "object" && !Array.isArray(args)) {
|
||||||
const a = { ...(args as Record<string, unknown>) }
|
const a = { ...(args as Record<string, unknown>) }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { planSubmitTool } from "./plan_submit.ts"
|
|||||||
import { planUpdateTool } from "./plan_update.ts"
|
import { planUpdateTool } from "./plan_update.ts"
|
||||||
import { planWriteTool } from "./plan_write.ts"
|
import { planWriteTool } from "./plan_write.ts"
|
||||||
import { taskTool } from "./task.ts"
|
import { taskTool } from "./task.ts"
|
||||||
|
import { teamAddTool, teamAssignTool, teamDecideTool, teamFindingsTool, teamFinishTool, teamReadTool, teamReplaceTool, teamRequestTool, teamSendTool, teamStopTool, teamWhoamiTool } from "./team_tools.ts"
|
||||||
import { decisionsTool, tasksTool } from "./project_tools.ts"
|
import { decisionsTool, tasksTool } from "./project_tools.ts"
|
||||||
import { webFetchTool, webSearchTool } from "./web.ts"
|
import { webFetchTool, webSearchTool } from "./web.ts"
|
||||||
import { memoryTool } from "./memory.ts"
|
import { memoryTool } from "./memory.ts"
|
||||||
@@ -40,6 +41,19 @@ export const BUILTIN_TOOLS: Tool[] = [
|
|||||||
webSearchTool,
|
webSearchTool,
|
||||||
webFetchTool,
|
webFetchTool,
|
||||||
taskTool,
|
taskTool,
|
||||||
|
// Spec 3.2.0: a team (team.md), offered where the model serves two requests at once.
|
||||||
|
teamAddTool,
|
||||||
|
teamSendTool,
|
||||||
|
teamReadTool,
|
||||||
|
teamStopTool,
|
||||||
|
// Spec 3.4.0 (subagents.md §8).
|
||||||
|
teamWhoamiTool,
|
||||||
|
teamFindingsTool,
|
||||||
|
teamFinishTool,
|
||||||
|
teamRequestTool,
|
||||||
|
teamDecideTool,
|
||||||
|
teamAssignTool,
|
||||||
|
teamReplaceTool,
|
||||||
planWriteTool,
|
planWriteTool,
|
||||||
planUpdateTool,
|
planUpdateTool,
|
||||||
tasksTool,
|
tasksTool,
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@ export const grepTool = defineTool({
|
|||||||
if (a.literal) args.push("-F")
|
if (a.literal) args.push("-F")
|
||||||
if (a.include) args.push("--glob", a.include)
|
if (a.include) args.push("--glob", a.include)
|
||||||
args.push("-e", a.pattern, target)
|
args.push("-e", a.pattern, target)
|
||||||
const r = await run(args, ctx.cwd, ctx.signal)
|
const r = await run(args, ctx.root, ctx.signal)
|
||||||
if (r.code > 1) throw new ToolError(r.err.trim() || `rg exited ${r.code}`)
|
if (r.code > 1) throw new ToolError(r.err.trim() || `rg exited ${r.code}`)
|
||||||
for (const l of r.out.split("\n")) if (l) lines.push(l)
|
for (const l of r.out.split("\n")) if (l) lines.push(l)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const line = (r: Row, n: number) => {
|
|||||||
}
|
}
|
||||||
const header = (s: SessionRow) => `${s.id} · ${when(s.created)} · ${s.root}${s.title ? ` · "${s.title}"` : ""} · ${s.model}`
|
const header = (s: SessionRow) => `${s.id} · ${when(s.created)} · ${s.root}${s.title ? ` · "${s.title}"` : ""} · ${s.model}`
|
||||||
|
|
||||||
function snippet(text: string, query: string): string {
|
export function snippet(text: string, query: string): string {
|
||||||
const terms = (query.match(/"[^"]*"|\S+/g) ?? []).map((t) => t.replace(/^"|"$|\*$/g, "").toLowerCase()).filter((t) => t && !/^(and|or|not)$/.test(t))
|
const terms = (query.match(/"[^"]*"|\S+/g) ?? []).map((t) => t.replace(/^"|"$|\*$/g, "").toLowerCase()).filter((t) => t && !/^(and|or|not)$/.test(t))
|
||||||
const low = text.toLowerCase()
|
const low = text.toLowerCase()
|
||||||
const at = Math.max(0, Math.min(...terms.map((t) => low.indexOf(t)).filter((i) => i >= 0), text.length))
|
const at = Math.max(0, Math.min(...terms.map((t) => low.indexOf(t)).filter((i) => i >= 0), text.length))
|
||||||
@@ -65,6 +65,25 @@ function around(rows: Row[], id: number, window: number): Row[] {
|
|||||||
return rows.slice(Math.max(0, i - window), i + window + 1)
|
return rows.slice(Math.max(0, i - window), i + window + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `/search`: the sessions whose messages match `query` (FTS5, the person's words made safe; all
|
||||||
|
* of them, else any), the best hit of each, ranked. `root`: this project's only. */
|
||||||
|
export function searchSessions(store: Store, query: string, o: { root?: string; exclude?: string[]; limit?: number } = {}): { id: string; title: string; updated: number; model: string; role: string; snippet: string }[] {
|
||||||
|
let match = ftsQuery(query)
|
||||||
|
if (!match) return []
|
||||||
|
const find = (m: string) => store.hits(m, { exclude: o.exclude, root: o.root, perSession: true, limit: o.limit ?? 20 })
|
||||||
|
let hits = find(match)
|
||||||
|
if (!hits.length && anyOf(match)) {
|
||||||
|
match = anyOf(match)!
|
||||||
|
hits = find(match)
|
||||||
|
}
|
||||||
|
return hits.flatMap((h) => {
|
||||||
|
const s = store.session(h.session_id)
|
||||||
|
if (!s) return []
|
||||||
|
const m = readable(h.json)
|
||||||
|
return [{ id: s.id, title: s.title, updated: s.updated, model: s.model, role: m.role, snippet: snippet(m.text, match) }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const sessionSearchTool = defineTool({
|
export const sessionSearchTool = defineTool({
|
||||||
...described("session_search"),
|
...described("session_search"),
|
||||||
purpose: false,
|
purpose: false,
|
||||||
|
|||||||
+1
-1
@@ -68,7 +68,7 @@ export const showTool = defineTool({
|
|||||||
if (size > SHOW_MAX_BYTES) throw new ToolError(`${showPath(abs, ctx)} is ${Math.round(size / 1024)} KB; at most 1 MB can be shown. Give the user the path instead.`)
|
if (size > SHOW_MAX_BYTES) throw new ToolError(`${showPath(abs, ctx)} is ${Math.round(size / 1024)} KB; at most 1 MB can be shown. Give the user the path instead.`)
|
||||||
const text = await readText(abs)
|
const text = await readText(abs)
|
||||||
if (text.includes("\u0000")) throw new ToolError(`${showPath(abs, ctx)} is not a text file; it cannot be shown. Give the user the path instead.`)
|
if (text.includes("\u0000")) throw new ToolError(`${showPath(abs, ctx)} is not a text file; it cannot be shown. Give the user the path instead.`)
|
||||||
const rel = relative(ctx.cwd, abs) || basename(abs)
|
const rel = relative(ctx.root, abs) || basename(abs)
|
||||||
const reply = await ctx.show({ path: rel, ...showKind(abs), ...(a.line ? { line: a.line } : {}), text }, ctx.callId)
|
const reply = await ctx.show({ path: rel, ...showKind(abs), ...(a.line ? { line: a.line } : {}), text }, ctx.callId)
|
||||||
if (!reply.shown) return { output: `Not shown: ${reply.reason}`, isError: true }
|
if (!reply.shown) return { output: `Not shown: ${reply.reason}`, isError: true }
|
||||||
return { output: `Shown to the user: ${showPath(abs, ctx)}${a.line ? ` at line ${a.line}` : ""}.`, title: showPath(abs, ctx) }
|
return { output: `Shown to the user: ${showPath(abs, ctx)}${a.line ? ` at line ${a.line}` : ""}.`, title: showPath(abs, ctx) }
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
// The team tools (harness spec 3.2.0, team.md; 3.4.0, subagents.md §5–§13; tools/team_*.json):
|
||||||
|
// add a subagent, send through the room, read the tree and the room, stop one — and from 3.4.0
|
||||||
|
// whoami, findings, finish, request and decide, assign and replace. The rules are team/team.ts; these are its calls
|
||||||
|
// for the agent the context names (`ctx.team.as`).
|
||||||
|
import { z } from "zod"
|
||||||
|
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
|
||||||
|
import { described, TOOL_SPECS } from "../harness.ts"
|
||||||
|
import { TEAM } from "../team/team.ts"
|
||||||
|
import { fillPrompt } from "../prompt/fill.ts"
|
||||||
|
|
||||||
|
function teamOf(ctx: ToolContext) {
|
||||||
|
if (!ctx.team) throw new ToolError(TOOL_SPECS.team_add!.unavailable ?? TEAM.unavailable.one_agent)
|
||||||
|
return ctx.team
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The spec's own answer when a name does not parse (team.md §4's order: the requirement, `all`,
|
||||||
|
* then the pattern), before the generic one for invalid arguments. */
|
||||||
|
function badName(raw: unknown, ctx: ToolContext): string | undefined {
|
||||||
|
const name = (raw as { name?: unknown } | undefined)?.name
|
||||||
|
if (typeof name !== "string") return undefined
|
||||||
|
const no = ctx.team ? (ctx.team.runtime.team.offered ? ctx.team.runtime.team.unavailable() : TEAM.unavailable.one_agent) : TEAM.unavailable.one_agent
|
||||||
|
if (no) return no
|
||||||
|
if (name === "all") return TEAM.texts.all_name
|
||||||
|
if (!new RegExp(TEAM.names.pattern).test(name)) return fillPrompt(TEAM.texts.bad_name, { name })
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const teamAddTool = defineTool({
|
||||||
|
...described("team_add"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_add",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({
|
||||||
|
name: z.string().regex(new RegExp(TEAM.names.pattern)),
|
||||||
|
role: z.string().min(1),
|
||||||
|
task: z.string().min(1),
|
||||||
|
goal: z.string().optional(),
|
||||||
|
lead: z.boolean().optional(),
|
||||||
|
agent: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
parent: z.string().optional(),
|
||||||
|
mode: z.enum(["auto", "edit", "manual", "plan"]).optional(),
|
||||||
|
request: z.array(z.enum(["read", "write", "execute"])).optional(),
|
||||||
|
}),
|
||||||
|
invalid: badName,
|
||||||
|
permission: (a) => ({ permission: "team_add", class: "interact", patterns: [a.agent ?? "general"] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
const r = t.runtime.add(t.as, a)
|
||||||
|
return { output: r.text, title: r.added ? `${a.name}: ${a.role}` : "not added", isError: !r.added }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamSendTool = defineTool({
|
||||||
|
...described("team_send"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_send",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({ to: z.array(z.string()).min(1), message: z.string().min(1) }),
|
||||||
|
permission: () => ({ permission: "team_send", class: "interact", patterns: ["*"] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
const before = t.runtime.team.room.length
|
||||||
|
const output = t.runtime.team.send(t.as, a)
|
||||||
|
t.runtime.notify()
|
||||||
|
const sent = t.runtime.team.room.length > before
|
||||||
|
return { output, title: sent ? `to ${a.to.includes("all") ? "all" : [...new Set(a.to)].join(", ")}` : "not sent", isError: !sent }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamReadTool = defineTool({
|
||||||
|
...described("team_read"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_read",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({ since: z.number().int().min(0).optional() }),
|
||||||
|
permission: () => ({ permission: "team_read", class: "read", patterns: ["*"] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
return { output: t.runtime.team.read(t.as, a.since ?? 0), title: `team${a.since ? ` since #${a.since}` : ""}` }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamStopTool = defineTool({
|
||||||
|
...described("team_stop"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_stop",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({ name: z.string().min(1) }),
|
||||||
|
permission: (a) => ({ permission: "team_stop", class: "interact", patterns: [a.name] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
const size = t.runtime.team.size()
|
||||||
|
const output = t.runtime.team.stopCmd(t.as, a.name)
|
||||||
|
t.runtime.notify()
|
||||||
|
return { output, title: t.runtime.team.size() < size ? `stopped ${a.name}` : "not stopped", isError: t.runtime.team.size() >= size }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamWhoamiTool = defineTool({
|
||||||
|
...described("team_whoami"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_whoami",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({}),
|
||||||
|
permission: () => ({ permission: "team_whoami", class: "read", patterns: ["*"] }),
|
||||||
|
async run(_a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
return { output: t.runtime.team.whoami(t.as), title: t.as }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamFindingsTool = defineTool({
|
||||||
|
...described("team_findings"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_findings",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({ action: z.enum(["write", "read"]), text: z.string().optional(), of: z.string().optional() }),
|
||||||
|
permission: (a) => ({ permission: "team_findings", class: "interact", patterns: [a.action] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
if (a.action === "write") {
|
||||||
|
if (!a.text?.trim()) throw new ToolError("write needs `text`: the finding.")
|
||||||
|
const output = t.runtime.team.noteFinding(t.as, a.text)
|
||||||
|
t.runtime.notify()
|
||||||
|
return { output, title: "finding noted" }
|
||||||
|
}
|
||||||
|
return { output: t.runtime.team.readFindings(t.as, a.of), title: `findings of ${a.of ?? t.as}` }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamFinishTool = defineTool({
|
||||||
|
...described("team_finish"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_finish",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({ report: z.string().min(1) }),
|
||||||
|
permission: () => ({ permission: "team_finish", class: "interact", patterns: ["*"] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
const output = t.runtime.team.finishTask(t.as, a.report)
|
||||||
|
t.runtime.notify()
|
||||||
|
return { output, title: "finished" }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamRequestTool = defineTool({
|
||||||
|
...described("team_request"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_request",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({ tool: z.string().min(1), pattern: z.string().min(1), why: z.string().min(1) }),
|
||||||
|
permission: () => ({ permission: "team_request", class: "interact", patterns: ["*"] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
const before = t.runtime.team.requests.length
|
||||||
|
const output = t.runtime.team.request(t.as, a, t.runtime.classOf(a.tool))
|
||||||
|
t.runtime.notify()
|
||||||
|
const asked = t.runtime.team.requests.length > before
|
||||||
|
return { output, title: asked ? `asked for ${a.tool} ${a.pattern}` : "not asked", isError: !asked }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamDecideTool = defineTool({
|
||||||
|
...described("team_decide"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_decide",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({ id: z.string().min(1), allow: z.boolean(), note: z.string().optional() }),
|
||||||
|
permission: () => ({ permission: "team_decide", class: "interact", patterns: ["*"] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
const output = t.runtime.team.decide(t.as, a, (tool) => t.runtime.permissionKeyOf(tool))
|
||||||
|
t.runtime.notify()
|
||||||
|
return { output, title: `${a.allow ? "allowed" : "refused"} ${a.id}` }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamAssignTool = defineTool({
|
||||||
|
...described("team_assign"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_assign",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({ name: z.string().min(1), task: z.string().min(1), role: z.string().optional(), parent: z.string().optional(), mode: z.enum(["auto", "edit", "manual", "plan"]).optional() }),
|
||||||
|
permission: (a) => ({ permission: "team_assign", class: "interact", patterns: [a.name] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
const output = t.runtime.team.assign(t.as, a)
|
||||||
|
t.runtime.syncModes()
|
||||||
|
t.runtime.notify()
|
||||||
|
return { output, title: `to ${a.name}` }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamReplaceTool = defineTool({
|
||||||
|
...described("team_replace"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_replace",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({ name: z.string().min(1), task: z.string().optional() }),
|
||||||
|
permission: (a) => ({ permission: "team_replace", class: "interact", patterns: [a.name] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
const r = t.runtime.team.replace(t.as, a)
|
||||||
|
t.runtime.notify()
|
||||||
|
return { output: r.text, title: r.added ? `${a.name} → ${r.added.id}` : "not replaced", isError: !r.added }
|
||||||
|
},
|
||||||
|
})
|
||||||
+16
-4
@@ -9,7 +9,8 @@ import { fillParameters, PURPOSE_DEF, TOOL_SPECS } from "../harness.ts"
|
|||||||
export interface ToolContext {
|
export interface ToolContext {
|
||||||
/** Absolute project root. */
|
/** Absolute project root. */
|
||||||
root: string
|
root: string
|
||||||
/** Absolute working directory for relative paths and shells. */
|
/** Where the session was started (absolute): it ends the instruction chain and is told to the
|
||||||
|
* model relative to the root. Relative paths and the shell are the root's (context.md §11). */
|
||||||
cwd: string
|
cwd: string
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
/** Absolute paths read in this session — `edit`/`write` of an existing file requires one. */
|
/** Absolute paths read in this session — `edit`/`write` of an existing file requires one. */
|
||||||
@@ -35,6 +36,9 @@ export interface ToolContext {
|
|||||||
* (the TUI's ctrl+b): bash hands it to the registry as it would at its timeout. Set by the
|
* (the TUI's ctrl+b): bash hands it to the registry as it would at its timeout. Set by the
|
||||||
* engine for the step's calls. */
|
* engine for the step's calls. */
|
||||||
backgroundNow?: AbortSignal
|
backgroundNow?: AbortSignal
|
||||||
|
/** The team this agent is on (spec 3.2.0, team/runtime.ts) and its name there (`lead`, or a
|
||||||
|
* member's). Absent where no team is offered. */
|
||||||
|
team?: { runtime: import("../team/runtime.ts").TeamRuntime; as: string }
|
||||||
/** The id of the call being run (set by the engine per call). */
|
/** The id of the call being run (set by the engine per call). */
|
||||||
callId?: string
|
callId?: string
|
||||||
/** The project directory (.agent), when the project has one. */
|
/** The project directory (.agent), when the project has one. */
|
||||||
@@ -90,12 +94,16 @@ export interface SpawnOptions {
|
|||||||
description?: string
|
description?: string
|
||||||
model?: string
|
model?: string
|
||||||
background?: boolean
|
background?: boolean
|
||||||
|
/** Spec 3.2.0: a team member (team.md §4) — run as one, not as a task helper. */
|
||||||
|
member?: import("../team/team.ts").TeamAgent
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the task tool gets back from spawn: the job's id and the report when it ends. */
|
/** What the task tool gets back from spawn: the job's id and the report when it ends. */
|
||||||
export interface SubagentHandleInfo {
|
export interface SubagentHandleInfo {
|
||||||
id: string
|
id: string
|
||||||
done: Promise<import("./task.ts").SubagentResult>
|
done: Promise<import("./task.ts").SubagentResult>
|
||||||
|
/** A team member's engine, for the team's runtime to drive. */
|
||||||
|
member?: import("../team/runtime.ts").MemberHandle
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolResult {
|
export interface ToolResult {
|
||||||
@@ -123,7 +131,7 @@ export interface Tool<S extends z.ZodType = z.ZodType> {
|
|||||||
* display`). The tool list is the session's (harness context.md §10): one the session could
|
* display`). The tool list is the session's (harness context.md §10): one the session could
|
||||||
* never meet leaves the tool out; one that comes and goes (plan mode, a display) keeps it in, and
|
* never meet leaves the tool out; one that comes and goes (plan mode, a display) keeps it in, and
|
||||||
* a call while it is unmet answers the spec's `unavailable` text. */
|
* a call while it is unmet answers the spec's `unavailable` text. */
|
||||||
requires?: "vision" | "plan" | "project" | "knowledge" | "display"
|
requires?: "vision" | "plan" | "project" | "knowledge" | "display" | "team"
|
||||||
description: string
|
description: string
|
||||||
schema: S
|
schema: S
|
||||||
/** The JSON Schema sent to the model, when it does not come from `schema` (MCP tools). */
|
/** The JSON Schema sent to the model, when it does not come from `schema` (MCP tools). */
|
||||||
@@ -135,6 +143,9 @@ export interface Tool<S extends z.ZodType = z.ZodType> {
|
|||||||
* `purpose` argument added to every tool (the default), a field of the tool's own (bash's
|
* `purpose` argument added to every tool (the default), a field of the tool's own (bash's
|
||||||
* `description`), or false for a tool that never asks. */
|
* `description`), or false for a tool that never asks. */
|
||||||
purpose?: string | false
|
purpose?: string | false
|
||||||
|
/** What a call answers when its arguments do not parse, where the spec has its own words for it
|
||||||
|
* (team_add's name); undefined: the generic answer. */
|
||||||
|
invalid?(raw: unknown, ctx: ToolContext): string | undefined
|
||||||
permission(args: z.infer<S>, ctx: ToolContext): PermissionRequest
|
permission(args: z.infer<S>, ctx: ToolContext): PermissionRequest
|
||||||
/** What the call would change, as a unified diff, shown when the user is asked. Writes nothing. */
|
/** What the call would change, as a unified diff, shown when the user is asked. Writes nothing. */
|
||||||
preview?(args: z.infer<S>, ctx: ToolContext): Promise<string>
|
preview?(args: z.infer<S>, ctx: ToolContext): Promise<string>
|
||||||
@@ -189,12 +200,13 @@ export async function readText(abs: string): Promise<string> {
|
|||||||
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(await Bun.file(abs).bytes())
|
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(await Bun.file(abs).bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A tool's path argument, absolute. Models send both absolute and relative paths. */
|
/** A tool's path argument, absolute. Models send both absolute and relative paths; a relative one
|
||||||
|
* is read against the project root, wherever the session was started (harness context.md §11). */
|
||||||
export function absPath(p: string, ctx: ToolContext): string {
|
export function absPath(p: string, ctx: ToolContext): string {
|
||||||
// A model writes ~/x as a shell would; without this it becomes a directory named "~".
|
// A model writes ~/x as a shell would; without this it becomes a directory named "~".
|
||||||
if (p === "~" || p.startsWith("~/")) return resolve(expandHome(p))
|
if (p === "~" || p.startsWith("~/")) return resolve(expandHome(p))
|
||||||
if (isAbsolute(p)) return resolve(p)
|
if (isAbsolute(p)) return resolve(p)
|
||||||
const rel = resolve(ctx.cwd, p)
|
const rel = resolve(ctx.root, p)
|
||||||
// Small models often drop the leading "/" of an absolute path ("home/me/proj/a.py"). When the
|
// Small models often drop the leading "/" of an absolute path ("home/me/proj/a.py"). When the
|
||||||
// relative reading does not exist and the absolute one does, inside the project, take that.
|
// relative reading does not exist and the absolute one does, inside the project, take that.
|
||||||
if (!existsSync(rel)) {
|
if (!existsSync(rel)) {
|
||||||
|
|||||||
+31
-8
@@ -3,7 +3,9 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
|||||||
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
|
||||||
import { stricterMode, type App as Runtime } from "../app.ts"
|
import { stricterMode, type App as Runtime } from "../app.ts"
|
||||||
import { isLocal } from "../config/paths.ts"
|
import { isLocal } from "../config/paths.ts"
|
||||||
import type { Effort, Mode } from "../config/schema.ts"
|
import type { Mode } from "../config/schema.ts"
|
||||||
|
import { levelLabel, type Level } from "../provider/effort.ts"
|
||||||
|
import { teamLabel } from "../session/commands.ts"
|
||||||
import { COMMANDS, findCommand, type CommandCtx } from "./commands.ts"
|
import { COMMANDS, findCommand, type CommandCtx } from "./commands.ts"
|
||||||
import { Dialog, type DialogSpec } from "./components/dialog.tsx"
|
import { Dialog, type DialogSpec } from "./components/dialog.tsx"
|
||||||
import { Messages } from "./components/messages.tsx"
|
import { Messages } from "./components/messages.tsx"
|
||||||
@@ -15,6 +17,7 @@ import { PlanPanel } from "./components/plan.tsx"
|
|||||||
import { JobView } from "./components/jobview.tsx"
|
import { JobView } from "./components/jobview.tsx"
|
||||||
import { SubagentView } from "./components/subagent.tsx"
|
import { SubagentView } from "./components/subagent.tsx"
|
||||||
import { Bus } from "../bus/index.ts"
|
import { Bus } from "../bus/index.ts"
|
||||||
|
import { FocusWatch, noticeSequence, noticeText } from "./notify.ts"
|
||||||
import { Prompt, type PaletteEntry } from "./components/prompt.tsx"
|
import { Prompt, type PaletteEntry } from "./components/prompt.tsx"
|
||||||
import { FileIndex } from "../project/files.ts"
|
import { FileIndex } from "../project/files.ts"
|
||||||
import { attachmentsFor } from "../project/attach.ts"
|
import { attachmentsFor } from "../project/attach.ts"
|
||||||
@@ -58,7 +61,9 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
|||||||
const refresh = () => setRev((n) => n + 1)
|
const refresh = () => setRev((n) => n + 1)
|
||||||
const mode = () => (rev(), app.engine.mode)
|
const mode = () => (rev(), app.engine.mode)
|
||||||
const model = () => (rev(), app.engine.model.ref)
|
const model = () => (rev(), app.engine.model.ref)
|
||||||
const effort = () => (rev(), app.engine.effort ?? "")
|
const effort = () => (rev(), levelLabel(app.engine.model, app.engine.effort))
|
||||||
|
// "team 3/4 · 2 working" while the lead has members (team.md, the TUI's status).
|
||||||
|
const teamWord = () => (rev(), teamLabel(app.team.team))
|
||||||
const busy = () => view.state.phase !== "idle"
|
const busy = () => view.state.phase !== "idle"
|
||||||
const branch = () => (rev(), app.project.gitRoot ? currentBranch(app.project.gitRoot) : undefined)
|
const branch = () => (rev(), app.project.gitRoot ? currentBranch(app.project.gitRoot) : undefined)
|
||||||
// A word in the status bar that goes by itself (a copy): it says something happened without
|
// A word in the status bar that goes by itself (a copy): it says something happened without
|
||||||
@@ -73,8 +78,24 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
|||||||
onCleanup(() => clearTimeout(flashTimer))
|
onCleanup(() => clearTimeout(flashTimer))
|
||||||
// A mode changed inside a turn (a plan approved) must reach the status bar; so must a branch the
|
// A mode changed inside a turn (a plan approved) must reach the status bar; so must a branch the
|
||||||
// model switched with git.
|
// model switched with git.
|
||||||
app.bus.on((e) => (e.type === "mode" || e.type === "done") && refresh())
|
app.bus.on((e) => (e.type === "mode" || e.type === "done" || e.type === "team") && refresh())
|
||||||
app.bus.on((e) => e.type === "flash" && flash(e.text, 6000))
|
app.bus.on((e) => e.type === "flash" && flash(e.text, 6000))
|
||||||
|
// The finished-reply notice (notify.ts): a reply that ends, or an answer the agent waits for,
|
||||||
|
// while the terminal is not focused — the bell and a desktop notification naming the session.
|
||||||
|
const focus = new FocusWatch((ev, fn) => renderer.on(ev, fn))
|
||||||
|
const notice = (what: "finished" | "waiting" | "failed") => {
|
||||||
|
if (focus.focused || app.loaded.config.notify === false) return
|
||||||
|
const id = app.engine.sessionId
|
||||||
|
const title = id ? app.store?.session(id)?.title : undefined
|
||||||
|
const seq = noticeSequence(noticeText(title, what))
|
||||||
|
const r = renderer as unknown as { writeOut?: (s: string) => void }
|
||||||
|
try {
|
||||||
|
if (r.writeOut) r.writeOut(seq)
|
||||||
|
else process.stdout.write(seq)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
app.bus.on((e) => e.type === "done" && e.reason !== "cancelled" && notice(e.reason === "error" ? "failed" : "finished"))
|
||||||
|
createEffect(() => (view.state.pending || view.state.question || view.state.plan) && notice("waiting"))
|
||||||
// A setting changed by the agent, /settings or a command: show it. Theme and icons are drawn here.
|
// A setting changed by the agent, /settings or a command: show it. Theme and icons are drawn here.
|
||||||
app.bus.on((e) => {
|
app.bus.on((e) => {
|
||||||
if (e.type !== "setting") return
|
if (e.type !== "setting") return
|
||||||
@@ -125,6 +146,8 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
|||||||
const sendToJob = (text: string) => {
|
const sendToJob = (text: string) => {
|
||||||
const id = entered()!
|
const id = entered()!
|
||||||
const job = app.registry.get(app.jobsSessionId(), id)
|
const job = app.registry.get(app.jobsSessionId(), id)
|
||||||
|
// Spec 3.4.0 §11: a subagent of the tree is read-only; the person speaks through the main agent.
|
||||||
|
if (job?.team) return view.info(`${id} is ${job.team}, a subagent of the main agent: its session is read-only — tell the main agent, or /team stop ${job.team}`, "warn")
|
||||||
if (!job?.subagent) return view.info(`${id} cannot be messaged any more — an earlier process ran it`, "warn")
|
if (!job?.subagent) return view.info(`${id} cannot be messaged any more — an earlier process ran it`, "warn")
|
||||||
if (job.state === "running") {
|
if (job.state === "running") {
|
||||||
job.subagent.send(text)
|
job.subagent.send(text)
|
||||||
@@ -223,8 +246,8 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
|||||||
setMode(m: Mode) {
|
setMode(m: Mode) {
|
||||||
app.settings.set("mode", m)
|
app.settings.set("mode", m)
|
||||||
},
|
},
|
||||||
setEffort(e: Effort | null) {
|
setEffort(e: Level) {
|
||||||
app.settings.set("effort", e ?? "off")
|
app.settings.set("effort", e)
|
||||||
},
|
},
|
||||||
setTheme(name: string) {
|
setTheme(name: string) {
|
||||||
try {
|
try {
|
||||||
@@ -246,7 +269,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
|||||||
confirmStop("reload", () => {
|
confirmStop("reload", () => {
|
||||||
// Back on this session, with what it was using; everything else comes from the files again.
|
// Back on this session, with what it was using; everything else comes from the files again.
|
||||||
const e = app.engine
|
const e = app.engine
|
||||||
const args = [...(isLocal() ? ["-l"] : []), ...(e.sessionId ? ["-s", e.sessionId] : []), "-m", e.model.ref, "--mode", e.mode, "-e", e.effort ?? "off"]
|
const args = [...(isLocal() ? ["-l"] : []), ...(e.sessionId ? ["-s", e.sessionId] : []), "-m", e.model.ref, "--mode", e.mode, "-e", e.effort]
|
||||||
void app
|
void app
|
||||||
.close()
|
.close()
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -402,7 +425,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
|||||||
const id = view.shellStart(command)
|
const id = view.shellStart(command)
|
||||||
const t0 = performance.now()
|
const t0 = performance.now()
|
||||||
bashTool
|
bashTool
|
||||||
.run({ command }, { ...app.engine.o.toolCtx, signal: new AbortController().signal, bashTimeline: false })
|
.run({ command, cwd: app.engine.o.toolCtx.cwd }, { ...app.engine.o.toolCtx, signal: new AbortController().signal, bashTimeline: false })
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
view.shellEnd(id, r, performance.now() - t0)
|
view.shellEnd(id, r, performance.now() - t0)
|
||||||
app.engine.pendingContext.push(`<shell command="${command.replaceAll('"', "'")}">\n${r.output}\n</shell>`)
|
app.engine.pendingContext.push(`<shell command="${command.replaceAll('"', "'")}">\n${r.output}\n</shell>`)
|
||||||
@@ -641,7 +664,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
|||||||
</Show>
|
</Show>
|
||||||
<Queued />
|
<Queued />
|
||||||
<Prompt focused={() => !dialog() && !jobPanel() && !view.state.pending && !view.state.question && !view.state.plan} busy={busy} commands={palette} files={files} onSubmit={submit} ref={(r) => (input = r)} queue={queueAccess} onWheel={(dir, lines) => scroll?.scrollBy(dir * lines)} onBackground={backgroundNow} />
|
<Prompt focused={() => !dialog() && !jobPanel() && !view.state.pending && !view.state.question && !view.state.plan} busy={busy} commands={palette} files={files} onSubmit={submit} ref={(r) => (input = r)} queue={queueAccess} onWheel={(dir, lines) => scroll?.scrollBy(dir * lines)} onBackground={backgroundNow} />
|
||||||
<StatusBar mode={mode} model={model} effort={effort} branch={branch()} voice={voiceStatus} flash={flashText} remote={() => (rev(), share.label())} spend={spend} />
|
<StatusBar mode={mode} model={model} effort={effort} branch={branch()} voice={voiceStatus} flash={flashText} remote={() => (rev(), share.label())} spend={spend} team={teamWord} />
|
||||||
</box>
|
</box>
|
||||||
<Show when={dialog()}>
|
<Show when={dialog()}>
|
||||||
<Dialog spec={dialog()!} close={() => setDialog(undefined)} />
|
<Dialog spec={dialog()!} close={() => setDialog(undefined)} />
|
||||||
|
|||||||
+315
-34
@@ -3,19 +3,21 @@
|
|||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { paths } from "../config/paths.ts"
|
import { paths } from "../config/paths.ts"
|
||||||
import { asMode, EFFORTS, MODES, type Effort, type Mode } from "../config/schema.ts"
|
import { asMode, MODES, type Mode } from "../config/schema.ts"
|
||||||
import { effortsFor } from "../provider/effort.ts"
|
import { levelsFor, type Level } from "../provider/effort.ts"
|
||||||
import { setTrust } from "../project/root.ts"
|
import { setTrust } from "../project/root.ts"
|
||||||
import { commit, commitPlan, draftMessage } from "../git/commit.ts"
|
import { commit, commitPlan, draftMessage } from "../git/commit.ts"
|
||||||
import { doRelease, planRelease } from "../git/release.ts"
|
import { doRelease, planRelease } from "../git/release.ts"
|
||||||
import { instructionFiles } from "../prompt/assemble.ts"
|
import { instructionFiles } from "../prompt/assemble.ts"
|
||||||
import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts"
|
import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts"
|
||||||
import { contextBreakdown, renderBreakdown } from "../session/context.ts"
|
import { contextBreakdown, renderBreakdown } from "../session/context.ts"
|
||||||
import { changelogPrompt, initPrompt, reviewPrompt } from "../session/commands.ts"
|
import { SUBAGENTS } from "../team/subagents.ts"
|
||||||
|
import { TEAM } from "../team/team.ts"
|
||||||
|
import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/commands.ts"
|
||||||
import { git } from "../git/run.ts"
|
import { git } from "../git/run.ts"
|
||||||
import { branches, deleteBranch, switchBranch } from "../git/branch.ts"
|
import { branches, deleteBranch, switchBranch } from "../git/branch.ts"
|
||||||
import type { Ctx } from "./context.ts"
|
import type { Ctx } from "./context.ts"
|
||||||
import type { DialogSpec } from "./components/dialog.tsx"
|
import type { DialogSpec, Option } from "./components/dialog.tsx"
|
||||||
import { deleteEverywhere } from "../acp/share.ts"
|
import { deleteEverywhere } from "../acp/share.ts"
|
||||||
import { themeNames } from "./theme.ts"
|
import { themeNames } from "./theme.ts"
|
||||||
import { render } from "../plan/plan.ts"
|
import { render } from "../plan/plan.ts"
|
||||||
@@ -47,7 +49,7 @@ export interface CommandCtx extends Ctx {
|
|||||||
sendAs?(prompt: string, shown: string, opts?: { model?: string; mode?: string; attachFrom?: string }): void
|
sendAs?(prompt: string, shown: string, opts?: { model?: string; mode?: string; attachFrom?: string }): void
|
||||||
openDialog(spec: DialogSpec): void
|
openDialog(spec: DialogSpec): void
|
||||||
setMode(m: Mode): void
|
setMode(m: Mode): void
|
||||||
setEffort(e: Effort | null): void
|
setEffort(e: Level): void
|
||||||
setTheme(name: string): void
|
setTheme(name: string): void
|
||||||
setIcons(set: import("./theme.ts").IconSet): void
|
setIcons(set: import("./theme.ts").IconSet): void
|
||||||
/** A short word in the status bar that goes by itself. */
|
/** A short word in the status bar that goes by itself. */
|
||||||
@@ -85,7 +87,6 @@ const pendingCodes = new Map<string, (text: string) => void>()
|
|||||||
|
|
||||||
const AGENT_TEMPLATE = (name: string) => `---
|
const AGENT_TEMPLATE = (name: string) => `---
|
||||||
description: what ${name} is for — the model reads this when it picks a subagent
|
description: what ${name} is for — the model reads this when it picks a subagent
|
||||||
# model: provider/model # run it on another of the server's models (default: the session's)
|
|
||||||
# mode: plan # plan: read-only; edit; manual (asks you) — never looser than yours
|
# mode: plan # plan: read-only; edit; manual (asks you) — never looser than yours
|
||||||
# tools: [read, glob, grep, list, bash, web_search, web_fetch] # default: every tool a subagent may have
|
# tools: [read, glob, grep, list, bash, web_search, web_fetch] # default: every tool a subagent may have
|
||||||
# isolation: worktree # work in a git checkout and branch of its own
|
# isolation: worktree # work in a git checkout and branch of its own
|
||||||
@@ -94,6 +95,48 @@ You are the ${name} subagent. Say what to do, how to check it, and what to repor
|
|||||||
report is all the session's model sees of your work.
|
report is all the session's model sees of your work.
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const TEAM_ROW = "\u0000team"
|
||||||
|
|
||||||
|
/** 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, "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: `${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[] = [
|
export const COMMANDS: Command[] = [
|
||||||
{
|
{
|
||||||
name: "agents",
|
name: "agents",
|
||||||
@@ -125,7 +168,7 @@ export const COMMANDS: Command[] = [
|
|||||||
const describe = (a: (typeof list)[number]) =>
|
const describe = (a: (typeof list)[number]) =>
|
||||||
[
|
[
|
||||||
`${a.name} (${a.source}) — ${a.description}`,
|
`${a.name} (${a.source}) — ${a.description}`,
|
||||||
` model ${a.model ?? "the session's"} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`,
|
` model the session's, or one you name${a.model ? ` (its own model: ${a.model} is not used)` : ""} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`,
|
||||||
].join("\n")
|
].join("\n")
|
||||||
if (named) return c.view.info(describe(named))
|
if (named) return c.view.info(describe(named))
|
||||||
c.view.info(
|
c.view.info(
|
||||||
@@ -171,7 +214,7 @@ export const COMMANDS: Command[] = [
|
|||||||
options: refs.map((r) => {
|
options: refs.map((r) => {
|
||||||
const known = c.app.loaded.refs.find((x) => x.ref === r)!
|
const known = c.app.loaded.refs.find((x) => x.ref === r)!
|
||||||
const spec = c.app.loaded.connections[known.connection]!.models[known.id]!
|
const spec = c.app.loaded.connections[known.connection]!.models[known.id]!
|
||||||
const bits = [spec.context ? `${Math.round(spec.context / 1024)}k ctx` : "", spec.vision ? "vision" : "", spec.efforts ? "effort" : ""].filter(Boolean).join(" · ")
|
const bits = [spec.context ? `${Math.round(spec.context / 1024)}k ctx` : "", spec.vision ? "vision" : "", levelsFor(c.app.engine.model.ref === r ? c.app.engine.model : { ...c.app.engine.model, ref: r, spec }).control === "switch" ? "thinking switch" : spec.efforts?.length ? "effort" : ""].filter(Boolean).join(" · ")
|
||||||
return { label: r, value: r, description: bits, current: r === c.app.engine.model.ref }
|
return { label: r, value: r, description: bits, current: r === c.app.engine.model.ref }
|
||||||
}),
|
}),
|
||||||
onSelect: pick,
|
onSelect: pick,
|
||||||
@@ -180,21 +223,80 @@ export const COMMANDS: Command[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "effort",
|
name: "effort",
|
||||||
args: `[${EFFORTS.join("|")}|off]`,
|
args: "[level|on|off]",
|
||||||
description: "reasoning effort for this model",
|
description: "how hard the model thinks: its levels, a Thinking switch, or nothing to choose",
|
||||||
run(c, arg) {
|
run(c, arg) {
|
||||||
const allowed = effortsFor(c.app.engine.model)
|
// The control follows the model's levels (harness models/schema.json `levels`): one level —
|
||||||
|
// nothing to choose; [off, X] — a Thinking switch; more — a list.
|
||||||
|
const m = c.app.engine.model
|
||||||
|
const { levels, control } = levelsFor(m)
|
||||||
|
const on = levels.find((l) => l !== "off")!
|
||||||
const set = (v: string) => {
|
const set = (v: string) => {
|
||||||
if (v === "off") return c.setEffort(null)
|
if (control === "switch" && (v === "on" || v === "thinking")) v = on
|
||||||
if (!allowed.includes(v as Effort)) return c.view.info(`${c.app.engine.model.ref} takes ${allowed.join(", ")} or off`, "warn")
|
if (!(levels as string[]).includes(v)) return c.view.info(control === "switch" ? `${m.ref} has a Thinking switch: /effort on or /effort off` : `${m.ref} offers ${levels.join(", ")}`, "warn")
|
||||||
c.setEffort(v as Effort)
|
c.setEffort(v as Level)
|
||||||
|
c.view.info(control === "switch" ? `thinking ${v === "off" ? "off" : `on (${v})`}` : `effort: ${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 (arg) return set(arg)
|
||||||
c.openDialog({
|
if (control === "none") return c.openDialog({ title: `Chat settings — ${m.ref}`, options: agentsRow, onSelect: choose })
|
||||||
title: `Effort — ${c.app.engine.model.ref}`,
|
if (control === "switch")
|
||||||
options: [...allowed, "off"].map((e) => ({ label: e, value: e, current: (c.app.engine.effort ?? "off") === e })),
|
return c.openDialog({
|
||||||
onSelect: set,
|
title: `Thinking — ${m.ref}`,
|
||||||
|
options: [
|
||||||
|
{ label: "on", value: on, description: on === "default" ? "the model's own" : on, current: c.app.engine.effort !== "off" },
|
||||||
|
{ label: "off", value: "off", current: c.app.engine.effort === "off" },
|
||||||
|
...agentsRow,
|
||||||
|
],
|
||||||
|
onSelect: choose,
|
||||||
})
|
})
|
||||||
|
c.openDialog({
|
||||||
|
title: `Effort — ${m.ref}`,
|
||||||
|
options: [...levels.map((e) => ({ label: e === "off" ? "off (no thinking)" : e, value: e, current: c.app.engine.effort === e })), ...agentsRow],
|
||||||
|
onSelect: choose,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "team",
|
||||||
|
args: "[size [<min> <max>] | stop [<name>] | open [<name>] | findings <name>]",
|
||||||
|
description: "subagents: the tree and the room; how many; stop one; their findings",
|
||||||
|
run(c, arg) {
|
||||||
|
// The tree and the room as the lead sees them — the person sees everything (team.md §5).
|
||||||
|
const words = arg.trim().split(/\s+/).filter(Boolean)
|
||||||
|
if (words[0] === "size" && words.length === 1) return openTeamSize(c)
|
||||||
|
// Spec 3.4.0 §11: a subagent's session, read-only, live or after it ended.
|
||||||
|
if (words[0] === "open") {
|
||||||
|
const t = c.app.team.team
|
||||||
|
const all = [...t.tree().filter((a) => a.manager !== null), ...t.ended]
|
||||||
|
const open = (key: string) => {
|
||||||
|
const a = t.find(key)
|
||||||
|
if (!a?.jobId) return c.view.info(`no subagent ${key} with a session here — /team lists them`, "warn")
|
||||||
|
c.enterJob(a.jobId)
|
||||||
|
}
|
||||||
|
if (words[1]) return open(words[1])
|
||||||
|
if (!all.length) return c.view.info("no subagents yet")
|
||||||
|
return c.openDialog({
|
||||||
|
title: "A subagent's session — read-only",
|
||||||
|
options: all.map((a) => ({ label: `${a.name} (${a.id})`, value: a.id, description: `${a.role} · ${TEAM.read.states[a.state]}` })),
|
||||||
|
onSelect: open,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (words[0] === "stop" && words.length === 1) {
|
||||||
|
const members = c.app.team.team.tree().filter((a) => a.manager !== null)
|
||||||
|
if (!members.length) return c.view.info("the team has no members")
|
||||||
|
return c.openDialog({
|
||||||
|
title: "Stop a member, and everything under it",
|
||||||
|
options: members.map((a) => ({ label: a.name, value: a.name, description: `${a.role} · ${a.state}` })),
|
||||||
|
onSelect: (name) => (c.view.info(teamText(c.app, `stop ${name}`)), c.refresh()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.view.info(teamText(c.app, arg))
|
||||||
|
c.refresh()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -413,6 +515,59 @@ export const COMMANDS: Command[] = [
|
|||||||
void c.share.remote(a !== "off").then((m) => (c.view.info(m), c.refresh()))
|
void c.share.remote(a !== "off").then((m) => (c.view.info(m), c.refresh()))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "rename",
|
||||||
|
args: "<title>",
|
||||||
|
description: "name this session (here, in /sessions, and in the web UI when it is shared)",
|
||||||
|
run(c, arg) {
|
||||||
|
const title = arg.trim().replace(/\s+/g, " ").slice(0, 200)
|
||||||
|
const id = c.app.engine.sessionId
|
||||||
|
const now = (id && c.app.store?.session(id)?.title) || ""
|
||||||
|
if (!id) return c.view.info("nothing to name yet: this session has no messages", "warn")
|
||||||
|
if (!title) return c.view.info(`usage: /rename <title>${now ? ` — now "${now}"` : ""}`, "warn")
|
||||||
|
c.app.rename(title)
|
||||||
|
c.view.info(`named "${title}"`)
|
||||||
|
c.refresh()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "search",
|
||||||
|
args: "<words> [all]",
|
||||||
|
description: "find a past session by what was said in it (this project's; add `all` for every project) and resume it",
|
||||||
|
async run(c, arg) {
|
||||||
|
const store = c.app.store
|
||||||
|
if (!store) return c.view.info("sessions are not being recorded here", "warn")
|
||||||
|
const words = arg.trim().split(/\s+/).filter(Boolean)
|
||||||
|
const everywhere = words.at(-1) === "all"
|
||||||
|
const query = (everywhere ? words.slice(0, -1) : words).join(" ")
|
||||||
|
if (!query) return c.view.info('usage: /search <words> [all] — plain words, "a phrase", OR, NOT', "warn")
|
||||||
|
const { searchSessions } = await import("../tool/session_search.ts")
|
||||||
|
let found: ReturnType<typeof searchSessions>
|
||||||
|
try {
|
||||||
|
found = searchSessions(store, query, { root: everywhere ? undefined : c.app.project.root, exclude: c.app.engine.sessionId ? [c.app.engine.sessionId] : [] })
|
||||||
|
} catch (e) {
|
||||||
|
return c.view.info(`/search: that could not be searched (${(e as Error).message}) — use plain words, "quoted phrases", OR and NOT`, "warn")
|
||||||
|
}
|
||||||
|
if (!found.length) return c.view.info(`nothing found for "${query}"${everywhere ? "" : " in this project's sessions — /search " + query + " all looks in every project"}`)
|
||||||
|
const other = (id: string) => store.session(id)?.root !== c.app.project.root
|
||||||
|
c.openDialog({
|
||||||
|
title: `${found.length} session${found.length === 1 ? "" : "s"} for "${query}"`,
|
||||||
|
width: 100,
|
||||||
|
options: found.map((f) => ({
|
||||||
|
label: f.title || firstUserLine(c, f.id) || f.id,
|
||||||
|
value: f.id,
|
||||||
|
description: `${new Date(f.updated).toISOString().slice(0, 16).replace("T", " ")}${other(f.id) ? ` · ${store.session(f.id)?.root}` : ""} · ${f.role}: ${f.snippet}`,
|
||||||
|
})),
|
||||||
|
onSelect: async (id) => {
|
||||||
|
if (other(id)) return c.view.info(`that session is in ${store.session(id)?.root} — resume it there (lembas -c, /sessions)`, "warn")
|
||||||
|
const why = await (c.share?.release(id) ?? Promise.resolve(undefined))
|
||||||
|
if (why) return c.view.info(`not resumed: ${why}`, "warn")
|
||||||
|
c.view.load(c.app.resume(id))
|
||||||
|
c.view.info(`resumed ${id}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "delete",
|
name: "delete",
|
||||||
description: "delete this session (here and in the web UI) and start a new one",
|
description: "delete this session (here and in the web UI) and start a new one",
|
||||||
@@ -624,8 +779,24 @@ export const COMMANDS: Command[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "context",
|
name: "context",
|
||||||
description: "where the context window goes",
|
args: "[<subagent>]",
|
||||||
run(c) {
|
description: "where the context window goes — the session's, or a subagent's",
|
||||||
|
run(c, arg) {
|
||||||
|
// Spec 3.4.0 §12: a subagent's context, by its name or id.
|
||||||
|
const who = arg.trim()
|
||||||
|
if (who) {
|
||||||
|
const t = c.app.team.team
|
||||||
|
const a = t.find(who)
|
||||||
|
if (!a) return c.view.info(`no subagent ${who} — /team lists them`, "warn")
|
||||||
|
const h = c.app.team.handle(a.name)
|
||||||
|
if (!h || a.endState) {
|
||||||
|
const u = a.context
|
||||||
|
return c.view.info(u ? `${a.name} (${a.id}): ${u.used} of ${u.window} tokens (${Math.floor((u.used * 100) / u.window)}%) when it ${a.endState ? TEAM.read.states[a.state] : "last said"}` : `${a.name} (${a.id}): no context use known yet`)
|
||||||
|
}
|
||||||
|
const se = h.engine
|
||||||
|
const sys = se.systemPrompt()
|
||||||
|
return c.view.info(`${a.name} (${a.id}) — ${a.role}\n${renderBreakdown(contextBreakdown(se, sys, [], undefined, se.o.tools))}`)
|
||||||
|
}
|
||||||
const e = c.app.engine
|
const e = c.app.engine
|
||||||
// The prompt the session sends (made now if no request has gone yet).
|
// The prompt the session sends (made now if no request has gone yet).
|
||||||
const system = e.systemPrompt()
|
const system = e.systemPrompt()
|
||||||
@@ -649,24 +820,63 @@ export const COMMANDS: Command[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "memory",
|
name: "memory",
|
||||||
description: "what is remembered across sessions — pick an entry to change or forget it",
|
description: "what is remembered across sessions — the server's memory when it keeps it, and this machine's files — pick an entry to change or forget it",
|
||||||
run(c) {
|
async run(c) {
|
||||||
const stores = [
|
const stores = [
|
||||||
{ key: "user", title: "user", store: c.app.memory, target: "user" as const },
|
{ key: "user", title: "user", store: c.app.memory, target: "user" as const },
|
||||||
{ key: "memory", title: "notes", store: c.app.memory, target: "memory" as const },
|
{ key: "memory", title: "notes", store: c.app.memory, target: "memory" as const },
|
||||||
...(c.app.projectMemory ? [{ key: "project", title: "project", store: c.app.projectMemory, target: "memory" as const }] : []),
|
...(c.app.projectMemory ? [{ key: "project", title: "project", store: c.app.projectMemory, target: "memory" as const }] : []),
|
||||||
]
|
]
|
||||||
const entries = stores.flatMap((s) => s.store.entries(s.target).map((text, i) => ({ s, text, value: `${s.key}:${i}` })))
|
// library: lembas — the account's memory on the server (harness acp.md *Memory*), read
|
||||||
|
// through its /mcp as the prompt reads it, and changed with its memory tool.
|
||||||
|
const { LIBRARY_SERVER } = await import("../mcp/index.ts")
|
||||||
|
const { MEMORY_RESOURCE } = await import("../app.ts")
|
||||||
|
const onServer = c.app.mcp.servers.has(LIBRARY_SERVER)
|
||||||
|
const scope = c.app.engine.o.toolCtx.libraryScope
|
||||||
|
let server: string[] = []
|
||||||
|
let serverError = ""
|
||||||
|
if (onServer) {
|
||||||
|
try {
|
||||||
|
server = serverEntries(await c.app.mcp.readResource(LIBRARY_SERVER, MEMORY_RESOURCE, scope))
|
||||||
|
} catch (e) {
|
||||||
|
serverError = (e as Error).message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type Entry = { title: string; text: string; value: string; change: (op: { action: "replace" | "remove"; content?: string }) => Promise<string> | string }
|
||||||
|
const local: Entry[] = stores.flatMap((s) =>
|
||||||
|
s.store.entries(s.target).map((text, i) => ({
|
||||||
|
title: s.title,
|
||||||
|
text,
|
||||||
|
value: `${s.key}:${i}`,
|
||||||
|
change: (op: { action: "replace" | "remove"; content?: string }) => {
|
||||||
|
const r = s.store.apply(s.target, [{ ...op, old_text: text }])
|
||||||
|
if (!r.ok) throw new Error(r.message)
|
||||||
|
return r.usage
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const remote: Entry[] = server.map((text, i) => ({
|
||||||
|
title: "server",
|
||||||
|
text,
|
||||||
|
value: `server:${i}`,
|
||||||
|
change: (op: { action: "replace" | "remove"; content?: string }) => c.app.mcp.callText(LIBRARY_SERVER, "memory", { target: "user", action: op.action, old_text: text, ...(op.content !== undefined ? { content: op.content } : {}) }, scope),
|
||||||
|
}))
|
||||||
|
const entries = [...remote, ...local]
|
||||||
const files = stores.map((s) => `${s.title}: ${s.store.file(s.target)} (${s.store.usage(s.target)})`).join("\n")
|
const files = stores.map((s) => `${s.title}: ${s.store.file(s.target)} (${s.store.usage(s.target)})`).join("\n")
|
||||||
if (!entries.length) return c.view.info(`nothing remembered yet — the agent saves facts with its memory tool, or tell it "remember that …"\n${files}`)
|
const where = onServer ? `the server's memory (${serverError ? `could not be read: ${serverError}` : `${server.length} entr${server.length === 1 ? "y" : "ies"}`}) is what sessions here use; this machine's files:\n${files}` : `the files, which you can also edit by hand (entries are separated by a line with §):\n${files}`
|
||||||
const change = (e: (typeof entries)[number], op: { action: "replace" | "remove"; content?: string }) => {
|
if (!entries.length) return c.view.info(`nothing remembered yet — the agent saves facts with its memory tool, or tell it "remember that …"\n${where}`)
|
||||||
const r = e.s.store.apply(e.s.target, [{ ...op, old_text: e.text }])
|
const change = async (e: Entry, op: { action: "replace" | "remove"; content?: string }) => {
|
||||||
c.view.info(r.ok ? `${e.s.title}: ${op.action === "remove" ? "forgotten" : "changed"} — the agent sees it from the next session (${r.usage})` : r.message, r.ok ? "info" : "warn")
|
try {
|
||||||
|
const said = await e.change(op)
|
||||||
|
c.view.info(`${e.title}: ${op.action === "remove" ? "forgotten" : "changed"} — the agent sees it from the next session${said && e.title !== "server" ? ` (${said})` : ""}`)
|
||||||
|
} catch (err) {
|
||||||
|
c.view.info(`${e.title}: ${(err as Error).message}`, "warn")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c.openDialog({
|
c.openDialog({
|
||||||
title: "Memory — enter to change or forget",
|
title: "Memory — enter to change or forget",
|
||||||
filter: true,
|
filter: true,
|
||||||
options: entries.map((e) => ({ label: `[${e.s.title}] ${e.text.replace(/\s+/g, " ").slice(0, 90)}`, value: e.value })),
|
options: entries.map((e) => ({ label: `[${e.title}] ${e.text.replace(/\s+/g, " ").slice(0, 90)}`, value: e.value })),
|
||||||
onSelect: (v) => {
|
onSelect: (v) => {
|
||||||
const e = entries.find((x) => x.value === v)!
|
const e = entries.find((x) => x.value === v)!
|
||||||
c.openDialog({
|
c.openDialog({
|
||||||
@@ -675,12 +885,12 @@ export const COMMANDS: Command[] = [
|
|||||||
{ label: "Forget it", value: "remove" },
|
{ label: "Forget it", value: "remove" },
|
||||||
{ label: "Keep it", value: "keep" },
|
{ label: "Keep it", value: "keep" },
|
||||||
],
|
],
|
||||||
onSelect: (a) => a === "remove" && change(e, { action: "remove" }),
|
onSelect: (a) => void (a === "remove" && change(e, { action: "remove" })),
|
||||||
custom: { label: "replace with", onSubmit: (text) => change(e, { action: "replace", content: text }) },
|
custom: { label: "replace with", onSubmit: (text) => void change(e, { action: "replace", content: text }) },
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
c.view.info(`the files, which you can also edit by hand (entries are separated by a line with §):\n${files}`)
|
c.view.info(where)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -880,6 +1090,64 @@ export const COMMANDS: Command[] = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "schedule",
|
||||||
|
aliases: ["schedules"],
|
||||||
|
args: "[list | new <words> | pause|resume|run|history <id or title>]",
|
||||||
|
description: "your schedules on the LLeMbas server: list, create from words, pause, resume, run now, history",
|
||||||
|
async run(c, arg) {
|
||||||
|
const S = await import("../lembas/schedules.ts")
|
||||||
|
const [sub = "list", ...rest] = arg.trim().split(/\s+/).filter(Boolean)
|
||||||
|
const words = arg.trim().slice(sub.length).trim()
|
||||||
|
const say = (lines: string[], level?: "warn") => c.view.info(lines.join("\n"), level)
|
||||||
|
try {
|
||||||
|
if (sub === "list" || sub === "ls") return say(S.listLines(await S.listSchedules()))
|
||||||
|
if (sub === "new" || sub === "create" || sub === "add") {
|
||||||
|
if (!words) return say(["usage: /schedule new <what, and when> — e.g. /schedule new every weekday at 8:30 summarise the open issues"], "warn")
|
||||||
|
c.view.info("working out what that means…")
|
||||||
|
const compiled = await S.compileWords(words)
|
||||||
|
const preview = S.previewLines(compiled)
|
||||||
|
if (!Object.keys(compiled.rule ?? {}).length) return say([...preview, "", "No schedule made: say when it should run (\"every Monday at 9\", \"tomorrow at 7\")."], "warn")
|
||||||
|
const cwd = c.app.engine.o.toolCtx.cwd
|
||||||
|
const { coveredBy, remoteState } = await import("../config/remote.ts")
|
||||||
|
const here = S.canRunHere() && coveredBy(cwd, remoteState().roots) !== undefined
|
||||||
|
c.openDialog({
|
||||||
|
title: preview[0]!,
|
||||||
|
options: [
|
||||||
|
{ label: "Create it", value: "server", description: preview.slice(1).join(" · ") || "runs on the server" },
|
||||||
|
...(here ? [{ label: "Create it, to run on this computer", value: "here", description: `as an agent turn in ${cwd}, in Edit` }] : []),
|
||||||
|
{ label: "Cancel", value: "cancel" },
|
||||||
|
],
|
||||||
|
onSelect: (v) => {
|
||||||
|
if (v === "cancel") return c.view.info("no schedule made")
|
||||||
|
void S.createSchedule(compiled, words, v === "here" ? { cwd } : undefined)
|
||||||
|
.then((made) => c.view.info(`made ${made.title} (${made.id}) — next ${S.when(made.next_run)}`))
|
||||||
|
.catch((e) => c.view.info(`/schedule: ${(e as Error).message}`, "warn"))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (["pause", "resume", "run", "history", "runs"].includes(sub)) {
|
||||||
|
const found = S.findSchedule(await S.listSchedules(), rest.join(" "))
|
||||||
|
if (typeof found === "string") return say([found], "warn")
|
||||||
|
if (sub === "pause") return say([`paused ${(await S.pauseSchedule(found.id)).title}`])
|
||||||
|
if (sub === "resume") {
|
||||||
|
const s = await S.resumeSchedule(found.id)
|
||||||
|
return say([`resumed ${s.title} — next ${S.when(s.next_run)}`])
|
||||||
|
}
|
||||||
|
if (sub === "run") {
|
||||||
|
const r = await S.runSchedule(found.id)
|
||||||
|
return say([`${found.title}: started now (${r.id}) — the next scheduled run stays as it was; /schedule history ${found.id}`])
|
||||||
|
}
|
||||||
|
const h = await S.scheduleRuns(found.id)
|
||||||
|
return say(S.historyLines(found, h.runs, h.more))
|
||||||
|
}
|
||||||
|
say([`usage: /schedule ${COMMANDS.find((x) => x.name === "schedule")!.args}`], "warn")
|
||||||
|
} catch (e) {
|
||||||
|
say([`/schedule: ${(e as Error).message}`], "warn")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "logout",
|
name: "logout",
|
||||||
args: "[connection]",
|
args: "[connection]",
|
||||||
@@ -934,7 +1202,7 @@ export const COMMANDS: Command[] = [
|
|||||||
e.def.kind === "model"
|
e.def.kind === "model"
|
||||||
? c.app.modelRefs()
|
? c.app.modelRefs()
|
||||||
: e.def.kind === "effort"
|
: e.def.kind === "effort"
|
||||||
? [...effortsFor(c.app.engine.model), "off"]
|
? [...levelsFor(c.app.engine.model).levels]
|
||||||
: e.def.kind === "boolean"
|
: e.def.kind === "boolean"
|
||||||
? ["true", "false"]
|
? ["true", "false"]
|
||||||
: key === "theme"
|
: key === "theme"
|
||||||
@@ -943,7 +1211,7 @@ export const COMMANDS: Command[] = [
|
|||||||
? [...(e.def.values ?? [])]
|
? [...(e.def.values ?? [])]
|
||||||
: []
|
: []
|
||||||
c.openDialog({
|
c.openDialog({
|
||||||
title: `${key} — now ${show(e.value) || e.def.fallback || "unset"}${e.source === "default" ? "" : ` (${e.source})`}`,
|
title: `${key} — now ${show(e.value) || e.def.fallback || "unset"}${e.source === "default" ? "" : ` (${e.source})`}${e.def.hint ? ` · ${e.def.hint}` : ""}`,
|
||||||
options: values.map((v) => ({ label: v, value: v, current: v === show(e.value) })),
|
options: values.map((v) => ({ label: v, value: v, current: v === show(e.value) })),
|
||||||
onSelect: (v) => askScope(key, v),
|
onSelect: (v) => askScope(key, v),
|
||||||
custom: e.def.kind === "enum" || e.def.kind === "boolean" || e.def.kind === "effort" ? undefined : { label: "set to", onSubmit: (v) => askScope(key, v) },
|
custom: e.def.kind === "enum" || e.def.kind === "boolean" || e.def.kind === "effort" ? undefined : { label: "set to", onSubmit: (v) => askScope(key, v) },
|
||||||
@@ -959,7 +1227,7 @@ export const COMMANDS: Command[] = [
|
|||||||
options: S.list().map((e) => ({
|
options: S.list().map((e) => ({
|
||||||
label: e.def.key,
|
label: e.def.key,
|
||||||
value: e.def.key,
|
value: e.def.key,
|
||||||
description: `${show(e.value) || e.def.fallback || "unset"}${e.source === "default" ? "" : ` · ${e.source}`}${e.def.reload ? " · after restart" : ""}`,
|
description: `${show(e.value) || e.def.fallback || "unset"}${e.source === "default" ? "" : ` · ${e.source}`}${e.def.reload ? " · after restart" : ""}${e.def.hint ? ` · ${e.def.hint}` : ""}`,
|
||||||
})),
|
})),
|
||||||
onSelect: askValue,
|
onSelect: askValue,
|
||||||
})
|
})
|
||||||
@@ -1191,6 +1459,19 @@ export function findCommand(name: string): Command | undefined {
|
|||||||
/** The sessions /sessions offers: this project's, newest first, not the one open here — and only
|
/** The sessions /sessions offers: this project's, newest first, not the one open here — and only
|
||||||
* those somebody said something in, as `lembas sessions` and the web UI list them: every
|
* those somebody said something in, as `lembas sessions` and the web UI list them: every
|
||||||
* start and /new makes one, and the empty ones crowded out the rest as rows named by their id. */
|
* start and /new makes one, and the empty ones crowded out the rest as rows named by their id. */
|
||||||
|
/** The server's memory block (`lembas://memory`) as entries: each `- ` line starts one, and the
|
||||||
|
* lines under it belong to it; the block's own "(…N more" line is not an entry. */
|
||||||
|
export function serverEntries(block: string): string[] {
|
||||||
|
const out: string[] = []
|
||||||
|
for (const line of block.split("\n")) {
|
||||||
|
if (line.startsWith("- ")) {
|
||||||
|
if (/^- \(…\d+ more/.test(line)) continue
|
||||||
|
out.push(line.slice(2))
|
||||||
|
} else if (out.length && line.trim()) out[out.length - 1] += `\n${line}`
|
||||||
|
}
|
||||||
|
return out.map((e) => e.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
function otherSessions(c: CommandCtx) {
|
function otherSessions(c: CommandCtx) {
|
||||||
return c.app.store?.sessions(40, c.app.project.root, true).filter((s) => s.id !== c.app.engine.sessionId) ?? []
|
return c.app.store?.sessions(40, c.app.project.root, true).filter((s) => s.id !== c.app.engine.sessionId) ?? []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,31 @@ export interface Option {
|
|||||||
value: string
|
value: string
|
||||||
description?: string
|
description?: string
|
||||||
current?: boolean
|
current?: boolean
|
||||||
|
/** A two-handle range on this row (the Subagents setting, harness team.md §2: one range from 1
|
||||||
|
* to the cap, Min and Max). While the row is highlighted ←→ move the handle in focus and tab
|
||||||
|
* moves the focus; Min never passes Max. Enter calls `onSubmit` with both, not `onSelect`. */
|
||||||
|
range?: Range
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Range {
|
||||||
|
/** The bounds of the track. */
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
/** The handles' values. */
|
||||||
|
lo: number
|
||||||
|
hi: number
|
||||||
|
/** The handles' names (Min, Max). */
|
||||||
|
names: [string, string]
|
||||||
|
onSubmit: (lo: number, hi: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The track as text: `─` outside the range, `━` inside it, `●` for a handle. */
|
||||||
|
export function rangeTrack(r: Pick<Range, "from" | "to">, lo: number, hi: number, width = 16): { before: string; lo: string; between: string; hi: string; after: string } {
|
||||||
|
const span = Math.max(1, r.to - r.from)
|
||||||
|
const at = (v: number) => Math.round(((v - r.from) / span) * (width - 1))
|
||||||
|
const a = at(lo)
|
||||||
|
const b = Math.max(at(hi), a + (hi > lo ? 1 : 0))
|
||||||
|
return { before: "─".repeat(a), lo: "●", between: "━".repeat(Math.max(0, b - a - 1)), hi: b > a ? "●" : "", after: "─".repeat(Math.max(0, width - 1 - b)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DialogSpec {
|
export interface DialogSpec {
|
||||||
@@ -45,6 +70,16 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
|
|||||||
const hits = q ? p.spec.options.filter((o) => (o.label + " " + (o.description ?? "")).toLowerCase().includes(q)) : p.spec.options
|
const hits = q ? p.spec.options.filter((o) => (o.label + " " + (o.description ?? "")).toLowerCase().includes(q)) : p.spec.options
|
||||||
return p.spec.custom && query().trim() ? [{ label: `${p.spec.custom.label} "${query().trim()}"`, value: CUSTOM }, ...hits] : hits
|
return p.spec.custom && query().trim() ? [{ label: `${p.spec.custom.label} "${query().trim()}"`, value: CUSTOM }, ...hits] : hits
|
||||||
})
|
})
|
||||||
|
// A range row's handles, moved while the dialog is open and handed over on enter; esc drops them.
|
||||||
|
const [ranges, setRanges] = createSignal<Record<string, [number, number]>>({})
|
||||||
|
const [focus, setFocus] = createSignal<0 | 1>(1)
|
||||||
|
const valuesOf = (o: Option): [number, number] => ranges()[o.value] ?? [o.range!.lo, o.range!.hi]
|
||||||
|
const move = (o: Option, by: number) => {
|
||||||
|
const r = o.range!
|
||||||
|
const [lo, hi] = valuesOf(o)
|
||||||
|
const next: [number, number] = focus() === 0 ? [Math.min(hi, Math.max(r.from, lo + by)), hi] : [lo, Math.min(r.to, Math.max(lo, hi + by))]
|
||||||
|
setRanges((m) => ({ ...m, [o.value]: next }))
|
||||||
|
}
|
||||||
const start = Math.max(0, p.spec.options.findIndex((o) => o.current))
|
const start = Math.max(0, p.spec.options.findIndex((o) => o.current))
|
||||||
const [sel, setSel] = createSignal(start)
|
const [sel, setSel] = createSignal(start)
|
||||||
// The spec is live: a list that redraws itself with fewer rows keeps the cursor on a row.
|
// The spec is live: a list that redraws itself with fewer rows keeps the cursor on a row.
|
||||||
@@ -70,6 +105,8 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
|
|||||||
run(o.value)
|
run(o.value)
|
||||||
}
|
}
|
||||||
} else if (key.name === "escape") p.close()
|
} else if (key.name === "escape") p.close()
|
||||||
|
else if (shown()[sel()]?.range && (key.name === "left" || key.name === "right")) move(shown()[sel()]!, key.name === "left" ? -1 : 1)
|
||||||
|
else if (shown()[sel()]?.range && key.name === "tab") setFocus((f) => (f === 0 ? 1 : 0))
|
||||||
else if (key.name === "up" || (key.ctrl && key.name === "p")) setSel((i) => (i + n - 1) % Math.max(n, 1))
|
else if (key.name === "up" || (key.ctrl && key.name === "p")) setSel((i) => (i + n - 1) % Math.max(n, 1))
|
||||||
else if (key.name === "down" || (key.ctrl && key.name === "n")) setSel((i) => (i + 1) % Math.max(n, 1))
|
else if (key.name === "down" || (key.ctrl && key.name === "n")) setSel((i) => (i + 1) % Math.max(n, 1))
|
||||||
else if (key.name === "return") {
|
else if (key.name === "return") {
|
||||||
@@ -79,8 +116,10 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
|
|||||||
const onSelect = p.spec.onSelect
|
const onSelect = p.spec.onSelect
|
||||||
const custom = p.spec.custom
|
const custom = p.spec.custom
|
||||||
const typed = query().trim()
|
const typed = query().trim()
|
||||||
|
const range = o.range && { submit: o.range.onSubmit, values: valuesOf(o) }
|
||||||
p.close()
|
p.close()
|
||||||
if (o.value === CUSTOM) custom?.onSubmit(typed)
|
if (o.value === CUSTOM) custom?.onSubmit(typed)
|
||||||
|
else if (range) range.submit(...range.values)
|
||||||
else onSelect(o.value)
|
else onSelect(o.value)
|
||||||
}
|
}
|
||||||
} else if (filterable() && key.name === "backspace") {
|
} else if (filterable() && key.name === "backspace") {
|
||||||
@@ -104,6 +143,30 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
|
|||||||
<For each={shown().slice(offset(), offset() + visible())}>
|
<For each={shown().slice(offset(), offset() + visible())}>
|
||||||
{(o, i) => {
|
{(o, i) => {
|
||||||
const active = () => i() + offset() === sel()
|
const active = () => i() + offset() === sel()
|
||||||
|
if (o.range) {
|
||||||
|
const values = () => valuesOf(o)
|
||||||
|
const track = () => rangeTrack(o.range!, values()[0], values()[1])
|
||||||
|
const handle = (n: 0 | 1) => (active() && focus() === n ? c().accent : active() ? c().title : c().text)
|
||||||
|
return (
|
||||||
|
<box flexDirection="row" backgroundColor={active() ? c().menuCurrent : undefined}>
|
||||||
|
<text wrapMode="none" truncate fg={active() ? c().title : c().text} attributes={active() ? TextAttributes.BOLD : 0}>
|
||||||
|
{" "}
|
||||||
|
{o.label}
|
||||||
|
{" "}
|
||||||
|
<span style={{ fg: handle(0) }}>{`${o.range.names[0]} ${values()[0]}`}</span>
|
||||||
|
{" "}
|
||||||
|
<span style={{ fg: c().dim }}>{track().before}</span>
|
||||||
|
<span style={{ fg: handle(0) }}>{track().lo}</span>
|
||||||
|
<span style={{ fg: c().accent }}>{track().between}</span>
|
||||||
|
<span style={{ fg: handle(1) }}>{track().hi}</span>
|
||||||
|
<span style={{ fg: c().dim }}>{track().after}</span>
|
||||||
|
{" "}
|
||||||
|
<span style={{ fg: handle(1) }}>{`${o.range.names[1]} ${values()[1]}`}</span>
|
||||||
|
<span style={{ fg: c().muted }}>{o.description ? ` ${o.description}` : ""}</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" backgroundColor={active() ? c().menuCurrent : undefined}>
|
<box flexDirection="row" backgroundColor={active() ? c().menuCurrent : undefined}>
|
||||||
<text wrapMode="none" truncate fg={active() ? c().title : c().text} attributes={active() ? TextAttributes.BOLD : 0}>
|
<text wrapMode="none" truncate fg={active() ? c().title : c().text} attributes={active() ? TextAttributes.BOLD : 0}>
|
||||||
@@ -118,7 +181,11 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
|
|||||||
<Show when={shown().length === 0}>
|
<Show when={shown().length === 0}>
|
||||||
<text fg={c().muted}>nothing matches</text>
|
<text fg={c().muted}>nothing matches</text>
|
||||||
</Show>
|
</Show>
|
||||||
<text fg={c().dim}>{p.spec.hint ?? "↑↓ move · enter choose · esc close"}</text>
|
<text fg={c().dim}>
|
||||||
|
{shown()[sel()]?.range
|
||||||
|
? `←→ move ${shown()[sel()]!.range!.names[focus()]} · tab ${shown()[sel()]!.range!.names[focus() === 0 ? 1 : 0]} · enter set · esc close`
|
||||||
|
: (p.spec.hint ?? "↑↓ move · enter choose · esc close")}
|
||||||
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { createSignal, For, onCleanup } from "solid-js"
|
import { createSignal, For, onCleanup } from "solid-js"
|
||||||
import { duration } from "../../duration.ts"
|
import { duration } from "../../duration.ts"
|
||||||
|
import { progressLine } from "../../session/progress.ts"
|
||||||
import { useTui } from "../context.ts"
|
import { useTui } from "../context.ts"
|
||||||
import { fmtTokens } from "./status.tsx"
|
import { fmtTokens } from "./status.tsx"
|
||||||
|
|
||||||
/** "reading the prompt 64% · 90k of 140k · ~2m left": the server's own count (llama.cpp's
|
/** "reading the prompt 64% · 90k of 140k · ~2m left": the server's own count (llama.cpp's
|
||||||
* return_progress). The time left is guessed from the reading so far, once there is enough of it;
|
* return_progress). The time left is guessed from the reading so far, once there is enough of it;
|
||||||
* a long prompt reads slower towards its end, so it is a floor more than a promise. */
|
* a long prompt reads slower towards its end, so it is a floor more than a promise. */
|
||||||
|
/** How long the rest of the prompt will take, from the server's pace so far; "" until it is known. */
|
||||||
|
export function etaOf(p: { total: number; done: number; processed: number; ms: number }): string {
|
||||||
|
if (p.processed >= 2048 && p.ms >= 2000 && p.done < p.total) return `~${duration(((p.total - p.done) / p.processed) * p.ms, true)} left`
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
export function reading(p: { total: number; done: number; processed: number; ms: number }): string {
|
export function reading(p: { total: number; done: number; processed: number; ms: number }): string {
|
||||||
const pct = Math.floor((p.done / p.total) * 100)
|
const pct = Math.floor((p.done / p.total) * 100)
|
||||||
const parts = [`reading the prompt ${pct}%`, `${fmtTokens(p.done)} of ${fmtTokens(p.total)}`]
|
const parts = [`reading the prompt ${pct}%`, `${fmtTokens(p.done)} of ${fmtTokens(p.total)}`]
|
||||||
@@ -34,6 +41,16 @@ export function Spinner() {
|
|||||||
}
|
}
|
||||||
const what = () => {
|
const what = () => {
|
||||||
const s = view.state
|
const s = view.state
|
||||||
|
// Spec 3.4.0: the phase the engine says, drawn as every client draws it (progress.md §3).
|
||||||
|
if (s.now) {
|
||||||
|
tick()
|
||||||
|
// The CLI's own look around the shared line: how long the rest of the prompt will take.
|
||||||
|
// The server's counts as they come (the phase event is paced for the link; the screen is not).
|
||||||
|
const reading = s.now.phase === "reading_prompt" && s.progress
|
||||||
|
const now = reading ? { ...s.now, done: s.progress!.done, total: s.progress!.total } : s.now
|
||||||
|
const eta = reading ? etaOf(s.progress!) : ""
|
||||||
|
return `${progressLine(now)}${eta ? ` · ${eta}` : ""}`
|
||||||
|
}
|
||||||
if (s.preparing) return `preparing ${s.preparing}`
|
if (s.preparing) return `preparing ${s.preparing}`
|
||||||
if (s.phase === "waiting" && s.progress) return reading(s.progress)
|
if (s.phase === "waiting" && s.progress) return reading(s.progress)
|
||||||
if (s.phase === "thinking") return pick(sp().verbs, 30)
|
if (s.phase === "thinking") return pick(sp().verbs, 30)
|
||||||
@@ -45,9 +62,9 @@ export function Spinner() {
|
|||||||
<box paddingLeft={1} height={1}>
|
<box paddingLeft={1} height={1}>
|
||||||
<text wrapMode="none" truncate>
|
<text wrapMode="none" truncate>
|
||||||
<span style={{ fg: theme().colors.accent }}>{pick(sp().frames, 1)} </span>
|
<span style={{ fg: theme().colors.accent }}>{pick(sp().frames, 1)} </span>
|
||||||
<span style={{ fg: theme().colors.muted }}>{what()}… </span>
|
<span style={{ fg: theme().colors.muted }}>{what()}{view.state.now ? " · " : "… "}</span>
|
||||||
<span style={{ fg: theme().colors.dim }}>
|
<span style={{ fg: theme().colors.dim }}>
|
||||||
{elapsed()} · {tokens()} · esc to stop
|
{view.state.now ? `turn ${elapsed()}` : elapsed()} · {tokens()} · esc to stop
|
||||||
</span>
|
</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function jobsWord(jobs: { state: string; unread: number; unseen?: boolean
|
|||||||
* for a moment, a flash (a copy) in their place. The jobs word sits on the right, which is never
|
* for a moment, a flash (a copy) in their place. The jobs word sits on the right, which is never
|
||||||
* truncated: at 80 columns a long project name cuts the left short, and a running job is the one
|
* truncated: at 80 columns a long project name cuts the left short, and a running job is the one
|
||||||
* thing that must stay visible. */
|
* thing that must stay visible. */
|
||||||
export function StatusBar(p: { mode: () => string; model: () => string; effort: () => string; branch?: string; voice?: () => string; flash?: () => string | undefined; remote?: () => string; spend?: () => string }) {
|
export function StatusBar(p: { mode: () => string; model: () => string; effort: () => string; branch?: string; voice?: () => string; flash?: () => string | undefined; remote?: () => string; spend?: () => string; team?: () => string }) {
|
||||||
const { theme, view, app } = useTui()
|
const { theme, view, app } = useTui()
|
||||||
const c = () => theme().colors
|
const c = () => theme().colors
|
||||||
const modeColor = () => ({ manual: c().statusText, edit: c().statusGood, auto: c().statusCritical, plan: c().info })[p.mode() as keyof typeof MODE_LABEL] ?? c().statusText
|
const modeColor = () => ({ manual: c().statusText, edit: c().statusGood, auto: c().statusCritical, plan: c().info })[p.mode() as keyof typeof MODE_LABEL] ?? c().statusText
|
||||||
@@ -50,6 +50,7 @@ export function StatusBar(p: { mode: () => string; model: () => string; effort:
|
|||||||
when={p.flash?.()}
|
when={p.flash?.()}
|
||||||
fallback={
|
fallback={
|
||||||
<text wrapMode="none">
|
<text wrapMode="none">
|
||||||
|
<span style={{ fg: c().info }}>{p.team?.() ? `${p.team()} · ` : ""}</span>
|
||||||
<span style={{ fg: jobsWord(view.state.jobs).endsWith("!") ? c().statusWarn : c().statusText }}>{jobsWord(view.state.jobs) ? `${jobsWord(view.state.jobs)} · ` : ""}</span>
|
<span style={{ fg: jobsWord(view.state.jobs).endsWith("!") ? c().statusWarn : c().statusText }}>{jobsWord(view.state.jobs) ? `${jobsWord(view.state.jobs)} · ` : ""}</span>
|
||||||
<span style={{ fg: c().statusDim }}>{view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""}</span>
|
<span style={{ fg: c().statusDim }}>{view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""}</span>
|
||||||
<span style={{ fg: c().statusText }}>{p.spend?.() ? `${p.spend()} · ` : ""}</span>
|
<span style={{ fg: c().statusText }}>{p.spend?.() ? `${p.spend()} · ` : ""}</span>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function SubagentView(p: { id: string; view: View; scrollRef?: (r: Scroll
|
|||||||
const j = job()
|
const j = job()
|
||||||
if (!j) return `subagent · ${p.id} · gone · esc back`
|
if (!j) return `subagent · ${p.id} · gone · esc back`
|
||||||
const state = j.state === "running" ? `running ${duration(Date.now() - j.started, true)}` : jobWords(j)
|
const state = j.state === "running" ? `running ${duration(Date.now() - j.started, true)}` : jobWords(j)
|
||||||
const message = j.subagent ? "type to message" : "cannot be messaged (an earlier process ran it)"
|
const message = j.team ? `read-only: ${j.team} works for the main agent` : j.subagent ? "type to message" : "cannot be messaged (an earlier process ran it)"
|
||||||
return [`subagent ${j.agent ?? ""}`.trimEnd(), j.id, state, "esc back", message].join(" · ")
|
return [`subagent ${j.agent ?? ""}`.trimEnd(), j.id, state, "esc back", message].join(" · ")
|
||||||
}
|
}
|
||||||
const what = () => job()?.description ?? ""
|
const what = () => job()?.description ?? ""
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// The finished-reply notice: when a reply finishes, or the agent waits for an answer, while the
|
||||||
|
// terminal is not focused, the bell rings and the terminal is asked for a desktop notification
|
||||||
|
// (OSC 9 — iTerm2, WezTerm, kitty, Windows Terminal, foot and others show it; the rest ignore it).
|
||||||
|
// It names the session and what happened, never what the reply says: a desktop notification may
|
||||||
|
// be seen by anyone near the screen. `notify: false` turns it off.
|
||||||
|
|
||||||
|
/** The words of the notice: the session's title (or "LLeMbas") and what happened. */
|
||||||
|
export function noticeText(title: string | undefined, what: "finished" | "waiting" | "failed"): string {
|
||||||
|
const name = (title ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 80) || "LLeMbas"
|
||||||
|
const said = what === "finished" ? "finished" : what === "waiting" ? "waits for you" : "stopped with an error"
|
||||||
|
return `${name}: ${said}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bytes sent to the terminal: OSC 9 with the text, then the bell. */
|
||||||
|
export function noticeSequence(text: string): string {
|
||||||
|
return `\x1b]9;${text.replace(/[\u0000-\u001f\u007f-\u009f;]/g, " ")}\x07\x07`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Follows the terminal's focus (OpenTUI's focus and blur events, from focus reporting) and says
|
||||||
|
* whether a notice is due: only once the terminal has said it lost focus — a terminal that
|
||||||
|
* reports nothing is taken as focused, so it never rings for nothing. */
|
||||||
|
export class FocusWatch {
|
||||||
|
focused = true
|
||||||
|
constructor(on?: (event: "focus" | "blur", fn: () => void) => void) {
|
||||||
|
on?.("focus", () => (this.focused = true))
|
||||||
|
on?.("blur", () => (this.focused = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-1
@@ -82,7 +82,7 @@ export interface Pending {
|
|||||||
/** What the model says the call is for. */
|
/** What the model says the call is for. */
|
||||||
purpose?: string
|
purpose?: string
|
||||||
/** The subagent (running as a job) that asks, when one does. */
|
/** The subagent (running as a job) that asks, when one does. */
|
||||||
source?: { jobId: string; agent: string; description: string }
|
source?: { jobId: string; agent: string; description: string; /** Spec 3.4.0: a subagent of the tree. */ subagent?: { id: string; name: string; position: string } }
|
||||||
resolve: (r: AskReply) => void
|
resolve: (r: AskReply) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +120,8 @@ export interface ViewState {
|
|||||||
progress?: { total: number; done: number; processed: number; ms: number }
|
progress?: { total: number; done: number; processed: number; ms: number }
|
||||||
/** The session's background jobs (the registry's list, kept from `job` events), running first. */
|
/** The session's background jobs (the registry's list, kept from `job` events), running first. */
|
||||||
jobs: TrackedJob[]
|
jobs: TrackedJob[]
|
||||||
|
/** Spec 3.4.0 (harness/progress.md): what the agent is doing now — the spinner line. */
|
||||||
|
now?: import("../session/progress.ts").Progress | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the model was given beside the words typed — attachments, and a session note
|
// What the model was given beside the words typed — attachments, and a session note
|
||||||
@@ -285,6 +287,9 @@ export function createView(bus: Bus) {
|
|||||||
if (e.state === "start") startTask()
|
if (e.state === "start") startTask()
|
||||||
else inTask = false
|
else inTask = false
|
||||||
break
|
break
|
||||||
|
case "phase":
|
||||||
|
set("now", e.progress ? { ...e.progress } : null)
|
||||||
|
break
|
||||||
case "sub_tool":
|
case "sub_tool":
|
||||||
set(
|
set(
|
||||||
"items",
|
"items",
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ test("a background subagent: its transcript as history turns; send to a finished
|
|||||||
], { route: (model) => (model.startsWith("g/") ? child : undefined) })
|
], { route: (model) => (model.startsWith("g/") ? child : undefined) })
|
||||||
config(fake.url, true)
|
config(fake.url, true)
|
||||||
const { c, events, sid } = await client()
|
const { c, events, sid } = await client()
|
||||||
await c.request("session/prompt", { sessionId: sid, prompt: [{ type: "text", text: "go" }] })
|
await c.request("session/prompt", { sessionId: sid, prompt: [{ type: "text", text: "go (a subagent on g/m)" }] })
|
||||||
expect(await until(() => events.some((e) => e.type === "job" && e.state === "end"))).toBe(true)
|
expect(await until(() => events.some((e) => e.type === "job" && e.state === "end"))).toBe(true)
|
||||||
const listed: any = await c.request("_lembas/jobs", { sessionId: sid })
|
const listed: any = await c.request("_lembas/jobs", { sessionId: sid })
|
||||||
expect(listed.jobs[0]).toMatchObject({ id: "job1", kind: "subagent", state: "done", agent: "explore", description: "find the bug", model: "g/m" })
|
expect(listed.jobs[0]).toMatchObject({ id: "job1", kind: "subagent", state: "done", agent: "explore", description: "find the bug", model: "g/m" })
|
||||||
|
|||||||
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user