Author SHA1 Message Date
HomerandClaude Opus 5.5 6a82e6cb61 2.0.0-beta.3
ci / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:40:15 +00:00
HomerandClaude Opus 5.5 536fd2aa45 Teams: a sub-manager's member is no looser than its manager's mode (team.md §4)
ci / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:12:33 +00:00
HomerandClaude Opus 5.5 d3af8c2f02 Changelog: harness spec 3.2.0 in the CLI — teams, levels, reasoning, paths
ci / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:11:41 +00:00
HomerandClaude Opus 5.5 6ec95fbd66 Teams in the CLI (harness spec 3.2.0, team.md)
ci / check (push) Waiting to run
The four team tools (tool/team_tools.ts) on the rules of team/team.ts,
driven from real turns by team/runtime.ts:

- Members run on the subagent job machinery (app.ts spawn with `member`):
  background, unattended, their own prompt from blocks/team_member.md
  (the whole of team_lead.md for one with lead: true), team_send and
  team_read (team_add and team_stop for one that leads). Not jobs to the
  model: job_list and the job_* tools skip them; a client over the link
  still sees them as subagent jobs named "<name>: <role>".
- The room: messages and reports with their marks, delivered at the next
  step boundary or waking a finished member or the idle lead; report
  posts to the manager.
- A manager's work calls answer the refusal before the permission check,
  by risk class (an MCP tool by its readOnlyHint); the tool list is
  unchanged. job_stop and reading bash stay.
- Slots: the lead waits for one before its turn (Esc stops the wait);
  members wait in order; task helpers in a team count toward the size
  and hold a slot; job_send restarting a helper is refused when full.
- The offer is decided with the prompt and kept with it: a resumed
  session takes it from its kept tool list.
- The size: agents.min/max settings, refused outside 1 <= min <= max <=
  cap, kept across /new and resume; said in a note on the first message,
  after a change, after a compaction and a rewind.
- TUI: /team (tree and room, size, stop a member, dialogs for both), an
  "agents" row beside the effort in /effort, "team n/max" in the status
  bar. /team works over ACP too.

tests/team.test.ts runs a lead, a member and its report through the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:08:52 +00:00
HomerandClaude Opus 5.5 fbefe7f844 Harness spec 3.2.0 in the CLI: effort levels, reasoning round-trip, no absolute paths
ci / check (push) Waiting to run
Levels (models/schema.json levels): provider/effort.ts levelsOf/levelFields/
readLevel — `off` only with thinking_switch and sends enable_thinking false
and no effort, `default` for a model that takes no efforts and sends
nothing, a level not offered is read as the start level. The engine's
effort is a level; /effort shows nothing for one level, a Thinking switch
for [off, X], a list otherwise; the status bar follows. /v1/models'
thinking_switch, efforts [] and capacity.parallel are read
(lembas/webui.ts); session/capacity.ts counts parallel slots.

Reasoning (context.md §8): an assistant message of the current turn
carries reasoning_content (provider/common.ts turnReasoning,
openai-chat.ts); step-boundary deliveries, nudges and the wrap-up are
marked turn: false and do not start a turn.

Paths (context.md §11): the <env> block names the project and, started
below the root, that directory relative to it (assemble.ts envLines);
instruction files and @ attachments are named from the root or as ~/…;
file tools and bash resolve relative paths against the project root.

The team rules (team/team.ts) and conformance runners for levels,
reasoning, paths and team; the team runtime follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:36:55 +00:00
HomerandClaude Opus 5.5 5c23087cef Harness spec 3.2.0: version, README, changelog
ci / check (push) Waiting to run
VERSION 3.2.0; README's file table, the `team` requirement and a 3.2.0
section; CHANGELOG Unreleased. Two tests that pinned the AGENTS.md
block's absolute root now pin the relative text (context.md §11).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:18:21 +00:00
HomerandClaude Opus 5.5 880289970b Harness spec 3.2.0: teams — subagents that work together
team.md: a lead adds members (team_add) that work in the background on
the parts they own; members may lead areas of their own. The chat
setting agents {min, max} (defaults 1/4, an instance cap of 32) bounds
the whole tree, task helpers included; a team is offered only where
subagents are enabled and the model serves two requests at once
(capacity.parallel). A manager's work calls — risk write or execute,
bar job_stop and reading bash — answer a fixed refusal while the tool
list stays unchanged. The room: team_send to names or all, delivered
under "[Team message from X to Y:]" at the next step or waking a
finished member; a member's final reply is its report to its manager;
team_read shows the tree and the visible part of the room; team_stop
stops a subtree. Past the model's capacity a member waits for a slot,
the lead first. Size notes in context.note; texts in loop.json team;
prompts blocks/team_lead.md and team_member.md; tools allowed by
default. Conformance area team, written from a reference model.

The CLI embeds the new tool and prompt files; its tools are pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:17:48 +00:00
HomerandClaude Opus 5.5 d09d51cfec Harness spec 3.2.0: reasoning goes back during its turn; no absolute paths
context.md §8: every assistant message of the current turn (after the
last message that started a turn — a step-boundary delivery does not)
carries its reasoning back, as reasoning_content in OpenAI chat and as
thinking blocks in Anthropic; earlier turns carry none, which the common
templates render identically. §11: an agent session's prompt names no
absolute machine path: the environment block names the project and says
paths are relative to its root (loop.json context.env), files outside
the project under home as ~/…; relative paths resolve against the
project root, absolute ones still accepted. agents-missing.md and
init.md no longer name the root. New conformance areas: reasoning, paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:08:53 +00:00
HomerandClaude Opus 5.5 fdfcf3666a Harness spec 3.2.0: effort levels, the thinking switch, parallel capacity
models/schema.json: thinking_switch (the server turns thinking off for
chat_template_kwargs.enable_thinking: false) and capacity.parallel
(single_session is parallel 1). The level `off` exists only with the
switch and sends enable_thinking false and no effort; a model that takes
no efforts has the one level `default`, which sends nothing. The levels a
chat offers are (off) + efforts; one level shows no control, [off, X] a
Thinking switch, more a slider. A start level and a level the model does
not offer follow one rule. quirks.json: thinking-default; acp.md: effort
values are levels. New conformance area: levels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:07:14 +00:00
76 changed files with 6797 additions and 214 deletions

No files matched your search

+38
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
3.1.0
3.2.0
+3 -3
View File
@@ -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
+337
View File
@@ -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"
}
}
}
]
}
+89
View File
@@ -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"
}
]
}
+323
View File
@@ -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
View File
@@ -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
+7 -2
View File
@@ -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
View File
@@ -34,7 +34,7 @@
"about": "Calls in one step run together, at most 4; a call that waits on the user (ask_user, plan_submit) runs alone; two calls on one file run in order. A tool's error is its result, never the end of the prompt.",
"max": 4
},
"capacity": "A subagent (task) is not started on a model whose server cannot hold it beside the prompt's own model — or beside the model of a subagent already running: another model of a connection that holds one model at a time, or the same model when it 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
+9 -3
View File
@@ -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
}
+4
View File
@@ -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",
+1 -1
View File
@@ -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.
+12
View File
@@ -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.
+14
View File
@@ -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}}
+21
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+62
View File
@@ -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)"
}
}
+28
View File
@@ -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)"
}
}
}
}
+39
View File
@@ -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"
]
}
}
+31
View File
@@ -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
View File
@@ -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,
+34 -1
View File
@@ -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",
+17 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 = ""
+3
View File
@@ -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
View File
@@ -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(", ") : ""}`)
}
+6
View File
@@ -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
View File
@@ -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."),
+6 -3
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -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
}
+9 -6
View File
@@ -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
View File
@@ -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}`
+15
View File
@@ -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))
}
+80 -16
View File
@@ -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
}
+6 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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
+197
View File
@@ -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)
}
}
+574
View File
@@ -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
View File
@@ -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.")
+2
View File
@@ -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>) }
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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) }
+94
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+2 -1
View File
@@ -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>
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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`.
+7 -5
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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
View File
@@ -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"]) {
+2 -1
View File
@@ -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`.")
})
+4 -2
View File
@@ -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
View File
@@ -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 () => {
+1 -1
View File
@@ -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")
})
+102
View File
@@ -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")
})
})
+3 -1
View File
@@ -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]
+148
View File
@@ -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
View File
@@ -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")