Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a82e6cb61
|
||
|
|
536fd2aa45
|
||
|
|
d3af8c2f02
|
||
|
|
6ec95fbd66
|
||
|
|
fbefe7f844
|
||
|
|
5c23087cef
|
||
|
|
880289970b
|
||
|
|
d09d51cfec
|
||
|
|
fdfcf3666a
|
No files matched your search
@@ -4,6 +4,44 @@ All notable changes to LLeMbas CLI. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
||||
[Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [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
|
||||
|
||||
Harness spec 3.1.0: what the model has already read stays exactly as it was, so its server
|
||||
|
||||
+31
-4
@@ -21,10 +21,11 @@ page *Harness-parity* in LLeMbas CLI's wiki.
|
||||
| `permission/arity.json` | How much of a command an "always allow" covers |
|
||||
| `permission/defaults.json` | The rules every session starts from |
|
||||
| `permission/modes.json` | The four modes — manual, edit, auto, plan — as what each does with each risk class |
|
||||
| `models/schema.json` | Model metadata: context, output limit, efforts, vision, tools, notes, capacity |
|
||||
| `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 |
|
||||
| `models/schema.json` | Model metadata: context, output limit, efforts and the thinking switch, vision, tools, notes, capacity (`parallel`); `levels` — the effort levels a chat offers and what each sends |
|
||||
| `loop.json` | How one prompt runs: the step backstop, budgets, wrap-up, nudges, parallel calls, capacity; `jobs` — background work, its marks and limits; `team` — a team's size, names, marks, refusals and texts; `context` — result ids, stubs, the output cap, the compaction table, the environment lines, the session notes |
|
||||
| `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 |
|
||||
| `quirks.json` | What real servers do, and how both harnesses cope |
|
||||
| `conformance/<area>.json` | Cases with their expected results, run by both test suites |
|
||||
| `acp.md` | The link between them: ACP plus every `_lembas/*` method, field and capability |
|
||||
@@ -47,7 +48,8 @@ page *Harness-parity* in LLeMbas CLI's wiki.
|
||||
page of the chat is open and attached (not a schedule, not a background job); `display`: the
|
||||
session is shown somewhere a file can be opened (the web UI, Desktop; not a bare terminal);
|
||||
`forge`: the session's view holds a repository mounted from a forge; `plan`: the session is in
|
||||
plan mode. 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, on a model that
|
||||
serves two requests at once (`team.md` §2). A requirement that goes away between the offer and the call makes the call answer the
|
||||
tool's `unavailable` text, word for word, as its result. **From 3.1.0 a requirement that can come
|
||||
and go during a session does not take the tool out of the list** (`context.md` §10, rule 4): the
|
||||
tool is offered from the start and answers `unavailable` while the requirement is not met.
|
||||
@@ -66,6 +68,31 @@ page *Harness-parity* in LLeMbas CLI's wiki.
|
||||
approval in plan mode and answers its `unavailable` text outside it. Their permission key is
|
||||
`plan` (`permission/defaults.json`), which a rule written for the former `todo` key still reaches.
|
||||
|
||||
## 3.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
|
||||
|
||||
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.2.0
|
||||
+3
-3
@@ -112,7 +112,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`
|
||||
(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
|
||||
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 } } }`.
|
||||
`instance` (spec 3.0.0) is a boolean, with the `model` event's meaning: true exactly when the
|
||||
@@ -284,7 +284,7 @@ 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 |
|
||||
| `steered` | `texts`, `ids?` — the `_lembas/steer` message ids taken in at this step |
|
||||
| `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) |
|
||||
| `compacted` | `summary` |
|
||||
| `title` | `id`, `title` |
|
||||
@@ -301,7 +301,7 @@ Only on the link (an error on `serve --stdio`).
|
||||
|
||||
### `_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> }` between prompts (a level as in `session/new`). Result
|
||||
`{ model: <ref>, effort }`. Emits a `model` event when something changed.
|
||||
|
||||
### `_lembas/steer` — client → agent → result
|
||||
|
||||
@@ -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,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,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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+58
-6
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
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`.
|
||||
@@ -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 —
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
reasoning a template keeps nor adds what it drops.
|
||||
From 3.2.0 (it was left to the chat template before). A model that thinks before each tool call
|
||||
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
|
||||
|
||||
`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
|
||||
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,
|
||||
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
|
||||
`/`. 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
|
||||
@@ -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
|
||||
**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
|
||||
|
||||
|
||||
+103
-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.",
|
||||
"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). A team's members are not refused there: they wait for a slot (team.md, Capacity).",
|
||||
"jobs": {
|
||||
"about": "Background work owned by a session: a command (bash `background: true`, or one still running when its timeout passed) or a subagent (task `background: true`). One id sequence per session (job1, job2, …), one list (job_list), one way to read (job_output), to stop (job_stop) and, for a subagent, to message (job_send); a subagent's own tools see only the jobs it started. A job that ends wakes the session that owns it: while a prompt runs, as a user message at the next step boundary; otherwise as a new turn of its own, marked `machine` so a UI shows it as the harness's, not the person's. A session that is not open keeps the note until it is resumed. Every such message begins with its mark, said rather than hidden: a model refuses an unmarked one as an injection (conformance/jobs.json gives the texts).",
|
||||
"marks": {
|
||||
@@ -61,6 +61,97 @@
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"team": {
|
||||
"about": "Teams (spec 3.2.0, team.md): a lead and the members it adds, who talk in the team's room. The texts are said word for word; {{size}} is the team's size after the call (team.md §3), {{names}} names joined with \", \" in tree order. conformance/team.json.",
|
||||
"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 member's name: unique in the team; `lead` is the lead's, `all` addresses everyone.",
|
||||
"pattern": "^[a-z][a-z0-9-]{0,31}$",
|
||||
"lead": "lead",
|
||||
"reserved": [
|
||||
"all",
|
||||
"lead"
|
||||
]
|
||||
},
|
||||
"marks": {
|
||||
"message": "[Team message from {{from}} to {{to}}:]",
|
||||
"report": "[Team report from {{from}} to {{to}}:]"
|
||||
},
|
||||
"unavailable": {
|
||||
"about": "What a team tool answers while its requirement `team` is not met (team.md §2). one_agent is also each team tool's own `unavailable`, and what task answers while a team is offered, max is 1 and the lead has no member.",
|
||||
"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.",
|
||||
"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}}]"
|
||||
},
|
||||
"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.",
|
||||
"tree": "Team ({{size}} of {{max}} agents):",
|
||||
"agent": "{{indent}}- {{name}} — {{role}} · {{state}} · {{model}} · {{last}}",
|
||||
"lead_role": "leads the team",
|
||||
"states": {
|
||||
"working": "working",
|
||||
"waiting": "waiting for a slot",
|
||||
"finished": "finished",
|
||||
"idle": "idle"
|
||||
},
|
||||
"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}}."
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"ids": {
|
||||
@@ -105,9 +196,14 @@
|
||||
"line": "Read a file again for its content; recall shows a result by its id.",
|
||||
"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": {
|
||||
"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.",
|
||||
"open": "<session-note>",
|
||||
"close": "</session-note>",
|
||||
"date": "Today is {{date}}.",
|
||||
@@ -120,6 +216,10 @@
|
||||
"personal": "the person's own instructions",
|
||||
"personality": "The person chose a different personality:\n{{personality}}",
|
||||
"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 a team from the start: you lead, and members do the work — at least {{members}} of them.",
|
||||
"team_one": "The person allows one agent: work alone; the team tools and task are unavailable.",
|
||||
"team_stopped": "The person stopped {{names}}; they have left the team.",
|
||||
"empty": "(none)"
|
||||
},
|
||||
"mcp_wait_ms": 3000
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
"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." },
|
||||
"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)." },
|
||||
"effort": { "enum": ["minimal", "low", "medium", "high", "xhigh", "max", "off"], "description": "The effort a new conversation starts with; off sends none." },
|
||||
"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`." },
|
||||
"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)." },
|
||||
"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." },
|
||||
@@ -23,11 +24,16 @@
|
||||
"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.",
|
||||
"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." },
|
||||
"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. A team (team.md) is offered only on a model that serves at least 2." }
|
||||
},
|
||||
"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"],
|
||||
"additionalProperties": true
|
||||
}
|
||||
@@ -26,6 +26,10 @@
|
||||
"job_stop": "allow",
|
||||
"job_send": "allow",
|
||||
"report": "allow",
|
||||
"team_add": "allow",
|
||||
"team_send": "allow",
|
||||
"team_read": "allow",
|
||||
"team_stop": "allow",
|
||||
"web_search": "allow",
|
||||
"memory": "allow",
|
||||
"session_search": "allow",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
kept by the agent, and how git is used there is the user's answer, asked once and written down. -->
|
||||
## 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:
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -0,0 +1,12 @@
|
||||
<!-- LLeMbas CLI and LLeMbas, spec 3.2.0 (team.md): how to lead a team, in the system prompt whenever
|
||||
the team tools are offered, and in a member's that may lead. Constant: the size the person
|
||||
allows comes in a session note, never here. -->
|
||||
# Leading a team
|
||||
You can add members to a team with team_add: agents that work in the background, each on the part of the work it owns, while you lead. A small job you may do yourself. Once you have a member you manage and no longer do the work: tools that change things are refused to you, and reading stays yours, for review.
|
||||
- Plan first: what the goal is, what the parts are, and what each one needs from the others. Split by area of ownership, not by step, so that one member owns a thing from start to finish.
|
||||
- Name each member after what it owns. Brief it so that it can work without you: what to do, what done means, what to leave alone, and what to report.
|
||||
- Keep the team flat while it is small — up to six or eight members, each reporting to you. Beyond that, add a lead for each area (`lead: true`), give it the area's goal, and let it build and run its own members; you then lead the leads.
|
||||
- Keep the team within the size the person allows, which a session note tells you. Stop a member whose part is done or no longer needed, to make room.
|
||||
- Coordinate through the room: team_send to the member who owns a thing, `all` only for what everyone needs. team_read shows the team and the room when you need to look; nothing from it reaches you unasked except your members' reports and messages addressed to you.
|
||||
- Step in when a member is stuck, when two disagree, or when the plan has to change: decide, and tell those it concerns.
|
||||
- Review every report against what you asked for — read the work itself where it matters — and send back what is not done. Then give your own answer: to the person, or, if you lead an area, as your report to your manager.
|
||||
@@ -0,0 +1,14 @@
|
||||
<!-- LLeMbas CLI and LLeMbas, spec 3.2.0 (team.md): a team member's standing, said once at the end
|
||||
of its system prompt in place of blocks/subagent.md. The variables are what differs per member;
|
||||
each stands on a line of its own, so no punctuation of theirs matters. -->
|
||||
You are "{{name}}", a member of a team, and you report to "{{manager}}".
|
||||
Your part: {{role}}
|
||||
The team's goal: {{goal}}
|
||||
|
||||
{{instructions}}
|
||||
|
||||
You work for your manager on the part you own, and you cannot ask the person anything: ask your manager, by name, with team_send. Other members own other parts. Message the one who owns a thing when you need it from them, and `all` only for what everyone on the team needs; messages to you arrive as you work. team_read shows who is on the team and the room.
|
||||
|
||||
Your final reply is your report to "{{manager}}" — make it complete on its own: the result, then the evidence (file:line), then anything you could not settle. A message may wake you again afterwards; your next final reply is then your next report.{{read_only}}
|
||||
|
||||
{{lead}}
|
||||
@@ -240,6 +240,27 @@
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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: 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 role as team_add gave it: one line, what it owns",
|
||||
"manager": "the name of the agent that added it (`lead` for the team's lead)",
|
||||
"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 member added with `lead: true`: the whole of blocks/team_lead.md; otherwise empty"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<!-- 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). -->
|
||||
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}}
|
||||
|
||||
|
||||
+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.",
|
||||
"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": "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": "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": "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": "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": "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 }
|
||||
]
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
# Teams
|
||||
|
||||
Spec 3.2.0. **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`.
|
||||
|
||||
## 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.
|
||||
|
||||
- `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. **The chat's model serves at least two requests at once**: `capacity.parallel ≥ 2`, or neither
|
||||
`parallel` nor `single_session` is set (a hosted API; `models/schema.json`).
|
||||
|
||||
Both are fixed for as long as the system prompt is (`context.md` §10, rule 4: a model switch makes
|
||||
a new prompt and a new list). Otherwise the setting is not shown and none of the four tools is
|
||||
listed, and the session's `task` works as before.
|
||||
|
||||
While the tools are listed they need **`team`** (`requires`): met when `max ≥ 2` and the model still
|
||||
serves two at once — or, for the lead, when its team still has a member (so a lead whose person
|
||||
lowered `max` to 1 can still talk to, read and stop the members it has). A call while it is not
|
||||
met answers, word for word, `team.unavailable.one_request` when the model's capacity no longer
|
||||
allows a team (its metadata changed after the prompt was made), else `team.unavailable.one_agent`
|
||||
— which is also the tools' own `unavailable` text.
|
||||
|
||||
The meaning of the setting:
|
||||
|
||||
- **`max = 1`**: one agent, no team, and no helper of any kind: `task` answers `one_agent` too
|
||||
(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` helper running in the team, started by
|
||||
any agent in it (the CLI's background `task` jobs and foreground ones alike, while they run).
|
||||
A stopped member does not count: stopping is how room is made.
|
||||
|
||||
- **`team_add` is refused past `max`** — when the size would become more than `max`, with
|
||||
`team.texts.full`. So is `task`, while a team is offered: with `full`, or with `one_agent` when
|
||||
`max` is 1 and the lead has no member. A `job_send` that would start a finished background
|
||||
`task` helper again is refused the same way.
|
||||
- **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); `model` is
|
||||
as `task`'s — a model the person may use, the default being the caller's.
|
||||
- **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.
|
||||
- **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 helper 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 helper. 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` helper. An agent with work (added, woken,
|
||||
given a message while finished, the lead given the person's message) starts when its model has a
|
||||
free slot and none of its `capacity.group`'s other models is busy; otherwise it is `waiting`.
|
||||
`team_add` is never refused for capacity (only for size): its answer says the member waits.
|
||||
|
||||
Waiting agents start **in order**: the lead first (the person waits on it), then the others in the
|
||||
order they began waiting. A slot frees when a turn ends or an agent is stopped. `task` keeps its own
|
||||
rule (`loop.json` `capacity`: refused where its model is full), and its helper holds a slot.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"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 member to your team and start it at once, in the background, on its part of the work. You lead: members do the work while you plan, coordinate and review — once you have a member, tools that change things are refused to you.\n\n- `name` is how everyone addresses it: lowercase letters, digits and hyphens, after what it owns (`parser`, `api-tests`), unique in the team.\n- `role` is one line: what it owns. `task` is its brief and must stand on its own — it sees nothing of this conversation: what to do, when it is done, and what to report.\n- `goal` is the team's goal in a line; your first member needs it, and every member is told it.\n- `lead: true` lets it add members of its own and lead an area; it then manages as you do.\n- The person sets how many agents the team may have; past that this is refused. When the model is already serving as many requests as it can at once, the member waits for a slot and starts when one frees.\n- Its final reply comes to you as its report. Meanwhile team_send messages it, team_read shows the team and the room, team_stop stops it.\n- Agents: {{agents}}",
|
||||
"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": "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": "May add members of its own and lead an area (default: false)"
|
||||
},
|
||||
"agent": {
|
||||
"type": "string",
|
||||
"description": "Which agent it is (default: general)"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Run it on another model you may use (from the list in your instructions). Default: yours."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"role",
|
||||
"task"
|
||||
]
|
||||
},
|
||||
"variables": {
|
||||
"agents": "the agents a member may be: `name — description`, separated by \"; \" (as task's)"
|
||||
}
|
||||
}
|
||||
@@ -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 team and its room: every agent (name, role, who leads it, state, model, last activity), and the room's messages after `since` that are yours to see — those you sent or received and those of the members under you; the lead sees them all. Messages are numbered: pass the last number you saw as `since` to read only what is new.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"since": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Show messages after this number (default: 0, from the start)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 your team's room: to one or more agents by name, or to `all`. It reaches each at its next step, or wakes one that has finished, its context intact. Message the agent who owns a thing; use `all` only for what everyone needs. The team's lead is `lead`; your manager is reached by its name.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"to": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"description": "Names, or [\"all\"]"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"to",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"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 member of your team, and the members under it, where they are: their work stops and they leave the team, which makes room for others. Only members under you, directly or through your members; `all` stops every one of them. Nothing they had not reported comes back — ask for a report with team_send first when you need it.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "A member under you, or all"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lembas-cli",
|
||||
"version": "2.0.0-beta.2",
|
||||
"version": "2.0.0-beta.3",
|
||||
"description": "LLeMbas CLI: a terminal coding agent and project manager for a LLeMbas server — OpenTUI, memory, skills, MCP, git-native.",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -870,7 +870,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"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": [
|
||||
{
|
||||
"type": "string",
|
||||
@@ -886,9 +886,42 @@
|
||||
{
|
||||
"type": "string",
|
||||
"const": "off"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "default"
|
||||
}
|
||||
]
|
||||
},
|
||||
"agents": {
|
||||
"description": "Teams (harness team.md): how many agents a session's team may have. Offered only where subagents are on and the model serves two requests at once. /agents changes it for the session.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min": {
|
||||
"description": "The fewest agents a team works with, the lead included (default 1). 2 or more: the lead works through a team from the start.",
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"max": {
|
||||
"description": "The most agents a team may have, the lead included (default 4). 1: one agent, no team and no helper.",
|
||||
"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": {
|
||||
"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",
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
"maximum": 1
|
||||
},
|
||||
"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",
|
||||
"items": {
|
||||
"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": {
|
||||
"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": [
|
||||
{
|
||||
"type": "string",
|
||||
@@ -158,6 +162,10 @@
|
||||
{
|
||||
"type": "string",
|
||||
"const": "off"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "default"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -202,9 +210,15 @@
|
||||
"maxLength": 300
|
||||
},
|
||||
"single_session": {
|
||||
"description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused.",
|
||||
"description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused. The same as parallel: 1.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"parallel": {
|
||||
"description": "How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). A team is offered only on a model that serves at least 2.",
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"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.",
|
||||
"type": "string"
|
||||
|
||||
+10
-8
@@ -33,7 +33,8 @@ import { addPendingDeleted, clearPendingDeleted, pendingDeleted } from "./pendin
|
||||
import { shellLaunch } from "./shellrc.ts"
|
||||
import { sessionTurnFile, sessionTurns, TurnLog } from "./turns.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 { findProject, trustedRoots, trustedWithin, trustOf } from "../project/root.ts"
|
||||
import { VERSION } from "../version.ts"
|
||||
@@ -131,10 +132,10 @@ interface Terminal {
|
||||
/** Most terminals one link may hold at once. */
|
||||
const MAX_TERMINALS = 4
|
||||
|
||||
/** An effort as the instance sends it: a level, or "off"; anything else is not one. */
|
||||
export function effortOf(v: unknown): Effort | "off" | undefined {
|
||||
if (v === "off" || v === "" || v === null) return "off"
|
||||
return typeof v === "string" && (EFFORTS as readonly string[]).includes(v) ? (v as Effort) : undefined
|
||||
/** An effort level as the instance sends it (an effort, "off" or "default"); anything else is not
|
||||
* one. Whether the model offers it is the session's to decide (readLevel). */
|
||||
export function effortOf(v: unknown): Level | undefined {
|
||||
return isLevel(v) ? v : undefined
|
||||
}
|
||||
|
||||
const MODE_RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
|
||||
@@ -619,7 +620,7 @@ export class AcpAgent {
|
||||
sessionId: id,
|
||||
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.
|
||||
_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 +673,9 @@ export class AcpAgent {
|
||||
s.app.switchModel(ref)
|
||||
}
|
||||
const effort = effortOf(p?.effort)
|
||||
if (effort !== undefined) s.app.engine.effort = effort === "off" ? null : effort
|
||||
return { model: s.app.engine.model.ref, effort: s.app.engine.effort ?? "off" }
|
||||
// A level the model does not offer is read as its start level; the answer says the one used.
|
||||
if (effort !== undefined) s.app.engine.effort = readLevel(s.app.engine.model, effort)
|
||||
return { model: s.app.engine.model.ref, effort: s.app.engine.effort }
|
||||
}
|
||||
|
||||
private modes(): Mode[] {
|
||||
|
||||
+4
-3
@@ -14,6 +14,7 @@ import type { ImagePart } from "../provider/types.ts"
|
||||
import { commandSet, sourcesOf } from "../session/commands.ts"
|
||||
import type { PlanReply, PlanRequest } from "../tool/plan_submit.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 { jobList, jobOutput, jobSend, jobStop, jobTranscript } from "./jobs.ts"
|
||||
import type { SessionContext } from "../app.ts"
|
||||
@@ -390,7 +391,7 @@ export class Share {
|
||||
// is the instance's.
|
||||
modelConnection: this.app.engine.model.connectionName,
|
||||
mode: this.app.engine.mode,
|
||||
effort: this.app.engine.effort ?? "off",
|
||||
effort: this.app.engine.effort,
|
||||
busy: Boolean(this.app.turns.current),
|
||||
explicit,
|
||||
},
|
||||
@@ -596,9 +597,9 @@ export class Share {
|
||||
// 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)
|
||||
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()
|
||||
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 }
|
||||
})
|
||||
}
|
||||
}
|
||||
+155
-26
@@ -12,10 +12,9 @@ import { boardSummary } from "./project/board.ts"
|
||||
import { unsafeProjectFile } from "./project/safe.ts"
|
||||
import { Bus, type Asker } from "./bus/index.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 { hardlineRules } from "./permission/hardline.ts"
|
||||
import { defaultEffort } from "./provider/effort.ts"
|
||||
import { clientFor, familyOf, resolveModel } from "./provider/index.ts"
|
||||
import { assembleSystem, instructionFiles, knowledgeScope, modeText } from "./prompt/assemble.ts"
|
||||
import type { Changing } from "./session/prefix.ts"
|
||||
@@ -45,14 +44,17 @@ import type { Plan } from "./plan/plan.ts"
|
||||
import { LIBRARY_SERVER, mcpInstructions } from "./mcp/index.ts"
|
||||
import type { McpServer } from "./config/schema.ts"
|
||||
import { embedderFor } from "./library/embed.ts"
|
||||
import { effortsFor } from "./provider/effort.ts"
|
||||
import { isLevel, levelsFor, readLevel, type Level } from "./provider/effort.ts"
|
||||
import { readingBashByDefault, Team, TEAM, TEAM_TOOLS, type Capacity, type TeamAgent } from "./team/team.ts"
|
||||
import { TeamRuntime, type MemberHandle } from "./team/runtime.ts"
|
||||
|
||||
export interface AppOptions {
|
||||
cwd: string
|
||||
model?: string
|
||||
mode?: Mode
|
||||
/** undefined = the model's default; "off" = none */
|
||||
effort?: Effort | "off"
|
||||
/** The effort level asked for (an effort, "off" or "default"); undefined, or one the model does
|
||||
* not offer: its start level. */
|
||||
effort?: string
|
||||
asker: Asker
|
||||
store?: Store | false
|
||||
/** Nobody can answer an approval (headless, no --yes). Said in the system prompt up front. */
|
||||
@@ -149,6 +151,8 @@ export interface App {
|
||||
context: SessionContext
|
||||
/** Replace it (`{}`: none), naming the instance it came from for the prompt's heading. */
|
||||
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
|
||||
* 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. */
|
||||
@@ -215,13 +219,10 @@ export function createApp(o: AppOptions): App {
|
||||
// An untrusted project is read-only: plan mode, and its own config is ignored.
|
||||
let mode: Mode = o.mode ?? loaded.config.mode ?? "manual"
|
||||
if (trust === "readonly") mode = "plan"
|
||||
// `effort:` in config when this model takes it, else the model's own default.
|
||||
const startEffort = (m: ResolvedModel) => {
|
||||
const e = loaded.config.effort
|
||||
if (e === "off") return null
|
||||
return e && effortsFor(m).includes(e) ? e : defaultEffort(m)
|
||||
}
|
||||
const effort = o.effort === "off" ? null : (o.effort ?? startEffort(model))
|
||||
// `effort:` in config when this model offers that level, else the model's own start level
|
||||
// (harness models/schema.json `levels`).
|
||||
const startEffort = (m: ResolvedModel): Level => readLevel(m, loaded.config.effort)
|
||||
const effort: Level = o.effort !== undefined && isLevel(o.effort) && (levelsFor(model).levels as string[]).includes(o.effort) ? o.effort : startEffort(model)
|
||||
// One object for the session's lifetime, so a setting changed later reaches the tools.
|
||||
loaded.config.search ??= {}
|
||||
const bus = o.bus ?? new Bus()
|
||||
@@ -363,7 +364,7 @@ export function createApp(o: AppOptions): App {
|
||||
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 }
|
||||
}
|
||||
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?: { name: string; manager: string; role: string; goal: string; lead: boolean }) => {
|
||||
const src = ch.source as Source
|
||||
return assembleSystem({
|
||||
modelRef: current.ref,
|
||||
@@ -395,6 +396,9 @@ export function createApp(o: AppOptions): App {
|
||||
library: libraryBlock(),
|
||||
prototypes: prototypesBlock(),
|
||||
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. */
|
||||
@@ -420,6 +424,7 @@ export function createApp(o: AppOptions): App {
|
||||
const inWords = (sec: number) => (sec % 60 === 0 ? `${sec / 60} minute${sec === 60 ? "" : "s"}` : `${sec} seconds`)
|
||||
const vars: Record<string, Record<string, string>> = {
|
||||
task: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") },
|
||||
team_add: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") },
|
||||
bash: {
|
||||
timeout: inWords(loaded.config.limits?.bash_timeout ?? 120),
|
||||
timeout_max: inWords(600),
|
||||
@@ -467,9 +472,15 @@ export function createApp(o: AppOptions): App {
|
||||
// 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
|
||||
// (src/session/capacity.ts), before anything is made.
|
||||
const member = opts.member
|
||||
const m = opts.model ? resolveModel(loaded, opts.model) : spec.model ? resolveModel(loaded, spec.model) : engine.model
|
||||
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)
|
||||
// A team's member is not refused for capacity: the team gave it its slot (team.md §8).
|
||||
if (!member) {
|
||||
const running = registry.running(jobsSessionId()).filter((j) => j.kind === "subagent" && j.subagent && !j.team)
|
||||
const onIt = running.filter((j) => j.subagent!.engine.model.ref === m.ref).length
|
||||
const refused = capacityRefusal(engine.model, m, onIt) || running.map((j) => capacityBeside(j.subagent!.engine.model, m, onIt)).find(Boolean)
|
||||
if (refused) throw new ToolError(`${refused} Do this part yourself, or hand it to a model on another connection.`)
|
||||
}
|
||||
// A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation).
|
||||
let wt: Worktree | undefined
|
||||
if (opts.worktree || spec.isolation === "worktree") {
|
||||
@@ -497,8 +508,14 @@ export function createApp(o: AppOptions): App {
|
||||
}
|
||||
: perm
|
||||
// `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))]
|
||||
const description = opts.description ?? prompt.split("\n")[0]!.slice(0, 60)
|
||||
// A team member also has team_send and team_read, and one that may lead team_add and team_stop
|
||||
// (team.md §4); a task helper has none of them.
|
||||
const memberTools = member ? (member.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"]) : []
|
||||
const childTools = [
|
||||
...tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && (!(TEAM_TOOLS as readonly string[]).includes(t.name) || memberTools.includes(t.name)) && (!spec.tools || spec.tools.includes(t.name) || memberTools.includes(t.name))),
|
||||
...BUILTIN_TOOLS.filter((t) => SUBAGENT_ONLY.has(t.name)),
|
||||
]
|
||||
const description = member ? `${member.name}: ${member.role}` : (opts.description ?? prompt.split("\n")[0]!.slice(0, 60))
|
||||
const ownerSession = jobsSessionId()
|
||||
const childRow = store?.createSession(at?.root ?? project.root, m.ref, engine.sessionId)
|
||||
if (childRow) store!.setTitle(childRow.id, `${spec.name}: ${description}`)
|
||||
@@ -524,7 +541,8 @@ export function createApp(o: AppOptions): App {
|
||||
store,
|
||||
sessionId: childRow?.id,
|
||||
// An agent's own mode can make it stricter than its parent, never looser.
|
||||
mode: stricterMode(spec.mode, engine.mode),
|
||||
// A team member's is no looser than its manager's (team.md §4): a sub-manager's own mode.
|
||||
mode: stricterMode(spec.mode, (member?.manager && member.manager !== TEAM.names.lead ? teamRt.handle(member.manager)?.engine.mode : undefined) ?? engine.mode),
|
||||
effort: m.ref === engine.model.ref ? engine.effort : startEffort(m),
|
||||
maxSteps: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default,
|
||||
compaction,
|
||||
@@ -533,7 +551,17 @@ export function createApp(o: AppOptions): App {
|
||||
...baseToolCtx(),
|
||||
...(at ? { root: at.root, cwd: at.cwd, projectDir: undefined } : {}),
|
||||
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),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
steerMark: MARKS.to_subagent,
|
||||
changing: () => changing(true, at),
|
||||
modeText: modeNote,
|
||||
@@ -545,7 +573,7 @@ export function createApp(o: AppOptions): App {
|
||||
{
|
||||
name: spec.name,
|
||||
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,
|
||||
report: childTools.some((t) => t.name === "report"),
|
||||
skills: childTools.some((t) => t.name === "skill_view"),
|
||||
@@ -553,6 +581,7 @@ export function createApp(o: AppOptions): App {
|
||||
},
|
||||
at,
|
||||
plan,
|
||||
member ? { 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
|
||||
@@ -568,7 +597,14 @@ export function createApp(o: AppOptions): App {
|
||||
const end = registry.finishSubagent(job, { reason, report: text, ...(error ? { error } : {}), ...(worktree ? { worktree } : {}) })
|
||||
return { text: worktree ? `${text}\n\n[worktree] ${worktree}` : text, steps: job.steps ?? 0, tools: job.tools ?? 0, reason, ...(error ? { error } : {}), ...(end.stopped ? { stopped: end.stopped } : {}) }
|
||||
}
|
||||
const run = (text: string) => child.prompt(text).then(finish, (e) => (job.lastError = (e as Error).message, finish("error")))
|
||||
// A task helper started inside a team counts toward its size and holds a slot while it runs
|
||||
// (team.md §3, §8).
|
||||
const counted = (p: Promise<SubagentResult>) => {
|
||||
if (member || !teamRt.team.offered) return p
|
||||
const id = teamRt.team.helperStart(TEAM.names.lead, m.ref)
|
||||
return p.finally(() => teamRt.team.helperDone(id))
|
||||
}
|
||||
const run = (text: string, machine = false) => counted(child.prompt(text, [], text, machine ? { machine: true } : {}).then(finish, (e) => (job.lastError = (e as Error).message, finish("error"))))
|
||||
const handle: SubagentHandle = {
|
||||
bus: childBus,
|
||||
engine: child,
|
||||
@@ -576,7 +612,23 @@ export function createApp(o: AppOptions): App {
|
||||
restart: (text) => run(text).then(() => undefined),
|
||||
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
|
||||
const memberRun = (text: string, machine: boolean) => {
|
||||
if (job.state !== "running") registry.restartSubagent(job)
|
||||
void run(text, machine).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() }
|
||||
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.
|
||||
const onAbort = () => child.cancel()
|
||||
opts.signal?.addEventListener("abort", onAbort, { once: true })
|
||||
@@ -602,16 +654,72 @@ 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 },
|
||||
compaction,
|
||||
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),
|
||||
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(),
|
||||
modeText: modeNote,
|
||||
// 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)]),
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
// 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,
|
||||
leadState: "idle",
|
||||
readingBash: (command) => readingBashByDefault(command, project.root),
|
||||
})
|
||||
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)) },
|
||||
resolve: (agent, model) => {
|
||||
if (!agentList.some((a) => a.name === agent)) return { error: `There is no agent "${agent}". The agents are: ${agentList.map((a) => a.name).join(", ")}.` }
|
||||
try {
|
||||
return { model: model ? resolveModel(loaded, model).ref : engine.model.ref }
|
||||
} 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())
|
||||
|
||||
// MCP tools join the engine's list as servers connect (the engine reads it at each prompt).
|
||||
let mcpNames = new Set<string>()
|
||||
const syncMcp = () => {
|
||||
@@ -721,19 +829,28 @@ export function createApp(o: AppOptions): App {
|
||||
},
|
||||
live(key) {
|
||||
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
|
||||
// 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)
|
||||
},
|
||||
check(key, v) {
|
||||
if (key === "model" || key === "small_model") resolveModel(loaded, String(v))
|
||||
if (key === "effort" && v !== "off" && !effortsFor(engine.model).includes(v as Effort))
|
||||
throw new Error(`${engine.model.ref} takes ${effortsFor(engine.model).join(", ")} or off`)
|
||||
if (key === "effort" && !(levelsFor(engine.model).levels as string[]).includes(String(v)))
|
||||
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`)
|
||||
// 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) {
|
||||
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 === "limits.steps") engine.o.maxSteps = Number(v)
|
||||
else if (key === "busy_input") engine.busyInput = v as "steer" | "queue"
|
||||
@@ -741,6 +858,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 === "compaction.auto_at") compaction.autoAt = Number(v)
|
||||
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)
|
||||
bus.emit({ type: "setting", key, value: v })
|
||||
},
|
||||
@@ -792,6 +917,7 @@ export function createApp(o: AppOptions): App {
|
||||
mcpReady,
|
||||
small,
|
||||
close: () => {
|
||||
teamRt.stopAll()
|
||||
detach()
|
||||
for (const d of childDetach.splice(0)) d()
|
||||
return mcp.close()
|
||||
@@ -806,6 +932,7 @@ export function createApp(o: AppOptions): App {
|
||||
jobsSessionId,
|
||||
jobs: () => registry.list(jobsSessionId()),
|
||||
wake,
|
||||
team: teamRt,
|
||||
get context() {
|
||||
return context
|
||||
},
|
||||
@@ -831,6 +958,7 @@ export function createApp(o: AppOptions): App {
|
||||
const s = store?.createSession(project.root, engine.model.ref)
|
||||
keepCwd(s?.id)
|
||||
standIn = `local_${crypto.randomUUID().slice(0, 8)}`
|
||||
teamRt.reset(newTeam())
|
||||
engine.newSession(s?.id)
|
||||
turns.reset()
|
||||
if (s) bus.emit({ type: "session", id: s.id, model: engine.model.ref, mode: engine.mode })
|
||||
@@ -844,6 +972,7 @@ export function createApp(o: AppOptions): App {
|
||||
const before = engine.sessionId
|
||||
frozen = freeze()
|
||||
void readServerMemory()
|
||||
teamRt.reset(newTeam())
|
||||
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.
|
||||
firstShown = ""
|
||||
|
||||
@@ -62,6 +62,9 @@ export type Event =
|
||||
/** 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. */
|
||||
| { 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" }
|
||||
/** The permission mode changed from inside a turn (plan approved). */
|
||||
| { type: "mode"; mode: Mode }
|
||||
/** The session's model or effort changed, for whatever reason — /model, /effort, the settings
|
||||
|
||||
+8
-8
@@ -5,13 +5,13 @@ import { parseArgs } from "node:util"
|
||||
import { createApp } from "./app.ts"
|
||||
import { attachmentsFor } from "./project/attach.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 { SettingError } from "./config/settings.ts"
|
||||
import { renderJson, renderPlain, settleJobs, terminalAsker } from "./headless.ts"
|
||||
import { enterLocal, localFor, LocalError, localRefusal, localTmp, LOCAL_FLAGS } from "./project/local.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 { Store } from "./session/store.ts"
|
||||
import { promptTitle } from "./session/title.ts"
|
||||
@@ -84,7 +84,7 @@ Local mode:
|
||||
Options for run:
|
||||
-m, --model conn/model model to use (default: config.yaml \`model\`)
|
||||
--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)
|
||||
--json events as JSON lines
|
||||
--reasoning show the model's reasoning
|
||||
@@ -240,7 +240,7 @@ async function main() {
|
||||
cwd: process.cwd(),
|
||||
model: values.model,
|
||||
mode: asMode(values.mode),
|
||||
effort: values.effort as Effort | "off" | undefined,
|
||||
effort: values.effort,
|
||||
resume: values.session ?? (values.continue ? "last" : undefined),
|
||||
})
|
||||
if (done.reload) await (await import("./reload.ts")).restart(done.reload)
|
||||
@@ -299,14 +299,13 @@ async function main() {
|
||||
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 (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))
|
||||
fail(`--effort must be one of ${EFFORTS.join(", ")}, off`, 2)
|
||||
if (values.effort && !isLevel(values.effort)) fail(`--effort must be one of ${EFFORTS.join(", ")}, off, default`, 2)
|
||||
|
||||
const app = createApp({
|
||||
cwd: process.cwd(),
|
||||
model: values.model,
|
||||
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 }),
|
||||
unattended: !values.yes && !process.stdin.isTTY,
|
||||
store: values["no-store"] ? false : undefined,
|
||||
@@ -342,7 +341,8 @@ async function main() {
|
||||
spec.max_output ? `out ${spec.max_output}` : "",
|
||||
spec.vision ? "vision" : "",
|
||||
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)
|
||||
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]
|
||||
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
|
||||
// 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.
|
||||
|
||||
+15
-4
@@ -51,15 +51,17 @@ export const ModelSpec = z.strictObject({
|
||||
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."),
|
||||
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."),
|
||||
effort: z.union([z.enum(EFFORTS), z.literal("off")]).optional().describe("Default effort; `off` sends none."),
|
||||
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`)."),
|
||||
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)."),
|
||||
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."),
|
||||
headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers for this model's requests."),
|
||||
body: z.record(z.string(), z.unknown()).optional().describe("Merged into every request body for this model."),
|
||||
notes: z.string().max(300).optional().describe("What this model is good at, in a line: the model in use is told which others there are, for handing work over (task) or switching."),
|
||||
single_session: z.boolean().optional().describe("The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused."),
|
||||
single_session: z.boolean().optional().describe("The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused. The same as parallel: 1."),
|
||||
parallel: z.number().int().positive().optional().describe("How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). A team is offered only on a model that serves at least 2."),
|
||||
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>
|
||||
@@ -293,7 +295,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."),
|
||||
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."),
|
||||
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("The fewest agents a team works with, the lead included (default 1). 2 or more: the lead works through a team from the start."),
|
||||
max: z.number().int().min(1).optional().describe("The most agents a team may have, the lead included (default 4). 1: one agent, no team and no helper."),
|
||||
cap: z.number().int().min(1).optional().describe("Global config only. The highest max a session may set (default 32)."),
|
||||
})
|
||||
.optional()
|
||||
.describe("Teams (harness team.md): how many agents a session's team may have. Offered only where subagents are on and the model serves two requests at once. /agents changes it for the session."),
|
||||
subagents: z.boolean().optional().describe("false: no subagents — no task tool and no team."),
|
||||
settings_tool: z.enum(["ask", "allow", "off"]).optional().describe("Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool."),
|
||||
update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."),
|
||||
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."),
|
||||
|
||||
@@ -19,7 +19,7 @@ export type Source = "default" | "global" | "project" | "session"
|
||||
|
||||
export interface SettingDef {
|
||||
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"
|
||||
values?: readonly string[]
|
||||
/** The agent may change it (through the settings tool, with approval). */
|
||||
@@ -36,7 +36,10 @@ const USER: Scope[] = ["session", "global"]
|
||||
|
||||
export const SETTINGS: SettingDef[] = [
|
||||
{ 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" },
|
||||
{ key: "agents.max", kind: "number", agent: false, scopes: ALL, fallback: "4" },
|
||||
{ key: "mode", kind: "enum", values: MODES, agent: true, scopes: ALL, fallback: "manual" },
|
||||
{ key: "small_model", kind: "model", agent: true, scopes: ALL, fallback: "the session's model" },
|
||||
{ key: "knowledge", kind: "list", agent: true, scopes: ALL, fallback: "every base" },
|
||||
@@ -104,7 +107,7 @@ export function parseValue(def: SettingDef, raw: unknown): unknown {
|
||||
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.
|
||||
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 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")}`)
|
||||
|
||||
+26
-3
@@ -48,6 +48,10 @@ import t_skills_list from "../harness/tools/skills_list.json"
|
||||
import t_tag from "../harness/tools/tag.json"
|
||||
import t_task from "../harness/tools/task.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_view_image from "../harness/tools/view_image.json"
|
||||
import t_web_fetch from "../harness/tools/web_fetch.json"
|
||||
import t_web_search from "../harness/tools/web_search.json"
|
||||
@@ -78,8 +82,9 @@ export interface ToolSpecDef {
|
||||
/** 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);
|
||||
* `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). */
|
||||
requires?: ("display" | "page" | "forge" | "plan")[]
|
||||
* leave the tool out: it is offered and answers `unavailable` while unmet (context.md §10).
|
||||
* `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. */
|
||||
unavailable?: string
|
||||
/** `always`: put to the person in every mode, auto included; never remembered. */
|
||||
@@ -113,6 +118,17 @@ export const LOOP: {
|
||||
running_max: { default: number }
|
||||
subagent_steps: { default: number }
|
||||
}
|
||||
/** 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[] }
|
||||
marks: { message: string; report: string }
|
||||
unavailable: { one_agent: string; one_request: string }
|
||||
work: { write: string[]; execute: string[]; kept: string[] }
|
||||
read_max: number
|
||||
texts: Record<"added" | "added_waiting" | "sent" | "stopped" | "manager" | "full" | "bad_name" | "all_name" | "taken" | "no_goal" | "unknown" | "self" | "nobody" | "not_yours" | "nobody_to_stop" | "no_report" | "ended", string>
|
||||
read: { tree: string; agent: string; lead_role: string; states: Record<"working" | "waiting" | "finished" | "idle", string>; active: string; room: string; message: string; report: string; none: string; more: string }
|
||||
}
|
||||
/** 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
|
||||
* compaction adds. */
|
||||
@@ -133,6 +149,8 @@ export const LOOP: {
|
||||
dedupe: { by_hash: string[]; by_result: string[] }
|
||||
compaction: { heading: string; line: string; rows: number }
|
||||
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
|
||||
* 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
|
||||
@@ -142,6 +160,11 @@ export const LOOP: {
|
||||
plan_ended: string
|
||||
/** For a chat, which does not resend earlier tool results; the CLI's model has them. */
|
||||
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
|
||||
}
|
||||
/** How long a session waits for its configured MCP servers before its first request. */
|
||||
mcp_wait_ms: number
|
||||
@@ -222,7 +245,7 @@ export function narrowParameters(parameters: Record<string, unknown>, narrow: Na
|
||||
}
|
||||
|
||||
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_read, t_team_send, t_team_stop, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]),
|
||||
)
|
||||
|
||||
/** 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
|
||||
* as the task call's output. */
|
||||
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
|
||||
|
||||
@@ -58,10 +58,12 @@ export interface ServedModel {
|
||||
top_p?: number
|
||||
efforts?: string[]
|
||||
effort?: string
|
||||
/** Spec 3.2.0: the server turns thinking off for enable_thinking false. */
|
||||
thinking_switch?: boolean
|
||||
vision?: boolean
|
||||
tools?: boolean
|
||||
notes?: string
|
||||
capacity?: { group?: string; single_session?: boolean }
|
||||
capacity?: { group?: string; single_session?: boolean; parallel?: number }
|
||||
default?: boolean
|
||||
/** The provider's slug — the part of `id` before its `/` — and what kind of model it is. */
|
||||
provider?: string
|
||||
|
||||
+5
-2
@@ -18,7 +18,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { parse } from "yaml"
|
||||
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 { 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 (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 (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.thinking_switch === true) out.thinking_switch = true
|
||||
if (m.vision !== undefined) out.vision = m.vision
|
||||
if (m.tools !== undefined) out.tools = m.tools
|
||||
if (m.notes) out.notes = m.notes.slice(0, 300)
|
||||
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}`
|
||||
return out as ModelSpec
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// 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 { join, resolve } from "node:path"
|
||||
import { promptPath } from "../prompt/assemble.ts"
|
||||
import type { ImagePart } from "../provider/types.ts"
|
||||
import type { ToolContext } from "../tool/tool.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 abs = resolve(ctx.cwd, rel)
|
||||
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] ?? ""}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
@@ -47,25 +50,25 @@ export function attachmentsFor(text: string, ctx: Pick<ToolContext, "root" | "cw
|
||||
const entries = readdirSync(abs, { withFileTypes: true })
|
||||
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
|
||||
.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
|
||||
}
|
||||
if (isImage(abs)) {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
const raw = readFileSync(abs)
|
||||
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
|
||||
}
|
||||
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 range = m[2] ? ` lines="${from}-${to}"` : ""
|
||||
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]) {
|
||||
ctx.readFiles.add(abs)
|
||||
ctx.fileStamps.set(abs, st.mtimeMs)
|
||||
|
||||
+58
-9
@@ -1,6 +1,7 @@
|
||||
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
|
||||
import { platform, release } from "node:os"
|
||||
import { dirname, join, relative, resolve, sep } from "node:path"
|
||||
import { homedir, platform, release } from "node:os"
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
|
||||
import { LOOP } from "../harness.ts"
|
||||
import type { Mode } from "../config/schema.ts"
|
||||
import { expandHome, paths } from "../config/paths.ts"
|
||||
import { render, type Plan } from "../plan/plan.ts"
|
||||
@@ -17,6 +18,8 @@ import blockLibrary from "../../harness/prompts/blocks/library.md" with { type:
|
||||
import blockPrototypes from "../../harness/prompts/blocks/prototypes.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 blockTeamLead from "../../harness/prompts/blocks/team_lead.md" with { type: "text" }
|
||||
import blockTeamMember from "../../harness/prompts/blocks/team_member.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 modeManual from "../../harness/prompts/modes/manual.md" with { type: "text" }
|
||||
@@ -56,6 +59,8 @@ const BUILTIN: Record<string, string> = {
|
||||
"blocks/prototypes.md": blockPrototypes,
|
||||
"blocks/subagent.md": blockSubagent,
|
||||
"blocks/context.md": blockContext,
|
||||
"blocks/team_lead.md": blockTeamLead,
|
||||
"blocks/team_member.md": blockTeamMember,
|
||||
"agents/explore.md": agentExplore,
|
||||
"agents/general.md": agentGeneral,
|
||||
"modes/manual.md": modeManual,
|
||||
@@ -231,6 +236,12 @@ export interface PromptContext {
|
||||
/** Set for a subagent: who it is and what it is for; replaces the mode's own instructions.
|
||||
* `report`: it has the report tool. */
|
||||
subagent?: { name: string; instructions: string; report?: boolean }
|
||||
/** Spec 3.2.0 (team.md §4): a team member — blocks/team_member.md in place of blocks/subagent.md,
|
||||
* with the whole of blocks/team_lead.md for one that may lead. */
|
||||
member?: { 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
|
||||
* when memory is off. A later change by the person goes as a note. */
|
||||
memory?: string
|
||||
@@ -264,6 +275,29 @@ export interface PromptContext {
|
||||
|
||||
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
|
||||
* 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
|
||||
@@ -301,8 +335,8 @@ export function assembleSystem(ctx: PromptContext): string {
|
||||
sections.push(
|
||||
[
|
||||
"<env>",
|
||||
`Working directory: ${ctx.cwd}`,
|
||||
`Project root: ${ctx.root}`,
|
||||
// No absolute path (context.md §11): the project by its name, relative paths against its root.
|
||||
...envLines(ctx.root, ctx.cwd),
|
||||
`Git repository: ${ctx.isGit ? "yes" : "no"}`,
|
||||
`Platform: ${platform()} ${release()}`,
|
||||
// No date: it would change at midnight and the whole session be read again (§10 rule 2).
|
||||
@@ -318,10 +352,10 @@ export function assembleSystem(ctx: PromptContext): string {
|
||||
const files = ctx.files ?? instructionFiles(ctx.cwd, ctx.root, ctx.instructions)
|
||||
for (const f of files) {
|
||||
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)
|
||||
text = text.slice(0, MAX_INSTRUCTION_CHARS) + `\n[…truncated: kept ${MAX_INSTRUCTION_CHARS} of ${f.text.length} characters; read ${f.path} 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
|
||||
text = text.slice(0, MAX_INSTRUCTION_CHARS) + `\n[…truncated: kept ${MAX_INSTRUCTION_CHARS} of ${f.text.length} characters; read ${named} for the rest]`
|
||||
sections.push(`Instructions from ${named}:\n<instructions>\n${text}\n</instructions>`)
|
||||
}
|
||||
|
||||
@@ -352,7 +386,22 @@ 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.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,
|
||||
manager: ctx.member.manager,
|
||||
role: ctx.member.role,
|
||||
goal: ctx.member.goal,
|
||||
instructions: ctx.subagent.instructions,
|
||||
read_only: ctx.mode === "plan" ? SUBAGENT_READ_ONLY : "",
|
||||
lead: ctx.member.lead ? promptText("blocks/team_lead.md") : "",
|
||||
})
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim(),
|
||||
)
|
||||
else if (ctx.subagent)
|
||||
sections.push(
|
||||
fillPrompt(promptText("blocks/subagent.md"), {
|
||||
name: ctx.subagent.name,
|
||||
@@ -389,7 +438,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.
|
||||
const marked = files.some((f) => f.chain)
|
||||
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)
|
||||
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}`
|
||||
|
||||
@@ -168,3 +168,18 @@ export function historyArgs(args: string): string {
|
||||
} catch {}
|
||||
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"]
|
||||
|
||||
/** 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[] {
|
||||
const list = learned().efforts[m.ref] ?? m.spec.efforts ?? DEFAULT_EFFORTS
|
||||
return EFFORTS.filter((e) => list.includes(e))
|
||||
}
|
||||
|
||||
/** The model's default effort, or null for off / not a reasoning model. */
|
||||
export function defaultEffort(m: ResolvedModel): Effort | null {
|
||||
const e = m.spec.effort
|
||||
if (!e || e === "off") return null
|
||||
return effortsFor(m).includes(e) ? e : null
|
||||
/** An effort level (harness models/schema.json `levels`, spec 3.2.0): an effort; `off` — thinking
|
||||
* switched off, only on a model with `thinking_switch`; `default` — nothing sent, the model's own,
|
||||
* only on a model that takes no efforts. */
|
||||
export type Level = Effort | "off" | "default"
|
||||
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 applyEffort(body: Record<string, unknown>, m: ResolvedModel, effort: Effort | null | undefined) {
|
||||
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
|
||||
export function isLevel(v: unknown): v is Level {
|
||||
return typeof v === "string" && (LEVEL_WORDS as readonly string[]).includes(v)
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
@@ -40,6 +94,7 @@ export function stripEffort(body: Record<string, unknown>): Record<string, unkno
|
||||
delete out.reasoning_effort
|
||||
const kwargs = { ...((out.chat_template_kwargs as Record<string, unknown>) ?? {}) }
|
||||
delete kwargs.reasoning_effort
|
||||
delete kwargs.enable_thinking
|
||||
if (Object.keys(kwargs).length) out.chat_template_kwargs = kwargs
|
||||
else delete out.chat_template_kwargs
|
||||
return out
|
||||
@@ -77,3 +132,12 @@ export function effortsFromTemplate(template: string): Effort[] {
|
||||
const out = EFFORTS.filter((e) => found.has(e))
|
||||
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 { authHeaders, request, joinUrl, tlsFor } from "./http.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 { ThinkSplitter } from "./think.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[] {
|
||||
const out: unknown[] = []
|
||||
if (system) out.push({ role: "system", content: system })
|
||||
for (const m of messages) {
|
||||
for (const m of turnReasoning(messages)) {
|
||||
if (m.role === "user") {
|
||||
const images = m.parts.filter((p) => p.type === "image")
|
||||
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 calls = m.parts.filter((p): p is ToolCallPart => p.type === "tool_call")
|
||||
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)
|
||||
msg.tool_calls = calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: historyArgs(c.args) } }))
|
||||
out.push(msg)
|
||||
|
||||
+14
-3
@@ -13,7 +13,17 @@ export type ToolCallPart = { type: "tool_call"; id: string; name: string; args:
|
||||
export type Message =
|
||||
/** `turnId`: the turn a prompt started, kept with it in the store for
|
||||
* `_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: "tool"; callId: string; name: string; content: string; isError?: boolean }
|
||||
|
||||
@@ -50,8 +60,9 @@ export interface ChatRequest {
|
||||
system: string
|
||||
messages: Message[]
|
||||
tools: ToolSpec[]
|
||||
/** null = off */
|
||||
effort?: Effort | null
|
||||
/** The effort level (provider/effort.ts); null or absent: nothing sent (a small job of the
|
||||
* harness's own). */
|
||||
effort?: Effort | "off" | "default" | null
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
|
||||
+22
-6
@@ -1,7 +1,8 @@
|
||||
// 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š,
|
||||
// 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
|
||||
// another of its models unloads the session's model, and the session's cached prompt with it,
|
||||
@@ -11,10 +12,23 @@
|
||||
// minutes, on a long session with a small GPU.
|
||||
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):
|
||||
* `parallel` when given, else 1 for single_session, else no limit (a hosted API). */
|
||||
export function parallelOf(m: Pick<ResolvedModel, "spec">): number {
|
||||
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 helper's model already serves besides the main prompt's
|
||||
* (subagents running on it). */
|
||||
export function capacityRefusal(main: ResolvedModel, helper: ResolvedModel, busy = 0): string {
|
||||
if (helper.ref === main.ref) {
|
||||
if (helper.spec.single_session)
|
||||
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 n = parallelOf(helper)
|
||||
if (n === 1)
|
||||
return `${helper.ref} serves one request at a time, so it cannot be its own subagent: the subagent would take the server's only slot and push this session's cached prompt out of it.`
|
||||
if (1 + busy >= n)
|
||||
return `${helper.ref} serves ${n} requests at once, and this session already has ${n} on it; another subagent would wait, or push this session's cached prompt out.`
|
||||
return ""
|
||||
}
|
||||
const shared = groupOf(main)
|
||||
@@ -28,9 +42,11 @@ 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
|
||||
* take the only slot of, a subagent already running (the spec's "beside the model of a subagent
|
||||
* already running"). */
|
||||
export function capacityBeside(running: ResolvedModel, helper: ResolvedModel): string {
|
||||
export function capacityBeside(running: ResolvedModel, helper: ResolvedModel, busy = 1): string {
|
||||
if (helper.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(helper)
|
||||
if (n === 1) return `${helper.ref} serves one request at a time, and a subagent is already running on it; a second would wait for the first, or push it out.`
|
||||
if (busy >= n) return `${helper.ref} serves ${n} requests at once, and as many subagents already run on it; another would wait, or push one out.`
|
||||
return ""
|
||||
}
|
||||
const shared = groupOf(running)
|
||||
|
||||
+47
-2
@@ -16,6 +16,7 @@ import { customCommands, expandCommand } from "../project/commands.ts"
|
||||
import { fill, promptText } from "../prompt/assemble.ts"
|
||||
import { skillMessage, slug, type Skill } from "../skill/index.ts"
|
||||
import { CONTINUE_PROMPT } from "./engine.ts"
|
||||
import { TEAM, Team } from "../team/team.ts"
|
||||
|
||||
export type CommandKind = "custom" | "skill" | "builtin"
|
||||
|
||||
@@ -34,7 +35,7 @@ export const TUI_COMMAND_NAMES = [
|
||||
"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",
|
||||
"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",
|
||||
] as const
|
||||
|
||||
/** The built-ins that work without a screen of their own. Descriptions as the TUI's (a test). */
|
||||
@@ -47,8 +48,51 @@ export const SHARED_BUILTINS: { name: string; description: string; hint?: string
|
||||
{ name: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" },
|
||||
{ name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" },
|
||||
{ name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" },
|
||||
{ name: "team", description: "the team: its agents and the room; its size; stop a member", hint: "size <min> <max> | stop <name>" },
|
||||
]
|
||||
|
||||
/** 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 `team ${team.size()}/${team.max}${n ? ` · ${n} working` : ""}`
|
||||
}
|
||||
|
||||
/** `/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 [verb, ...rest] = arg.trim().split(/\s+/).filter(Boolean)
|
||||
if (verb === "size") {
|
||||
const [min, max] = rest.map(Number)
|
||||
if (min === undefined || max === undefined || Number.isNaN(min) || Number.isNaN(max)) return `/team size <min> <max> — now ${t.min} to ${t.max} (at most ${t.cap})`
|
||||
// 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 `The team may have ${t.min} to ${t.max} agents for this session; the lead is told with your next message.`
|
||||
}
|
||||
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 team in this session: ${app.engine.model.ref} serves one request at a time, and a team needs two at once.` : "No team in this session: subagents are switched off (subagents: false)."
|
||||
const since = Math.max(0, t.room.length - TEAM.read_max)
|
||||
return `${t.read("lead", since)}\n\nSize ${t.min} to ${t.max} agents (/team size <min> <max>); /team stop <name> stops a member.`
|
||||
}
|
||||
|
||||
/** `/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. */
|
||||
export function agentsText(app: App, arg: string): string {
|
||||
@@ -151,7 +195,6 @@ export function changelogPrompt(app: App, arg: string): { prompt: string } | { m
|
||||
/** `/init [focus]`: make or update AGENTS.md. */
|
||||
export function initPrompt(app: App, arg: string): string {
|
||||
return fill(promptText("tasks/init.md"), {
|
||||
root: app.project.root,
|
||||
focus: arg.trim() ? `What the user wants it to focus on: ${arg.trim()}` : "",
|
||||
}).replace(/\n{3,}/g, "\n\n")
|
||||
}
|
||||
@@ -176,6 +219,8 @@ export function expandSlash(app: App, text: string): Expanded | undefined {
|
||||
// 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." }
|
||||
return { kind: "message", name, text: agentsText(app, arg) }
|
||||
case "team":
|
||||
return { kind: "message", name, text: teamText(app, arg) }
|
||||
case "init":
|
||||
// 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 }
|
||||
|
||||
+70
-20
@@ -6,7 +6,8 @@
|
||||
import continueText from "../../harness/prompts/tasks/continue.md" with { type: "text" }
|
||||
import { isAbsolute } from "node:path"
|
||||
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 type { Level } from "../provider/effort.ts"
|
||||
import { evaluate, shadowsDeny, type Context as PermContext, 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 type { Plan } from "../plan/plan.ts"
|
||||
@@ -46,7 +47,8 @@ export interface EngineOptions {
|
||||
/** Today's date, as the note says it (tests set it). */
|
||||
today?: () => string
|
||||
mode: Mode
|
||||
effort: Effort | null
|
||||
/** The effort level (provider/effort.ts): an effort, `off` or `default`. */
|
||||
effort: Level
|
||||
perm: Omit<PermContext, "mode">
|
||||
toolCtx: Omit<ToolContext, "signal">
|
||||
maxSteps?: number
|
||||
@@ -61,6 +63,21 @@ export interface EngineOptions {
|
||||
/** 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). */
|
||||
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>
|
||||
}
|
||||
}
|
||||
|
||||
/** What a prompt carries besides its text. */
|
||||
@@ -89,7 +106,8 @@ export function mergeUsers(messages: Message[]): Message[] {
|
||||
const out: Message[] = []
|
||||
for (const m of messages) {
|
||||
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)
|
||||
}
|
||||
return out
|
||||
@@ -109,7 +127,7 @@ export const PLAN_META = "plan"
|
||||
export class Engine {
|
||||
messages: Message[] = []
|
||||
mode: Mode
|
||||
private _effort: Effort | null
|
||||
private _effort: Level
|
||||
usage: Usage | undefined
|
||||
/** messages.length when `usage` was reported: everything after it is not yet counted. */
|
||||
usageAt = 0
|
||||
@@ -165,6 +183,8 @@ export class Engine {
|
||||
private keptBase: Told | undefined
|
||||
/** The last request's usage, cached input tokens included, and what it was for. */
|
||||
lastRequest: { usage: Usage; kind: "step" | "compaction" } | undefined
|
||||
/** The prompt the team's offer was last decided for. */
|
||||
private teamFixed: Kept | undefined
|
||||
|
||||
constructor(readonly o: EngineOptions) {
|
||||
this.mode = o.mode
|
||||
@@ -198,6 +218,9 @@ export class Engine {
|
||||
if (this.model.spec.vision === true) could.add("vision")
|
||||
if (this.o.toolCtx.projectDir !== undefined) could.add("project")
|
||||
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 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")
|
||||
@@ -220,7 +243,20 @@ export class Engine {
|
||||
|
||||
/** The session's prefix, made now if there is none yet (or it was made for another model). */
|
||||
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 now = this.now(changing)
|
||||
const could = this.could()
|
||||
@@ -232,6 +268,7 @@ export class Engine {
|
||||
if (this.kept?.told.date) told.date = this.kept.told.date
|
||||
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.teamFixed = this.kept
|
||||
this.saveKept()
|
||||
return this.kept
|
||||
}
|
||||
@@ -258,7 +295,7 @@ export class Engine {
|
||||
* the model's from then on. */
|
||||
private takeNote(): string {
|
||||
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) {
|
||||
kept.told = told
|
||||
this.saveKept()
|
||||
@@ -279,6 +316,7 @@ export class Engine {
|
||||
rewound() {
|
||||
if (!this.kept || !this.keptBase) return
|
||||
this.kept.told = structuredClone(this.keptBase)
|
||||
this.o.team?.forget?.()
|
||||
this.saveKept()
|
||||
}
|
||||
|
||||
@@ -314,25 +352,25 @@ export class Engine {
|
||||
this.o.bus.emit({ type: "plan", plan: this.plan })
|
||||
}
|
||||
|
||||
get effort(): Effort | null {
|
||||
get effort(): Level {
|
||||
return this._effort
|
||||
}
|
||||
/** Said on the bus when it changes: the web UI's chat shows the device's model and effort,
|
||||
* whoever changed them. */
|
||||
set effort(v: Effort | null) {
|
||||
set effort(v: Level) {
|
||||
if (v === this._effort) return
|
||||
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.
|
||||
* 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
|
||||
this.model = model
|
||||
this.client = client
|
||||
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. */
|
||||
@@ -405,9 +443,10 @@ export class Engine {
|
||||
return summary
|
||||
}
|
||||
|
||||
/** Tokens in use now: the last reported usage plus an estimate of what was added since. The
|
||||
* reply's thinking is left out where the dialect never sends it back (openai-chat): it is in the
|
||||
* last reply's output but will not be in the next request. */
|
||||
/** Tokens in use now: the last reported usage plus an estimate of what was added since. A final
|
||||
* reply's thinking is left out: it is in its output, but the next request starts a turn and
|
||||
* 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 {
|
||||
if (!this.usage) return undefined
|
||||
return this.usage.input + this.usage.output - this.droppedThinking() + estimateValue(this.messages.slice(this.usageAt))
|
||||
@@ -416,7 +455,7 @@ export class Engine {
|
||||
private droppedThinking(): number {
|
||||
if (!this.usage) return 0
|
||||
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)
|
||||
return Math.min(this.usage.output, this.usage.reasoning ?? estimate)
|
||||
}
|
||||
@@ -553,7 +592,7 @@ export class Engine {
|
||||
private deliverInbox(): boolean {
|
||||
const notes = this.notes.splice(0)
|
||||
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.
|
||||
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 +600,7 @@ export class Engine {
|
||||
const texts = this.inbox.splice(0)
|
||||
const ids = this.inboxIds.splice(0).filter((i): i is string => typeof i === "string")
|
||||
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: "inbox", texts: [], mode: this.busyInput })
|
||||
return true
|
||||
@@ -592,6 +631,11 @@ export class Engine {
|
||||
// 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).
|
||||
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).
|
||||
const note = this.takeNote()
|
||||
this.push({
|
||||
@@ -638,7 +682,7 @@ export class Engine {
|
||||
// The same system prompt and tool list on every request of the session (§10).
|
||||
const stream = this.client.stream({
|
||||
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
|
||||
// call in its reply is not run.
|
||||
tools: this.toolSpecs(),
|
||||
@@ -721,7 +765,7 @@ export class Engine {
|
||||
? `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`,
|
||||
})
|
||||
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
|
||||
}
|
||||
bus.emit({
|
||||
@@ -828,7 +872,7 @@ export class Engine {
|
||||
}
|
||||
// No dialect takes an image inside a tool result the same way; a user turn right after the
|
||||
// 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 })
|
||||
if (signal.aborted) {
|
||||
bus.emit({ type: "done", reason: "cancelled" })
|
||||
return "cancelled"
|
||||
@@ -874,6 +918,7 @@ export class Engine {
|
||||
const resolved = resolveCall(call.name, raw)
|
||||
tool = toolMap.get(resolved.name) ?? tool
|
||||
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).
|
||||
const no = unavailable(tool.name, tool.requires ? [tool.requires] : undefined, this.met())
|
||||
if (no) return { result: { output: no, isError: true } }
|
||||
@@ -884,10 +929,15 @@ export class Engine {
|
||||
const { args: own, purpose } = splitPurpose(tool, raw)
|
||||
const parsed = tool.schema.safeParse(own)
|
||||
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("; ")
|
||||
return { result: { output: `Invalid arguments for ${tool.name}: ${why}`, isError: true } }
|
||||
}
|
||||
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 }
|
||||
let request = tool.permission(args, toolCtx)
|
||||
let edited: string | undefined
|
||||
|
||||
@@ -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,
|
||||
* 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. */
|
||||
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 say = (text: string, vars: Record<string, string>) => lines.push(fillPrompt(text, vars))
|
||||
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.
|
||||
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") })
|
||||
// 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 } }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
* 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
|
||||
* is one the session could meet at some point (`could`), decided once as the prompt is made; a
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// A lead session's team at work (harness spec 3.2.0, team.md): 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"
|
||||
|
||||
/** A member's engine, as the app made it. */
|
||||
export interface MemberHandle {
|
||||
engine: Engine
|
||||
/** 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 }
|
||||
/** 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 }
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/** 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()
|
||||
}
|
||||
|
||||
private changed() {
|
||||
this.o.bus.emit({ type: "team" })
|
||||
}
|
||||
|
||||
/** 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 }): { 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)
|
||||
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.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. */
|
||||
memberDone(name: string, reply: string, reason: string, leftover: string[]) {
|
||||
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 {
|
||||
if (tool.name === "task" && as !== TEAM.names.lead) return undefined
|
||||
// A tool the spec does not have (an MCP server's) by the class its call carries: read when it
|
||||
// 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,574 @@
|
||||
// Teams (harness spec 3.2.0, harness/team.md): a lead session's agents, their room, who receives
|
||||
// what, the size the person allows, a manager's refusals, and the slots of the model they run on.
|
||||
// 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"
|
||||
|
||||
export const TEAM = LOOP.team
|
||||
const TEXTS = TEAM.texts
|
||||
const LEAD = TEAM.names.lead
|
||||
export const TEAM_TOOLS = ["team_add", "team_read", "team_send", "team_stop"] as const
|
||||
const NAME = new RegExp(TEAM.names.pattern)
|
||||
|
||||
export type AgentState = "working" | "waiting" | "finished" | "idle"
|
||||
|
||||
/** 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 {
|
||||
name: string
|
||||
role: string
|
||||
/** Who added it; null for the lead. */
|
||||
manager: string | null
|
||||
/** May add members of its own (team_add's `lead`); the lead may. */
|
||||
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
|
||||
}
|
||||
|
||||
export interface RoomMessage {
|
||||
n: number
|
||||
from: string
|
||||
/** As addressed: names, or ["all"]. */
|
||||
to: string[]
|
||||
recipients: string[]
|
||||
text: string
|
||||
time: number
|
||||
report: boolean
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
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 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` helpers running in the team: who started each, and on which model. */
|
||||
private helpers: { id: number; by: string; model: string }[] = []
|
||||
private seq = 0
|
||||
private helperSeq = 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
|
||||
|
||||
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, { name: LEAD, role: TEAM.read.lead_role, manager: null, leads: true, goal: "", model: o.model, agent: "", state: o.leadState ?? "working", inbox: [], seq: this.seq++ })
|
||||
this.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 `agents: 1 ≤ min ≤ max ≤ ${cap} (the cap) — whole numbers; 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))
|
||||
}
|
||||
|
||||
/** §2: offered when subagents are on and the chat's model serves two at once — fixed as the
|
||||
* prompt is made (and made again on a model switch); `offered` is a kept prompt's decision. */
|
||||
fix(model = this.model, offered?: boolean): boolean {
|
||||
this.model = model
|
||||
const lead = this.agents.get(LEAD)
|
||||
if (lead) lead.model = model
|
||||
this.offered = offered ?? (this.subagents && this.parallelNow() >= 2)
|
||||
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` helper. */
|
||||
size(): number {
|
||||
return this.agents.size + this.helpers.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 {
|
||||
if ((this.max >= 2 && this.parallelNow() >= 2) || this.hasMembers()) return undefined
|
||||
return this.parallelNow() < 2 ? 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)) return this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||
if (tool === "task") return 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.helpers.filter((h) => h.model === model).length
|
||||
}
|
||||
|
||||
private free(model: string): boolean {
|
||||
const group = this.o.capacity(model).group
|
||||
if (group !== undefined) {
|
||||
for (const a of this.agents.values()) if (a.state === "working" && a.model !== model && this.o.capacity(a.model).group === group) return false
|
||||
for (const h of this.helpers) if (h.model !== model && this.o.capacity(h.model).group === group) return false
|
||||
}
|
||||
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` helper started by `by` (it counts and holds a slot until helperDone). */
|
||||
helperStart(by: string, model = this.model): number {
|
||||
const id = ++this.helperSeq
|
||||
this.helpers.push({ id, by, model })
|
||||
return id
|
||||
}
|
||||
|
||||
helperDone(id: number) {
|
||||
this.helpers = this.helpers.filter((h) => h.id !== id)
|
||||
this.fillSlots()
|
||||
}
|
||||
|
||||
/** The first helper `by` started, done (the conformance area's `helper_done`). */
|
||||
helperDoneBy(by: string) {
|
||||
const h = this.helpers.find((x) => x.by === by)
|
||||
if (h) this.helperDone(h.id)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
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): RoomMessage {
|
||||
const m: RoomMessage = { n: this.room.length + 1, from, to, recipients, text, time: this.now(), report }
|
||||
this.room.push(m)
|
||||
const mark = fill(report ? TEAM.marks.report : TEAM.marks.message, { from, to: to.join(", ") })
|
||||
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 }): string | undefined {
|
||||
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||
if (no) return no
|
||||
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)) return fill(TEXTS.taken, { name: a.name })
|
||||
if (by === LEAD && !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 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 }): { 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 caller = this.agents.get(by) ?? lead
|
||||
if (by === LEAD && goal) lead.goal = goal
|
||||
const member: TeamAgent = {
|
||||
name: a.name,
|
||||
role: a.role.trim().split("\n")[0]!,
|
||||
manager: by,
|
||||
leads: a.lead === true,
|
||||
goal: goal || caller.goal,
|
||||
model: a.model ?? caller.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)) }
|
||||
return { text: fill(member.state === "working" ? TEXTS.added : TEXTS.added_waiting, 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
|
||||
const list = [...new Set(typeof a.to === "string" ? [a.to] : a.to)]
|
||||
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,
|
||||
role: a.role,
|
||||
state: R.states[a.state],
|
||||
model: a.model,
|
||||
last: a.state === "working" ? R.active : a.ended !== undefined ? `${duration(this.now() - a.ended)} ago` : "—",
|
||||
}),
|
||||
)
|
||||
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 (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[]) {
|
||||
for (const a of gone) {
|
||||
this.agents.delete(a.name)
|
||||
this.o.effects?.stop(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 || 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 = []
|
||||
}
|
||||
|
||||
// ── 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 })] : [])].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 {
|
||||
const { registry, owner } = jobsOf(ctx)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -42,7 +43,7 @@ export const jobListTool = defineTool({
|
||||
permission: () => ({ permission: "job_list", class: "read", patterns: ["*"] }),
|
||||
async run(_a, 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" }
|
||||
const now = Date.now()
|
||||
const lines = jobs.map((j) => {
|
||||
@@ -101,6 +102,9 @@ export const jobSendTool = defineTool({
|
||||
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` }
|
||||
}
|
||||
// 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)
|
||||
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` }
|
||||
@@ -114,6 +118,12 @@ export const reportTool = defineTool({
|
||||
schema: z.object({ text: z.string().min(1) }),
|
||||
permission: () => ({ permission: "report", class: "interact", patterns: ["*"] }),
|
||||
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 job = owner().job
|
||||
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)
|
||||
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]
|
||||
if (renamed && args && typeof args === "object" && !Array.isArray(args)) {
|
||||
const a = { ...(args as Record<string, unknown>) }
|
||||
|
||||
@@ -11,6 +11,7 @@ import { planSubmitTool } from "./plan_submit.ts"
|
||||
import { planUpdateTool } from "./plan_update.ts"
|
||||
import { planWriteTool } from "./plan_write.ts"
|
||||
import { taskTool } from "./task.ts"
|
||||
import { teamAddTool, teamReadTool, teamSendTool, teamStopTool } from "./team_tools.ts"
|
||||
import { decisionsTool, tasksTool } from "./project_tools.ts"
|
||||
import { webFetchTool, webSearchTool } from "./web.ts"
|
||||
import { memoryTool } from "./memory.ts"
|
||||
@@ -40,6 +41,11 @@ export const BUILTIN_TOOLS: Tool[] = [
|
||||
webSearchTool,
|
||||
webFetchTool,
|
||||
taskTool,
|
||||
// Spec 3.2.0: a team (team.md), offered where the model serves two requests at once.
|
||||
teamAddTool,
|
||||
teamSendTool,
|
||||
teamReadTool,
|
||||
teamStopTool,
|
||||
planWriteTool,
|
||||
planUpdateTool,
|
||||
tasksTool,
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ export const grepTool = defineTool({
|
||||
if (a.literal) args.push("-F")
|
||||
if (a.include) args.push("--glob", a.include)
|
||||
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}`)
|
||||
for (const l of r.out.split("\n")) if (l) lines.push(l)
|
||||
} else {
|
||||
|
||||
+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.`)
|
||||
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.`)
|
||||
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)
|
||||
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) }
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// The team tools (harness spec 3.2.0, team.md; tools/team_*.json): add a member, send through the
|
||||
// room, read the team and the room, stop a member. 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(),
|
||||
}),
|
||||
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 }
|
||||
},
|
||||
})
|
||||
+16
-4
@@ -9,7 +9,8 @@ import { fillParameters, PURPOSE_DEF, TOOL_SPECS } from "../harness.ts"
|
||||
export interface ToolContext {
|
||||
/** Absolute project root. */
|
||||
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
|
||||
signal: AbortSignal
|
||||
/** 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
|
||||
* engine for the step's calls. */
|
||||
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). */
|
||||
callId?: string
|
||||
/** The project directory (.agent), when the project has one. */
|
||||
@@ -90,12 +94,16 @@ export interface SpawnOptions {
|
||||
description?: string
|
||||
model?: string
|
||||
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. */
|
||||
export interface SubagentHandleInfo {
|
||||
id: string
|
||||
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 {
|
||||
@@ -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
|
||||
* 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. */
|
||||
requires?: "vision" | "plan" | "project" | "knowledge" | "display"
|
||||
requires?: "vision" | "plan" | "project" | "knowledge" | "display" | "team"
|
||||
description: string
|
||||
schema: S
|
||||
/** 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
|
||||
* `description`), or false for a tool that never asks. */
|
||||
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
|
||||
/** 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>
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
// A model writes ~/x as a shell would; without this it becomes a directory named "~".
|
||||
if (p === "~" || p.startsWith("~/")) return resolve(expandHome(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
|
||||
// relative reading does not exist and the absolute one does, inside the project, take that.
|
||||
if (!existsSync(rel)) {
|
||||
|
||||
+12
-8
@@ -3,7 +3,9 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { stricterMode, type App as Runtime } from "../app.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 { Dialog, type DialogSpec } from "./components/dialog.tsx"
|
||||
import { Messages } from "./components/messages.tsx"
|
||||
@@ -58,7 +60,9 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
||||
const refresh = () => setRev((n) => n + 1)
|
||||
const mode = () => (rev(), app.engine.mode)
|
||||
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 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
|
||||
@@ -73,7 +77,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
||||
onCleanup(() => clearTimeout(flashTimer))
|
||||
// A mode changed inside a turn (a plan approved) must reach the status bar; so must a branch the
|
||||
// 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))
|
||||
// A setting changed by the agent, /settings or a command: show it. Theme and icons are drawn here.
|
||||
app.bus.on((e) => {
|
||||
@@ -223,8 +227,8 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
||||
setMode(m: Mode) {
|
||||
app.settings.set("mode", m)
|
||||
},
|
||||
setEffort(e: Effort | null) {
|
||||
app.settings.set("effort", e ?? "off")
|
||||
setEffort(e: Level) {
|
||||
app.settings.set("effort", e)
|
||||
},
|
||||
setTheme(name: string) {
|
||||
try {
|
||||
@@ -246,7 +250,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
||||
confirmStop("reload", () => {
|
||||
// Back on this session, with what it was using; everything else comes from the files again.
|
||||
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
|
||||
.close()
|
||||
.catch(() => {})
|
||||
@@ -402,7 +406,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
||||
const id = view.shellStart(command)
|
||||
const t0 = performance.now()
|
||||
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) => {
|
||||
view.shellEnd(id, r, performance.now() - t0)
|
||||
app.engine.pendingContext.push(`<shell command="${command.replaceAll('"', "'")}">\n${r.output}\n</shell>`)
|
||||
@@ -641,7 +645,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
||||
</Show>
|
||||
<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} />
|
||||
<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>
|
||||
<Show when={dialog()}>
|
||||
<Dialog spec={dialog()!} close={() => setDialog(undefined)} />
|
||||
|
||||
+79
-16
@@ -3,15 +3,15 @@
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { paths } from "../config/paths.ts"
|
||||
import { asMode, EFFORTS, MODES, type Effort, type Mode } from "../config/schema.ts"
|
||||
import { effortsFor } from "../provider/effort.ts"
|
||||
import { asMode, MODES, type Mode } from "../config/schema.ts"
|
||||
import { levelsFor, type Level } from "../provider/effort.ts"
|
||||
import { setTrust } from "../project/root.ts"
|
||||
import { commit, commitPlan, draftMessage } from "../git/commit.ts"
|
||||
import { doRelease, planRelease } from "../git/release.ts"
|
||||
import { instructionFiles } from "../prompt/assemble.ts"
|
||||
import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts"
|
||||
import { contextBreakdown, renderBreakdown } from "../session/context.ts"
|
||||
import { changelogPrompt, initPrompt, reviewPrompt } from "../session/commands.ts"
|
||||
import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/commands.ts"
|
||||
import { git } from "../git/run.ts"
|
||||
import { branches, deleteBranch, switchBranch } from "../git/branch.ts"
|
||||
import type { Ctx } from "./context.ts"
|
||||
@@ -47,7 +47,7 @@ export interface CommandCtx extends Ctx {
|
||||
sendAs?(prompt: string, shown: string, opts?: { model?: string; mode?: string; attachFrom?: string }): void
|
||||
openDialog(spec: DialogSpec): void
|
||||
setMode(m: Mode): void
|
||||
setEffort(e: Effort | null): void
|
||||
setEffort(e: Level): void
|
||||
setTheme(name: string): void
|
||||
setIcons(set: import("./theme.ts").IconSet): void
|
||||
/** A short word in the status bar that goes by itself. */
|
||||
@@ -94,6 +94,27 @@ 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.
|
||||
`
|
||||
|
||||
const TEAM_ROW = "\u0000team"
|
||||
|
||||
/** The team's size for this session: a few usual pairs, or any typed as `<min> <max>`. */
|
||||
function openTeamSize(c: CommandCtx) {
|
||||
const t = c.app.team.team
|
||||
const pairs = [
|
||||
[1, 1, "one agent: no team, no helper"],
|
||||
[1, 4, "you decide; up to three members"],
|
||||
[2, 4, "a team from the start"],
|
||||
[1, 8, "you decide; up to seven members"],
|
||||
[3, 8, "a team of at least three"],
|
||||
[1, Math.min(16, t.cap), "a bigger team, with sub-managers"],
|
||||
] as const
|
||||
const seen = new Set<string>()
|
||||
const options = pairs
|
||||
.filter(([min, max]) => max <= t.cap && !seen.has(`${min} ${max}`) && seen.add(`${min} ${max}`))
|
||||
.map(([min, max, what]) => ({ label: `${min} to ${max}`, value: `${min} ${max}`, description: what, current: t.min === min && t.max === max }))
|
||||
const apply = (v: string) => (c.view.info(teamText(c.app, `size ${v}`)), c.refresh())
|
||||
c.openDialog({ title: `Agents — now ${t.min} to ${t.max} (at most ${t.cap})`, options, onSelect: apply, custom: { label: "set to", onSubmit: apply } })
|
||||
}
|
||||
|
||||
export const COMMANDS: Command[] = [
|
||||
{
|
||||
name: "agents",
|
||||
@@ -171,7 +192,7 @@ export const COMMANDS: Command[] = [
|
||||
options: refs.map((r) => {
|
||||
const known = c.app.loaded.refs.find((x) => x.ref === r)!
|
||||
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 }
|
||||
}),
|
||||
onSelect: pick,
|
||||
@@ -180,21 +201,63 @@ export const COMMANDS: Command[] = [
|
||||
},
|
||||
{
|
||||
name: "effort",
|
||||
args: `[${EFFORTS.join("|")}|off]`,
|
||||
description: "reasoning effort for this model",
|
||||
args: "[level|on|off]",
|
||||
description: "how hard the model thinks: its levels, a Thinking switch, or nothing to choose",
|
||||
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) => {
|
||||
if (v === "off") return c.setEffort(null)
|
||||
if (!allowed.includes(v as Effort)) return c.view.info(`${c.app.engine.model.ref} takes ${allowed.join(", ")} or off`, "warn")
|
||||
c.setEffort(v as Effort)
|
||||
if (control === "switch" && (v === "on" || v === "thinking")) v = on
|
||||
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 Level)
|
||||
c.view.info(control === "switch" ? `thinking ${v === "off" ? "off" : `on (${v})`}` : `effort: ${v}`)
|
||||
}
|
||||
// The team's size sits beside the effort (team.md §2: shown only where a team is offered).
|
||||
const team = c.app.team.team
|
||||
const agentsRow = team.offered ? [{ label: `agents: ${team.min} to ${team.max}`, value: TEAM_ROW, description: "how many agents the team may have, you included" }] : []
|
||||
const choose = (v: string) => (v === TEAM_ROW ? openTeamSize(c) : set(v))
|
||||
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)
|
||||
c.openDialog({
|
||||
title: `Effort — ${c.app.engine.model.ref}`,
|
||||
options: [...allowed, "off"].map((e) => ({ label: e, value: e, current: (c.app.engine.effort ?? "off") === e })),
|
||||
onSelect: set,
|
||||
if (control === "none") return c.openDialog({ title: `Chat settings — ${m.ref}`, options: agentsRow, onSelect: choose })
|
||||
if (control === "switch")
|
||||
return c.openDialog({
|
||||
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>]]",
|
||||
description: "the team: its agents and the room; its size; stop a member",
|
||||
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)
|
||||
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()
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -934,7 +997,7 @@ export const COMMANDS: Command[] = [
|
||||
e.def.kind === "model"
|
||||
? c.app.modelRefs()
|
||||
: e.def.kind === "effort"
|
||||
? [...effortsFor(c.app.engine.model), "off"]
|
||||
? [...levelsFor(c.app.engine.model).levels]
|
||||
: e.def.kind === "boolean"
|
||||
? ["true", "false"]
|
||||
: key === "theme"
|
||||
|
||||
@@ -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
|
||||
* truncated: at 80 columns a long project name cuts the left short, and a running job is the one
|
||||
* 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 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
|
||||
@@ -50,6 +50,7 @@ export function StatusBar(p: { mode: () => string; model: () => string; effort:
|
||||
when={p.flash?.()}
|
||||
fallback={
|
||||
<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: c().statusDim }}>{view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""}</span>
|
||||
<span style={{ fg: c().statusText }}>{p.spend?.() ? `${p.spend()} · ` : ""}</span>
|
||||
|
||||
@@ -553,7 +553,8 @@ test("the model event: said whenever the model or effort changes, forwarded to L
|
||||
await until(() => events.filter((e) => e.event.type === "model").length >= 2, "two model events")
|
||||
const models = events.filter((e) => e.event.type === "model").map((e) => e.event)
|
||||
// `instance`: the model is spoken to through the connection this link's instance is.
|
||||
expect(models[0]).toEqual({ type: "model", ref: "example/b", effort: "off", connection: "example", instance: true })
|
||||
// b says nothing of its efforts: the common three, starting at medium (models/schema.json levels).
|
||||
expect(models[0]).toEqual({ type: "model", ref: "example/b", effort: "medium", connection: "example", instance: true })
|
||||
expect(models.at(-1)).toEqual({ type: "model", ref: "example/a", effort: "high", connection: "example", instance: true })
|
||||
})
|
||||
|
||||
|
||||
+5
-1
@@ -210,7 +210,7 @@ test("_lembas/directories is for the link only: an editor's ACP session has no r
|
||||
test("the chat's model and effort: named by the instance, at the start and between prompts", async () => {
|
||||
fake = fakeProvider([{ chunks: [delta({ content: "one" })] }, { chunks: [delta({ content: "two" })] }])
|
||||
mkdirSync(paths.config, { recursive: true })
|
||||
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n example:\n type: webui\n url: ${fake.url}\n models: { bonsai: {}, deepseek-flash: { efforts: [low, medium, high] } }\n`, { mode: 0o600 })
|
||||
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n example:\n type: webui\n url: ${fake.url}\n models: { bonsai: { thinking_switch: true, efforts: [medium] }, deepseek-flash: { efforts: [low, medium, high] } }\n`, { mode: 0o600 })
|
||||
writeFileSync(join(paths.config, "config.yaml"), "model: bonsai\n")
|
||||
const root = project()
|
||||
const { c } = await client({ roots: [root], maxMode: "auto", approvalTimeoutMs: 50, requireTrust: true }, undefined, "example")
|
||||
@@ -225,7 +225,11 @@ test("the chat's model and effort: named by the instance, at the start and betwe
|
||||
expect(changed).toEqual({ model: "bonsai", effort: "off" })
|
||||
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "again" }] })
|
||||
expect(fake.requests.at(-1).model).toBe("bonsai")
|
||||
// off (spec 3.2.0): thinking switched off, and no effort anywhere.
|
||||
expect(JSON.stringify(fake.requests.at(-1))).not.toContain("reasoning_effort")
|
||||
expect(fake.requests.at(-1).chat_template_kwargs).toEqual({ enable_thinking: false })
|
||||
// A level the model does not offer is read as its start level, and the answer says which.
|
||||
expect(await c.request<any>("_lembas/configure", { sessionId: s.sessionId, model: "deepseek-flash", effort: "off" })).toEqual({ model: "deepseek-flash", effort: "medium" })
|
||||
})
|
||||
|
||||
test("the chat's model, full stop (spec 3.0.0): `instance` on session/new; a model the device cannot run is -32602, never its own", async () => {
|
||||
|
||||
+91
-3
@@ -7,13 +7,14 @@ import { splitCommand, words } from "../src/permission/bash.ts"
|
||||
import { prefix } from "../src/permission/arity.ts"
|
||||
import { split } from "../src/library/chunks.ts"
|
||||
import { ThinkSplitter } from "../src/provider/think.ts"
|
||||
import { advertisedEfforts, effortRefused } from "../src/provider/effort.ts"
|
||||
import { advertisedEfforts, effortRefused, levelFields, levelsOf, type LevelMeta } from "../src/provider/effort.ts"
|
||||
import { replace } from "../src/tool/replace.ts"
|
||||
import { applyUnified, parseUnified } from "../src/tool/unidiff.ts"
|
||||
import { derive, joinBom, parse } from "../src/tool/patch.ts"
|
||||
import type { Mode, PermissionConfig } from "../src/config/schema.ts"
|
||||
import { apply, fromTodos, normalise, type Plan } from "../src/plan/plan.ts"
|
||||
import { ToolCallAccumulator } from "../src/provider/openai-chat.ts"
|
||||
import { ToolCallAccumulator, toOpenAIMessages } from "../src/provider/openai-chat.ts"
|
||||
import type { Message } from "../src/provider/types.ts"
|
||||
import { parseInfo, titleFor } from "../src/prototype/fence.ts"
|
||||
import { resolveBase } from "../src/library/store.ts"
|
||||
import { NarrowError, narrowParameters, type Narrow } from "../src/harness.ts"
|
||||
@@ -24,8 +25,11 @@ import { readBody } from "../src/tool/read.ts"
|
||||
import { applyOps, type Op, type Target } from "../src/memory/store.ts"
|
||||
import { jobNote, type NoteCase } from "../src/jobs/note.ts"
|
||||
import { compactAsk, noteFor, offeredFor, toldAt, unavailable, type Now, type Requirement, type Told } from "../src/session/prefix.ts"
|
||||
import { promptText } from "../src/prompt/assemble.ts"
|
||||
import { envLines, promptText } from "../src/prompt/assemble.ts"
|
||||
import { absPath, type ToolContext } from "../src/tool/tool.ts"
|
||||
import { TOOL_SPECS } from "../src/harness.ts"
|
||||
import { Team, TEAM_TOOLS, type Capacity } from "../src/team/team.ts"
|
||||
import { noteBlock } from "../src/session/prefix.ts"
|
||||
|
||||
type Case = Record<string, unknown>
|
||||
|
||||
@@ -68,6 +72,14 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
||||
return out
|
||||
},
|
||||
effort: (c) => ({ refused: effortRefused(c.message as string), advertised: advertisedEfforts(c.message as string) }),
|
||||
// Effort levels (models/schema.json `levels`): what a model offers, how it is shown, where it
|
||||
// starts, and what a request carries for a level.
|
||||
levels: (c) => {
|
||||
const meta = c.model as LevelMeta
|
||||
if (c.op === "levels") return levelsOf(meta)
|
||||
if (c.op === "request") return levelFields(meta, c.level as string)
|
||||
throw new Error(`levels: unknown op ${String(c.op)}`)
|
||||
},
|
||||
edit: (c) => {
|
||||
try {
|
||||
return { content: replace(c.content as string, c.old as string, c.new as string, c.all === true) }
|
||||
@@ -205,6 +217,82 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
||||
throw new Error(`prefix: unknown op ${String(st.op)}`)
|
||||
})
|
||||
},
|
||||
// Reasoning sent back during its turn (context.md §8): the history as the OpenAI chat dialect
|
||||
// sends it — role, content and reasoning_content (tool calls and ids left out, as the cases do).
|
||||
reasoning: (c) => {
|
||||
const history: Message[] = (c.messages as Record<string, any>[]).map((m, i): Message => {
|
||||
if (m.role === "user") return { role: "user", parts: [{ type: "text", text: m.content }], ...(m.turn === false ? { turn: false as const } : {}) }
|
||||
if (m.role === "assistant") return { role: "assistant", parts: [...(m.reasoning !== undefined ? [{ type: "reasoning" as const, text: m.reasoning }] : []), ...(m.content ? [{ type: "text" as const, text: m.content }] : [])] }
|
||||
return { role: "tool", callId: `c${i}`, name: "read", content: m.content }
|
||||
})
|
||||
return (toOpenAIMessages("", history, false) as Record<string, any>[]).map((m) => ({ role: m.role, content: m.content ?? "", ...(m.reasoning_content !== undefined ? { reasoning_content: m.reasoning_content } : {}) }))
|
||||
},
|
||||
// No absolute paths (context.md §11): the environment block's lines, and how a tool reads a path.
|
||||
paths: (c) => {
|
||||
if (c.op === "env") return envLines(c.root as string, c.started as string)
|
||||
if (c.op === "resolve") return absPath(c.path as string, { root: (c.root as string).replace(/\/+$/, ""), cwd: c.started as string } as ToolContext)
|
||||
throw new Error(`paths: unknown op ${String(c.op)}`)
|
||||
},
|
||||
// Teams (team.md): one lead session's team, step by step, through the CLI's own rules
|
||||
// (team/team.ts); the app drives the same class from real turns.
|
||||
team: (c) => {
|
||||
const start = c.start as { min: number; max: number; subagents?: boolean; capacity: Capacity }
|
||||
let capacity = start.capacity
|
||||
const team = new Team({ min: start.min, max: start.max, subagents: start.subagents, model: "m", capacity: () => capacity })
|
||||
const delivered = () => {
|
||||
const out: Record<string, string[]> = {}
|
||||
for (const d of team.delivered.splice(0)) (out[d.name] ??= []).push(d.text)
|
||||
return out
|
||||
}
|
||||
return (c.steps as Record<string, any>[]).map((st) => {
|
||||
const by = (st.by as string | undefined) ?? "lead"
|
||||
switch (st.op) {
|
||||
case "offered": {
|
||||
const a = team.get(by)!
|
||||
const tools = !team.offered ? [] : a.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"]
|
||||
return { tools: tools.sort(), setting: team.offered }
|
||||
}
|
||||
case "call": {
|
||||
const args = st.args as Record<string, any>
|
||||
let result: string | null
|
||||
if (st.tool === "team_read") {
|
||||
const no = team.offered ? team.unavailable() : "unavailable"
|
||||
const room = no ? [] : team.readRoom(by, args.since ?? 0).messages.map((m) => ({ n: m.n, from: m.from, to: m.to.join(", ") }))
|
||||
return { room, delivered: delivered() }
|
||||
}
|
||||
if (st.tool === "team_add") result = team.add(by, args as any).text
|
||||
else if (st.tool === "team_send") result = team.send(by, args as any)
|
||||
else if (st.tool === "team_stop") result = team.stopCmd(by, args.name)
|
||||
else {
|
||||
result = team.refusal(by, st.tool, args) ?? null
|
||||
if (st.tool === "task" && result === null && team.offered) team.helperStart(by)
|
||||
}
|
||||
return { result, delivered: delivered() }
|
||||
}
|
||||
case "finish":
|
||||
team.finish(by, st.reply ?? "", st.reason ?? "stop")
|
||||
return { delivered: delivered() }
|
||||
case "helper_done":
|
||||
team.helperDoneBy(by)
|
||||
return null
|
||||
case "set":
|
||||
if (st.capacity) capacity = st.capacity
|
||||
if (st.min !== undefined || st.max !== undefined) team.setSetting(st.min ?? team.min, st.max ?? team.max)
|
||||
return null
|
||||
case "person_stop":
|
||||
team.personStop(st.name)
|
||||
return null
|
||||
case "user": {
|
||||
const lines = team.noteLines()
|
||||
team.leadWants()
|
||||
return { note: lines.length ? noteBlock(lines) : null }
|
||||
}
|
||||
case "state":
|
||||
return team.state()
|
||||
}
|
||||
throw new Error(`team: unknown op ${String(st.op)}`)
|
||||
})
|
||||
},
|
||||
library_scope: (c) => resolveBase(c.bases as { id: string; name: string }[], c.base as string | null),
|
||||
toolcall_index: (c) => {
|
||||
// The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`.
|
||||
|
||||
@@ -93,18 +93,20 @@ describe("history", () => {
|
||||
})
|
||||
|
||||
describe("the context meter", () => {
|
||||
test("counts the reply's thinking only until it is dropped from the next request", async () => {
|
||||
test("counts a reply's thinking while its turn goes on (it is sent back), not after the turn's final reply (context.md §8)", async () => {
|
||||
const dir = setup([
|
||||
{ chunks: [delta({ reasoning_content: "x".repeat(4000) }), toolCall(0, "l1", "list", "{}"), usage(1000, 1100)] },
|
||||
{ chunks: [delta({ content: "ok" }, "stop"), usage(1150, 5)] },
|
||||
{ chunks: [delta({ reasoning_content: "y".repeat(4000) }), delta({ content: "ok" }, "stop"), usage(2150, 1100)] },
|
||||
])
|
||||
const app = createApp({ cwd: dir, mode: "edit", asker: noAsk, store: false })
|
||||
const used: number[] = []
|
||||
app.bus.on((e: Event) => e.type === "usage" && e.used !== undefined && used.push(e.used))
|
||||
await app.engine.prompt("go")
|
||||
// 1000 in + 1100 out, of which ~1000 thinking that is not sent again: ~1100, not 2100.
|
||||
expect(used[0]).toBeLessThan(1200)
|
||||
expect(used[0]).toBeGreaterThan(1000)
|
||||
// The step that called a tool: its ~1000 tokens of thinking go back with the next step.
|
||||
expect(used[0]).toBeGreaterThan(2000)
|
||||
// The final reply: 2150 in + 1100 out, of which ~1000 thinking the next turn does not send.
|
||||
expect(used[1]).toBeLessThan(2400)
|
||||
expect(used[1]).toBeGreaterThan(2150)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+4
-3
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { basename, join } from "node:path"
|
||||
import { createApp } from "../src/app.ts"
|
||||
import type { AskReply, Event } from "../src/bus/index.ts"
|
||||
import { paths } from "../src/config/paths.ts"
|
||||
@@ -49,11 +49,12 @@ describe("end to end", () => {
|
||||
expect(fake!.requests[1].messages.at(-1).content).toMatch(/^\[#0001 hello\.txt lines 1-1 of 1 sha [0-9a-f]{8}\]\n1: hello world$/)
|
||||
expect(fake!.requests[2].messages.at(-1).content).toMatch(/^\[#0002 hello\.txt sha [0-9a-f]{8}\]\n@@ -1,1 \+1,1 @@\n-hello world\n\+hello lembas$/)
|
||||
// System prompt carries env and the mode.
|
||||
expect(fake!.requests[0].messages[0].content).toContain(`Project root: ${dir}`)
|
||||
expect(fake!.requests[0].messages[0].content).toContain(`Project: ${basename(dir)} (paths are relative to its root)`)
|
||||
expect(fake!.requests[0].messages[0].content).not.toContain(dir)
|
||||
expect(fake!.requests[0].messages[0].content).toContain("Permission mode: edit")
|
||||
// knowledge_search/get are offered once any knowledge base has documents, and the suite shares
|
||||
// one LEMBAS_HOME: another file's base must not decide this list (found in the full run).
|
||||
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "job_send", "web_search", "web_fetch", "task", "plan_write", "plan_update", "ask_user", "plan_submit", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings", "recall"])
|
||||
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "job_send", "web_search", "web_fetch", "task", "team_add", "team_send", "team_read", "team_stop", "plan_write", "plan_update", "ask_user", "plan_submit", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings", "recall"])
|
||||
const ends = events.filter((e) => e.type === "tool_end")
|
||||
expect(ends.map((e) => e.type === "tool_end" && e.result.title)).toEqual(["hello.txt · 1–1 of 1", "hello.txt +1 −1"])
|
||||
const sid = store.sessions(1)[0]!.id
|
||||
|
||||
@@ -16,7 +16,9 @@ export interface Fake {
|
||||
/** `served`: what GET /v1/models lists, as a LLeMbas server lists its models (default two
|
||||
* placeholders). Any other GET — the instance's own description, its personalization — is a 404,
|
||||
* which a client reads as "this server does not have it". */
|
||||
export function fakeProvider(script: Scripted[], opts: { route?: (model: string) => Fake | undefined; served?: Record<string, unknown>[] } = {}): Fake {
|
||||
/** `script` may be a function of the request (and its number), for requests that race: a team's
|
||||
* agents ask at once, and each is answered by who is asking. */
|
||||
export function fakeProvider(script: Scripted[] | ((body: any, n: number) => Scripted | undefined), opts: { route?: (model: string) => Fake | undefined; served?: Record<string, unknown>[] } = {}): Fake {
|
||||
const requests: any[] = []
|
||||
const calls: { path: string; headers: Record<string, string> }[] = []
|
||||
let i = 0
|
||||
@@ -34,7 +36,7 @@ export function fakeProvider(script: Scripted[], opts: { route?: (model: string)
|
||||
if (to) return fetch(to.url.replace(/\/v1$/, "") + url.pathname + url.search, { method: "POST", headers: req.headers, body: JSON.stringify(body) })
|
||||
requests.push(body)
|
||||
calls.push({ path: url.pathname + url.search, headers: Object.fromEntries(req.headers.entries()) })
|
||||
const r = script[i++]
|
||||
const r = typeof script === "function" ? script(body, i++) : script[i++]
|
||||
if (!r) return new Response("script exhausted", { status: 500 })
|
||||
if ("status" in r) return new Response(r.body, { status: r.status })
|
||||
// gapMs: a pause between chunks, to look at the screen mid-stream.
|
||||
|
||||
+13
-1
@@ -153,10 +153,22 @@ test("spec 3.0.0: every tool says where it is offered; the CLI's are the agent's
|
||||
// plan_write's former name rather than a tool.
|
||||
expect(TOOL_SPECS.todo).toBeUndefined()
|
||||
expect(TOOL_SPECS.plan_write!.aliases.cli).toEqual(["todo"])
|
||||
const shared = ["ask_user", "edit", "glob", "grep", "job_list", "job_output", "job_send", "job_stop", "list", "memory", "plan_submit", "plan_update", "plan_write", "read", "recall", "report", "session_search", "show", "skill_manage", "skill_view", "skills_list", "task", "web_fetch", "web_search", "write"]
|
||||
const shared = ["ask_user", "edit", "glob", "grep", "job_list", "job_output", "job_send", "job_stop", "list", "memory", "plan_submit", "plan_update", "plan_write", "read", "recall", "report", "session_search", "show", "skill_manage", "skill_view", "skills_list", "task", "team_add", "team_read", "team_send", "team_stop", "web_fetch", "web_search", "write"]
|
||||
expect(Object.values(TOOL_SPECS).filter((t) => t.scope === "shared").map((t) => t.name).sort()).toEqual(shared.sort())
|
||||
})
|
||||
|
||||
test("spec 3.2.0: the team tools need `team` and answer one_agent; a manager's refused tools are exactly the writers and executors", () => {
|
||||
const loop = JSON.parse(readFileSync(join(HARNESS, "loop.json"), "utf8")) as { team: { unavailable: { one_agent: string }; work: { write: string[]; execute: string[]; kept: string[] } } }
|
||||
for (const n of ["team_add", "team_read", "team_send", "team_stop"]) {
|
||||
const t = TOOL_SPECS[n]!
|
||||
expect([t.scope, t.sessions, t.requires, t.unavailable]).toEqual(["shared", "both", ["team"], loop.team.unavailable.one_agent])
|
||||
}
|
||||
// team.work names every tool by its risk class, and nothing else: a new tool is placed by its class.
|
||||
const byRisk = (r: string) => Object.values(TOOL_SPECS).filter((t) => t.risk === r).map((t) => t.name).sort()
|
||||
expect([...loop.team.work.write].sort()).toEqual(byRisk("write"))
|
||||
expect([...loop.team.work.execute, ...loop.team.work.kept].sort()).toEqual(byRisk("execute"))
|
||||
})
|
||||
|
||||
test("the personality presets are shared prompt texts, one paragraph each", () => {
|
||||
const index = JSON.parse(readFileSync(join(HARNESS, "prompts", "index.json"), "utf8")) as { prompts: Record<string, { scope: string; llembas_text?: string }> }
|
||||
for (const n of ["concise", "pragmatic", "optimistic", "funny", "formal", "socratic"]) {
|
||||
|
||||
@@ -76,5 +76,6 @@ test("no instruction file anywhere on the way: the project is told to make one",
|
||||
mkdirSync(join(root, "a"))
|
||||
writeFileSync(join(paths.config, "config.yaml"), "")
|
||||
const system = assembleSystem({ modelRef: "s/m", family: "local", cwd: join(root, "a"), root, isGit: false, mode: "manual", planDir: join(root, ".agent", "plans"), toolNames: [] })
|
||||
expect(system).toContain(`It goes at the project root: ${root}/AGENTS.md.`)
|
||||
// Spec 3.2.0 (context.md §11): the block names no absolute path.
|
||||
expect(system).toContain("It goes at the project root: `AGENTS.md`.")
|
||||
})
|
||||
@@ -52,7 +52,8 @@ test("no clock in the prompt: two requests across a date change send the same sy
|
||||
expect(system(1)).toBe(system(0))
|
||||
expect(system(2)).toBe(system(0))
|
||||
expect(fake!.requests[2].tools).toEqual(fake!.requests[0].tools)
|
||||
expect(lastUser(0)).toBe(`first\n${NOTE.open}\nToday is 2026-11-04.\n${NOTE.close}`)
|
||||
// A model with no capacity said (a hosted API) offers a team: its size is said once, last (spec 3.2.0).
|
||||
expect(lastUser(0)).toBe(`first\n${NOTE.open}\nToday is 2026-11-04.\n\nThe team may have 1 to 4 agents, you included.\n${NOTE.close}`)
|
||||
expect(lastUser(1)).toBe("second")
|
||||
expect(lastUser(2)).toBe(`third\n${NOTE.open}\nToday is 2026-11-05.\n${NOTE.close}`)
|
||||
// A note is part of its message from then on: the next request sends the first one as it was.
|
||||
@@ -140,7 +141,8 @@ test("compaction: the same system and tools, every message, then the compaction
|
||||
expect(store.requests(sid).map((r) => r.kind)).toEqual(["step", "compaction"])
|
||||
// After it the prompt is made again, and the next user message says the date again.
|
||||
await app.engine.prompt("go on")
|
||||
expect(lastUser(2)).toMatch(/^go on\n<session-note>\nToday is \d{4}-\d{2}-\d{2}\.\n<\/session-note>$/)
|
||||
// The team's size too (spec 3.2.0): like the date, it is not in the prompt.
|
||||
expect(lastUser(2)).toMatch(/^go on\n<session-note>\nToday is \d{4}-\d{2}-\d{2}\.\n\nThe team may have 1 to 4 agents, you included\.\n<\/session-note>$/)
|
||||
})
|
||||
|
||||
test("a resumed session sends the prompt it kept, byte for byte; what changed meanwhile is a note", async () => {
|
||||
|
||||
+11
-4
@@ -139,11 +139,18 @@ describe("openai-chat streaming", () => {
|
||||
expect(fake.requests[0].chat_template_kwargs).toEqual({ reasoning_effort: "high" })
|
||||
})
|
||||
|
||||
test("effort outside the vocabulary is not sent", async () => {
|
||||
fake = fakeProvider([{ chunks: [delta({ content: "ok" })] }])
|
||||
test("a level the model does not offer is read as its start level (spec 3.2.0 levels)", async () => {
|
||||
fake = fakeProvider([{ chunks: [delta({ content: "ok" })] }, { chunks: [delta({ content: "ok" })] }, { chunks: [delta({ content: "ok" })] }])
|
||||
await collect(new OpenAIChatClient(model(fake.url, { efforts: ["low"] })), "high")
|
||||
expect(fake.requests[0].reasoning_effort).toBeUndefined()
|
||||
expect(fake.requests[0].chat_template_kwargs).toBeUndefined()
|
||||
expect(fake.requests[0].reasoning_effort).toBe("low")
|
||||
// off: only with the switch, and then enable_thinking false and no effort anywhere.
|
||||
await collect(new OpenAIChatClient(model(fake.url, { efforts: ["medium"], thinking_switch: true })), "off")
|
||||
expect(fake.requests[1].reasoning_effort).toBeUndefined()
|
||||
expect(fake.requests[1].chat_template_kwargs).toEqual({ enable_thinking: false })
|
||||
// default: a model that takes no efforts — nothing sent.
|
||||
await collect(new OpenAIChatClient(model(fake.url, { efforts: [] })), "default")
|
||||
expect(fake.requests[2].reasoning_effort).toBeUndefined()
|
||||
expect(fake.requests[2].chat_template_kwargs).toBeUndefined()
|
||||
})
|
||||
|
||||
test("template refuses an effort → retried without it, vocabulary learned from the message", async () => {
|
||||
|
||||
@@ -68,7 +68,7 @@ test("session, global and project scope: what is written where, and where a valu
|
||||
expect(readFileSync(join(cwd, ".agent/config.yaml"), "utf8")).toContain("auto_at: 0.6")
|
||||
expect(s.get("compaction.auto_at")).toMatchObject({ value: 0.6, source: "project" })
|
||||
|
||||
expect(() => s.set("effort", "max")).toThrow("takes")
|
||||
expect(() => s.set("effort", "max")).toThrow("offers the levels")
|
||||
expect(() => s.set("update.channel", "beta", "project")).toThrow("can be set for: global")
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Harness spec 3.2.0 through the whole app: effort levels as a request carries them, the model's
|
||||
// reasoning sent back during its turn and not after it, and no absolute path in what the model is
|
||||
// told — relative paths and the shell at the project root, wherever the session was started.
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { createApp } from "../src/app.ts"
|
||||
import type { AskReply } from "../src/bus/index.ts"
|
||||
import { paths } from "../src/config/paths.ts"
|
||||
import { delta, fakeProvider, toolCall, usage, type Fake } from "./fake-provider.ts"
|
||||
|
||||
let fake: Fake | undefined
|
||||
afterEach(() => fake?.stop())
|
||||
|
||||
function setup(script: Parameters<typeof fakeProvider>[0], spec = "{ context: 32768 }") {
|
||||
fake = fakeProvider(script)
|
||||
mkdirSync(paths.config, { recursive: true })
|
||||
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n fake:\n type: webui\n url: ${fake.url}\n models:\n fake/m: ${spec}\n`, { mode: 0o600 })
|
||||
writeFileSync(join(paths.config, "config.yaml"), "model: fake/m\n")
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), "lembas-320-")))
|
||||
Bun.spawnSync(["git", "init", "-q", dir])
|
||||
mkdirSync(join(dir, "packages", "web"), { recursive: true })
|
||||
writeFileSync(join(dir, "top.txt"), "at the root\n")
|
||||
writeFileSync(join(dir, "packages", "web", "index.ts"), "export {}\n")
|
||||
return dir
|
||||
}
|
||||
|
||||
const yes = { ask: async (): Promise<AskReply> => ({ kind: "once" }) }
|
||||
|
||||
describe("reasoning goes back during its turn (context.md §8)", () => {
|
||||
test("a step's reasoning is sent back within the turn, a step-boundary note does not end the turn, and the next turn sends none", async () => {
|
||||
const dir = setup([
|
||||
{ chunks: [delta({ reasoning_content: "List first." }), toolCall(0, "l1", "list", "{}"), usage(100, 10)] },
|
||||
{ chunks: [delta({ reasoning_content: "Now answer." }), delta({ content: "Two files." }, "stop"), usage(150, 10)] },
|
||||
{ chunks: [delta({ content: "Still two." }, "stop"), usage(200, 5)] },
|
||||
])
|
||||
const app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
|
||||
// A job's note delivered at the first step boundary: it does not start a turn.
|
||||
app.engine.note("[A background job you started has finished:]\njob1 (true) exited with 0 after 0s.")
|
||||
await app.engine.prompt("what is here?")
|
||||
const second = fake!.requests[1].messages.filter((m: any) => m.role === "assistant")
|
||||
expect(second).toEqual([expect.objectContaining({ reasoning_content: "List first." })])
|
||||
await app.engine.prompt("and now?")
|
||||
// A new turn: the earlier replies go without their reasoning.
|
||||
const third = fake!.requests[2].messages.filter((m: any) => m.role === "assistant")
|
||||
expect(third.length).toBe(2)
|
||||
for (const m of third) expect(m.reasoning_content).toBeUndefined()
|
||||
})
|
||||
|
||||
test("reasoning read from inline <think> tags goes back as reasoning_content, never as text", async () => {
|
||||
const dir = setup([
|
||||
{ chunks: [delta({ content: "<think>Look at the list.</think>" }), toolCall(0, "l1", "list", "{}"), usage(100, 10)] },
|
||||
{ chunks: [delta({ content: "Done." }, "stop"), usage(150, 10)] },
|
||||
])
|
||||
const app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
|
||||
await app.engine.prompt("look")
|
||||
const reply = fake!.requests[1].messages.find((m: any) => m.role === "assistant")
|
||||
expect(reply.reasoning_content).toBe("Look at the list.")
|
||||
expect(reply.content ?? "").not.toContain("think")
|
||||
})
|
||||
})
|
||||
|
||||
describe("effort levels in a request (models/schema.json levels)", () => {
|
||||
test("off on a model with the switch: enable_thinking false and no effort; the start level otherwise", async () => {
|
||||
const dir = setup([{ chunks: [delta({ content: "a" }, "stop")] }, { chunks: [delta({ content: "b" }, "stop")] }], "{ thinking_switch: true, efforts: [medium] }")
|
||||
const app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false, effort: "off" })
|
||||
expect(app.engine.effort).toBe("off")
|
||||
await app.engine.prompt("hi")
|
||||
expect(fake!.requests[0].chat_template_kwargs).toEqual({ enable_thinking: false })
|
||||
expect(fake!.requests[0].reasoning_effort).toBeUndefined()
|
||||
app.settings.set("effort", "medium")
|
||||
await app.engine.prompt("again")
|
||||
expect(fake!.requests[1].reasoning_effort).toBe("medium")
|
||||
expect(fake!.requests[1].chat_template_kwargs).toEqual({ reasoning_effort: "medium" })
|
||||
expect(() => app.settings.set("effort", "high")).toThrow("offers the levels off, medium")
|
||||
})
|
||||
|
||||
test("off asked of a model without the switch starts at its start level", async () => {
|
||||
const dir = setup([{ chunks: [delta({ content: "a" }, "stop")] }], "{ efforts: [low, high] }")
|
||||
const app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false, effort: "off" })
|
||||
expect(app.engine.effort).toBe("low")
|
||||
})
|
||||
})
|
||||
|
||||
describe("no absolute paths (context.md §11)", () => {
|
||||
test("started below the root: the prompt names the project and the directory relative to it; read and bash work from the root", async () => {
|
||||
const dir = setup([
|
||||
{ chunks: [toolCall(0, "r1", "read", '{"path":"top.txt"}'), toolCall(1, "b1", "bash", '{"command":"pwd","description":"where"}'), usage(100, 10)] },
|
||||
{ chunks: [delta({ content: "ok" }, "stop"), usage(150, 5)] },
|
||||
])
|
||||
const app = createApp({ cwd: join(dir, "packages", "web"), mode: "auto", asker: yes, store: false })
|
||||
await app.engine.prompt("look")
|
||||
const system = fake!.requests[0].messages[0].content as string
|
||||
expect(system).toContain(`Project: ${dir.split("/").pop()} (paths are relative to its root)\nStarted in: packages/web (relative to the project root)`)
|
||||
expect(system).not.toContain(dir)
|
||||
const results = fake!.requests[1].messages.filter((m: any) => m.role === "tool").map((m: any) => m.content as string)
|
||||
expect(results[0]).toContain("1: at the root")
|
||||
expect(results[1]).toContain(dir)
|
||||
expect(results[1]).not.toContain("packages/web")
|
||||
})
|
||||
})
|
||||
@@ -133,7 +133,9 @@ describe("worktrees", () => {
|
||||
await a.engine.prompt("fix it in a worktree")
|
||||
const child = fake!.requests[1].messages[0].content as string
|
||||
expect(child).toContain("You are working in a separate checkout")
|
||||
expect(child).toMatch(/Project root: .*worktrees/)
|
||||
// Named by its directory, never by an absolute path (context.md §11).
|
||||
expect(child).toMatch(/Project: general-fix-average-[a-z0-9]+ \(paths are relative to its root\)/)
|
||||
expect(child).not.toContain(root)
|
||||
expect(readFileSync(join(root, "calc.py"), "utf8")).toContain("len(v) - 1")
|
||||
const result = fake!.requests[4].messages.at(-1).content as string
|
||||
const branch = /branch (lembas\/general-fix-average-[a-z0-9]+)/.exec(result)?.[1]
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Teams (harness spec 3.2.0, team.md) through the whole app: the lead adds a member that runs on the
|
||||
// subagent machinery, its tools that change things answer the manager's refusal before anything is
|
||||
// asked, the member's final reply comes back as a marked report that wakes the idle lead, and the
|
||||
// size setting is said in a note. The rules themselves are conformance/team.json's.
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtempSync, realpathSync, writeFileSync, mkdirSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { createApp, type App } from "../src/app.ts"
|
||||
import type { AskReply } from "../src/bus/index.ts"
|
||||
import { paths } from "../src/config/paths.ts"
|
||||
import { LOOP } from "../src/harness.ts"
|
||||
import { teamLabel, teamText } from "../src/session/commands.ts"
|
||||
import { delta, fakeProvider, toolCall, type Fake, type Scripted } from "./fake-provider.ts"
|
||||
|
||||
let fake: Fake | undefined
|
||||
let app: App | undefined
|
||||
afterEach(async () => {
|
||||
await app?.close()
|
||||
app = undefined
|
||||
fake?.stop()
|
||||
})
|
||||
|
||||
const T = LOOP.team
|
||||
const yes = { ask: async (): Promise<AskReply> => ({ kind: "once" }) }
|
||||
const say = (text: string): Scripted => ({ chunks: [delta({ content: text }, "stop")] })
|
||||
const call = (id: string, name: string, args: unknown): Scripted => ({ chunks: [toolCall(0, id, name, JSON.stringify(args)), delta({}, "tool_calls")] })
|
||||
const system = (body: any): string => body.messages[0].content
|
||||
const isMember = (body: any, name: string) => system(body).includes(`You are "${name}", a member of a team`)
|
||||
const lastUser = (body: any): string => {
|
||||
const u = [...body.messages].reverse().find((m: any) => m.role === "user")
|
||||
return typeof u.content === "string" ? u.content : u.content.map((p: any) => p.text ?? "").join("")
|
||||
}
|
||||
// A result as the model reads it, less its id line (context.md §1).
|
||||
const toolResults = (body: any): string[] => body.messages.filter((m: any) => m.role === "tool").map((m: any) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content)).replace(/^\[#\d+\]\n/, ""))
|
||||
|
||||
function setup(answer: (body: any, n: number) => Scripted | undefined, config = "", spec = "{ context: 32768, parallel: 2 }") {
|
||||
fake = fakeProvider(answer)
|
||||
mkdirSync(paths.config, { recursive: true })
|
||||
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n fake:\n type: webui\n url: ${fake.url}\n models:\n fake/m: ${spec}\n`, { mode: 0o600 })
|
||||
writeFileSync(join(paths.config, "config.yaml"), `model: fake/m\n${config}`)
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), "lembas-team-")))
|
||||
Bun.spawnSync(["git", "init", "-q", dir])
|
||||
return dir
|
||||
}
|
||||
|
||||
async function until(what: () => boolean, ms = 10000) {
|
||||
const end = Date.now() + ms
|
||||
while (!what()) {
|
||||
if (Date.now() > end) throw new Error("timed out")
|
||||
await Bun.sleep(20)
|
||||
}
|
||||
}
|
||||
|
||||
describe("a team at work (team.md)", () => {
|
||||
test("the lead adds a member, is refused work, and is woken by the member's report", async () => {
|
||||
let lead = 0
|
||||
const dir = setup((body) => {
|
||||
if (isMember(body, "parser")) return say("The parser is done: src/parse.ts.")
|
||||
const n = lead++
|
||||
if (n === 0) return call("a1", "team_add", { name: "parser", role: "the parser", task: "Write the parser; report the file.", goal: "a calculator" })
|
||||
if (n === 1) return call("w1", "write", { path: "x.txt", content: "x" })
|
||||
return say(n === 2 ? "parser is on it." : "All done.")
|
||||
})
|
||||
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
|
||||
await app.engine.prompt("build a calculator")
|
||||
// The lead's report arrives — at a step boundary, or as a turn of its own when it was idle.
|
||||
await until(() => fake!.requests.some((r) => !isMember(r, "parser") && r.messages.some((m: any) => m.role === "user" && JSON.stringify(m.content).includes("[Team report from parser to lead:]"))))
|
||||
await until(() => !app!.engine.busy && app!.team.team.get("lead")!.state === "idle")
|
||||
|
||||
const first = fake!.requests[0]
|
||||
expect(first.tools.map((t: any) => t.function.name)).toEqual(expect.arrayContaining(["team_add", "team_send", "team_read", "team_stop"]))
|
||||
expect(system(first)).toContain("team_add")
|
||||
// The size is said once, in the note on the first message.
|
||||
expect(lastUser(first)).toContain(LOOP.context.note.team.replace("{{min}}", "1").replace("{{max}}", "4"))
|
||||
|
||||
const leads = fake!.requests.filter((r) => !isMember(r, "parser"))
|
||||
const results = toolResults(leads[leads.length - 1])
|
||||
expect(results[0]).toContain("Added parser (the parser)")
|
||||
expect(results[1]).toContain(T.texts.manager)
|
||||
|
||||
// The member: its own prompt, its brief as its first message, and the team tools a member has.
|
||||
const member = fake!.requests.find((r) => isMember(r, "parser"))!
|
||||
expect(system(member)).toContain("The team's goal: a calculator")
|
||||
expect(system(member)).toContain('you report to "lead"')
|
||||
expect(lastUser(member)).toContain("Write the parser; report the file.")
|
||||
const memberTools = member.tools.map((t: any) => t.function.name)
|
||||
expect(memberTools).toEqual(expect.arrayContaining(["team_send", "team_read"]))
|
||||
expect(memberTools).not.toContain("team_add")
|
||||
expect(memberTools).not.toContain("task")
|
||||
|
||||
// The room has the report; the lead manages the finished member; nothing was written.
|
||||
const t = app.team.team
|
||||
expect(t.room.map((m) => ({ from: m.from, to: m.to, report: m.report }))).toEqual([{ from: "parser", to: ["lead"], report: true }])
|
||||
expect(t.get("parser")!.state).toBe("finished")
|
||||
expect(t.isManager("lead")).toBe(true)
|
||||
expect(teamLabel(t)).toBe("team 2/4")
|
||||
expect(teamText(app, "")).toContain("- parser — the parser · finished")
|
||||
expect(await Bun.file(join(dir, "x.txt")).exists()).toBe(false)
|
||||
// A member is not a job to the model.
|
||||
expect(app.jobs().every((j) => j.team === "parser")).toBe(true)
|
||||
})
|
||||
|
||||
test("max 1: the tools are listed but answer one_agent, and task too; the note says to work alone", async () => {
|
||||
let n = 0
|
||||
const dir = setup(() => (n++ === 0 ? call("a1", "team_add", { name: "x", role: "r", task: "t", goal: "g" }) : n === 2 ? call("t1", "task", { description: "d", prompt: "p" }) : say("alone")), "agents: { min: 1, max: 1 }\n")
|
||||
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
|
||||
await app.engine.prompt("go")
|
||||
expect(lastUser(fake!.requests[0])).toContain(LOOP.context.note.team_one)
|
||||
const results = toolResults(fake!.requests[2])
|
||||
expect(results).toEqual([T.unavailable.one_agent, T.unavailable.one_agent])
|
||||
})
|
||||
|
||||
test("min 2: the lead is a manager from the start, and the note asks for members", async () => {
|
||||
let n = 0
|
||||
const dir = setup(() => (n++ === 0 ? call("e1", "bash", { command: "touch made.txt" }) : say("ok")), "agents: { min: 2, max: 4 }\n")
|
||||
app = createApp({ cwd: dir, mode: "auto", asker: yes, store: false })
|
||||
await app.engine.prompt("go")
|
||||
expect(lastUser(fake!.requests[0])).toContain(LOOP.context.note.team_lead.replace("{{members}}", "1"))
|
||||
expect(toolResults(fake!.requests[1])).toEqual([T.texts.manager])
|
||||
expect(await Bun.file(join(dir, "made.txt")).exists()).toBe(false)
|
||||
})
|
||||
|
||||
test("a model that serves one request at a time offers no team: no team tools, /team says why", async () => {
|
||||
const dir = setup(() => say("ok"), "", "{ context: 32768, parallel: 1 }")
|
||||
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
|
||||
await app.engine.prompt("go")
|
||||
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => n.startsWith("team_"))).toEqual([])
|
||||
expect(lastUser(fake!.requests[0])).not.toContain("The team may have")
|
||||
expect(teamText(app, "")).toContain("serves one request at a time")
|
||||
})
|
||||
|
||||
test("the size is refused outside its bounds, whole, and a change is said in the next note", async () => {
|
||||
let n = 0
|
||||
const dir = setup(() => say(`ok ${n++}`))
|
||||
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
|
||||
await app.engine.prompt("one")
|
||||
expect(teamText(app, "size 3 2")).toContain("1 ≤ min ≤ max ≤ 32")
|
||||
expect(teamText(app, "size 1 40")).toContain("1 ≤ min ≤ max ≤ 32")
|
||||
expect([app.team.team.min, app.team.team.max]).toEqual([1, 4])
|
||||
expect(teamText(app, "size 2 6")).toContain("2 to 6")
|
||||
expect(app.settings.get("agents.max").value).toBe(6)
|
||||
await app.engine.prompt("two")
|
||||
expect(lastUser(fake!.requests[1])).toContain("The team may have 2 to 6 agents, you included.")
|
||||
await app.engine.prompt("three")
|
||||
expect(lastUser(fake!.requests[2])).toBe("three")
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -122,7 +122,7 @@ describe("the project's AGENTS.md", () => {
|
||||
const base = { modelRef: "x/y", family: "local", cwd: root, root, isGit: true, mode: "edit" as const, planDir: join(root, ".agent/plans"), toolNames: [] }
|
||||
const missing = assembleSystem(base)
|
||||
expect(missing).toContain(`This project has no AGENTS.md yet`)
|
||||
expect(missing).toContain(`${root}/AGENTS.md`)
|
||||
expect(missing).toContain("It goes at the project root: `AGENTS.md`.")
|
||||
expect(missing).toContain("which branch the work goes on")
|
||||
expect(assembleSystem({ ...base, isGit: false })).toContain("not a git repository yet")
|
||||
expect(assembleSystem({ ...base, subagent: { name: "explore", instructions: "look" } })).not.toContain("AGENTS.md yet")
|
||||
|
||||
Reference in new issue
Block a user