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
HomerandClaude Opus 5.5 a97c5b46a4 2.0.0-beta.2
ci / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 21:54:35 +00:00
HomerandClaude Opus 5.5 aa83bb9783 Harness spec 3.1.0: carried by both implementations
ci / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:37:08 +00:00
HomerandClaude Opus 5.5 8e06590d57 Harness spec 3.1.0: the prefix stays still, in the CLI
ci / check (push) Canceled after 0s
The system prompt is made once per session (start, compaction, model
switch), kept in session_meta and resent byte for byte; it holds no
date. What changes mid-session -- the date, a mode the person chose, a
plan or memory or instruction file they edited, a personality, MCP
instructions that arrived later -- is a <session-note> on the next user
message, stored with it. The tool list is fixed for the session:
plan_submit is always offered and answers unavailable outside plan
mode, the wrap-up keeps the tools and runs no call, a withdrawn tool
stays listed and answers its refusal; the first request waits up to
mcp_wait_ms for MCP servers. Compaction asks for its summary as the
next turn of the same conversation. Cached input tokens are parsed
(incl. llama.cpp timings), stored per request and shown in /context.

Spec: context.md §7/§10, loop.json context.note (+ plan_ended,
plan_own, personal, empty, mcp_wait_ms), modes.json plan.refused,
compact.md without the transcript heading. Conformance area prefix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:22:02 +00:00
HomerandClaude Opus 5.5 ecae6247fd Harness spec 3.1.0-draft: the prefix stays still
ci / check (push) Canceled after 0s
Measured on 2026-11-05 against a local model's prompt cache: a harness
that rebuilds its system prompt for every request re-reads the whole
session whenever any part changes (a date at midnight: 16,912 tokens,
99 % -> 0 % cached, 139 s). context.md §10: the system prompt is made
once per session and resent; no clock in it; mid-session changes go as
a <session-note> on the next user message (loop.json context.note); the
tool list is fixed for the session, a requires that comes and goes
answers unavailable instead (plan_submit gains requires: plan); §7: the
compaction summary is the next turn of the same conversation, not a
separate request with a pasted transcript; cached tokens are recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:37:25 +00:00
95 changed files with 8404 additions and 373 deletions

No files matched your search

+67 -1
View File
@@ -4,7 +4,73 @@ All notable changes to LLeMbas CLI. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
[Semantic Versioning](https://semver.org/). [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [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
can reuse it instead of reading the whole session again.
### Changed
- **The system prompt is made once per session** — at the start, after a compaction, on a model
switch — kept with the session and sent byte for byte on every request; a resumed session sends
the one it kept. It holds no date any more.
- **What changes during a session is said in a note** after your words on your next message: the
date (on the first message, and when it changes), a permission mode you chose, a plan you
changed, memory or an instruction file you edited, a personality you picked, an MCP server's
instructions that arrived later. The model's own changes (its plan, its edits, a plan approval)
are not said again. Notes are not shown as your words.
- **The tool list is the same for the whole session.** `plan_submit` is offered in every mode and
answers that it works only in plan mode; `show` is offered from the start where the session can
have a viewer; knowledge search is decided as the session starts. The tools stay listed when a
step limit or budget winds a prompt up (a call in that answer is not run), and a tool refused
for good stays listed and answers that refusal. The first request waits up to 3 seconds for the
configured MCP servers, so their tools are in the first list.
- **Compaction asks for its summary as the next turn of the same conversation** instead of a
separate request with a pasted transcript, so it reuses what the server has read and no longer
loses the middle of a long session. A tool call in the summary's reply is not run.
### Added
- **Cached input tokens are recorded for every request** (the usage's own count, or llama.cpp's
`timings` and progress), kept with the session, and shown by `/context` for the last request.
## [2.0.0-beta.1] — 2026-11-03 ## [2.0.0-beta.1] — 2026-11-03
+52 -9
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/arity.json` | How much of a command an "always allow" covers |
| `permission/defaults.json` | The rules every session starts from | | `permission/defaults.json` | The rules every session starts from |
| `permission/modes.json` | The four modes — manual, edit, auto, plan — as what each does with each risk class | | `permission/modes.json` | The four modes — manual, edit, auto, plan — as what each does with each risk class |
| `models/schema.json` | Model metadata: context, output limit, efforts, vision, tools, notes, capacity | | `models/schema.json` | Model metadata: context, output limit, efforts and the thinking switch, vision, tools, notes, capacity (`parallel`); `levels` — the effort levels a chat offers and what each sends |
| `loop.json` | How one prompt runs: the step backstop, budgets, wrap-up, nudges, parallel calls, capacity; `jobs` — background work, its marks and limits; `context` — result ids, stubs, the output cap, the compaction table | | `loop.json` | How one prompt runs: the step backstop, budgets, wrap-up, nudges, parallel calls, capacity; `jobs` — background work, its marks and limits; `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 | | `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 | | `quirks.json` | What real servers do, and how both harnesses cope |
| `conformance/<area>.json` | Cases with their expected results, run by both test suites | | `conformance/<area>.json` | Cases with their expected results, run by both test suites |
| `acp.md` | The link between them: ACP plus every `_lembas/*` method, field and capability | | `acp.md` | The link between them: ACP plus every `_lembas/*` method, field and capability |
@@ -46,9 +47,12 @@ page *Harness-parity* in LLeMbas CLI's wiki.
- **Requires** (spec 3.0.0): what must hold for a tool to be offered at all — `page`: a browser - **Requires** (spec 3.0.0): what must hold for a tool to be offered at all — `page`: a browser
page of the chat is open and attached (not a schedule, not a background job); `display`: the page of the chat is open and attached (not a schedule, not a background job); `display`: the
session is shown somewhere a file can be opened (the web UI, Desktop; not a bare terminal); session is shown somewhere a file can be opened (the web UI, Desktop; not a bare terminal);
`forge`: the session's view holds a repository mounted from a forge. A requirement that goes away `forge`: the session's view holds a repository mounted from a forge; `plan`: the session is in
between the offer and the call makes the call answer the tool's `unavailable` text, word for plan mode; `team` (3.2.0): the session's team setting allows more than one agent, on a model that
word, as its result. 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.
- **Confirm** (spec 3.0.0): `always` — the call is put to the person before it runs, in every mode, - **Confirm** (spec 3.0.0): `always` — the call is put to the person before it runs, in every mode,
auto included, and runs on a yes only; nothing remembers the answer (`permission/modes.json`). auto included, and runs on a yes only; nothing remembers the answer (`permission/modes.json`).
- A tool's other names, per project, are listed in its `aliases` and work as the tool. A former - A tool's other names, per project, are listed in its `aliases` and work as the tool. A former
@@ -57,11 +61,50 @@ page *Harness-parity* in LLeMbas CLI's wiki.
`in_progress` → `doing`, `completed` → `done`, `cancelled` → `dropped`; `conformance/plan.json`). `in_progress` → `doing`, `completed` → `done`, `cancelled` → `dropped`; `conformance/plan.json`).
- **The plan** (`plan_write`, `plan_update`, `plan_submit`) is one structure both keep per session — - **The plan** (`plan_write`, `plan_update`, `plan_submit`) is one structure both keep per session —
title, summary, findings, objectives, phases of tasks with a status each, ids given by the title, summary, findings, objectives, phases of tasks with a status each, ids given by the
implementation and never by the model — shown as a card in every UI and put into the prompt implementation and never by the model — shown as a card in every UI. From 3.1.0 it is in the
(`{{plan_context}}`) on every step while it exists. `plan_write` and `plan_update` are offered in system prompt (`{{plan_context}}`) only as it was when the prompt was made; the model sees it change
every mode; `plan_submit` only in plan mode, where it is the approval. Their permission key is in its own `plan_write` / `plan_update` results, and a change by the person comes as a note
(`context.md` §10). All three are offered in every mode; `plan_submit` (`requires: plan`) is the
approval in plan mode and answers its `unavailable` text outside it. Their permission key is
`plan` (`permission/defaults.json`), which a rule written for the former `todo` key still reaches. `plan` (`permission/defaults.json`), which a rule written for the former `todo` key still reaches.
## 3.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
prompt cache: a harness that rebuilds its system prompt for every request re-reads the whole session
whenever any part of it changes — the date at midnight, a permission mode, a memory, the plan. So:
the system prompt is made once per session and resent byte for byte; no clock is in it; what
changes during a session comes as a `<session-note>` on the next user message (`loop.json`
`context.note`); the tool list is fixed for the session (`requires` that can come and go no longer
remove a tool — `plan_submit` gains `requires: plan` and an `unavailable` text); compaction asks
for its summary as the next turn of the same conversation instead of a separate request with a
pasted transcript (§7); every request records its cached input tokens.
## 3.0.0 ## 3.0.0
Written ahead of both implementations as `3.0.0-draft`, and `3.0.0` once both carried it (LLeMbas Written ahead of both implementations as `3.0.0-draft`, and `3.0.0` once both carried it (LLeMbas
+1 -1
View File
@@ -1 +1 @@
3.0.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` `cwd` must be a directory inside `remote.roots`, in a trusted project when `remote.require_trust`
(the default) — otherwise `-32602` with the reason. `mode` is lowered to `remote.max_mode`. (the default) — otherwise `-32602` with the reason. `mode` is lowered to `remote.max_mode`.
`lembas_model` is the instance's name of a model; `model` a ref the device knows (an editor's `lembas_model` is the instance's name of a model; `model` a ref the device knows (an editor's
choice); `effort` a level or `"off"`. choice); `effort` one of the model's levels (`models/schema.json` `levels`, spec 3.2.0: `"off"` where the model has `thinking_switch`, `"default"` where it takes no efforts, else an effort); a level the model does not offer is read as its start level, and the result says the level used.
Result: `{ sessionId: "ses_…", modes: { currentModeId, availableModes }, _meta: { lembas: { model, mode, effort, instance } } }`. Result: `{ sessionId: "ses_…", modes: { currentModeId, availableModes }, _meta: { lembas: { model, mode, effort, instance } } }`.
`instance` (spec 3.0.0) is a boolean, with the `model` event's meaning: true exactly when the `instance` (spec 3.0.0) is a boolean, with the `model` event's meaning: true exactly when the
@@ -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 | | `task` | `state: "start" \| "end"`, `turnId` — every turn has one: the client's, or one made here for a turn typed in a terminal |
| `steered` | `texts`, `ids?` — the `_lembas/steer` message ids taken in at this step | | `steered` | `texts`, `ids?` — the `_lembas/steer` message ids taken in at this step |
| `mode` | `mode` | | `mode` | `mode` |
| `model` | `ref` (the device's ref, above), `effort` (a level or `"off"`), `connection` (the device connection it is spoken to through) and **`instance: boolean`** — true exactly when that connection is the login of **this link's** instance, i.e. the model is one the instance serves (its served id is `ref`, or `ref` less a leading `<connection>/`). Whenever either changes, for any reason: `/model`, `/effort`, the settings tool, a fallback, `_lembas/configure` | | `model` | `ref` (the device's ref, above), `effort` (one of the model's levels: an effort, `"off"` or `"default"`), `connection` (the device connection it is spoken to through) and **`instance: boolean`** — true exactly when that connection is the login of **this link's** instance, i.e. the model is one the instance serves (its served id is `ref`, or `ref` less a leading `<connection>/`). Whenever either changes, for any reason: `/model`, `/effort`, the settings tool, a fallback, `_lembas/configure` |
| `plan` | `plan` — the structure, or `null` when cleared (what the `plan` session update carries) | | `plan` | `plan` — the structure, or `null` when cleared (what the `plan` session update carries) |
| `compacted` | `summary` | | `compacted` | `summary` |
| `title` | `id`, `title` | | `title` | `id`, `title` |
@@ -301,7 +301,7 @@ Only on the link (an error on `serve --stdio`).
### `_lembas/configure` — client → agent → result ### `_lembas/configure` — client → agent → result
Params `{ sessionId, model?: <instance's name>, effort?: <level> | "off" }` between prompts. Result Params `{ sessionId, model?: <instance's name>, effort?: <level> }` between prompts (a level as in `session/new`). Result
`{ model: <ref>, effort }`. Emits a `model` event when something changed. `{ model: <ref>, effort }`. Emits a `model` event when something changed.
### `_lembas/steer` — client → agent → result ### `_lembas/steer` — client → agent → result
+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"
}
]
}
+535
View File
@@ -0,0 +1,535 @@
{
"about": "The prefix (context.md §10, spec 3.1.0): one session's requests, in order — which system prompt each sends, which tools it offers, and the note on a user message. `start` is the session as it begins: `date`, `mode`, and what the prompt is made from that may change (`plan` as rendered, `memory`, `personality`, `files` — instruction texts by path or heading — `mcp` — instructions by server; each \"\" or {} for none), `could` (the requirements the session could ever meet: `plan` for any session that may enter plan mode, `display`, `page`, `forge`) and `met` (those met now besides plan, which is met in plan mode; default: all it could). `tools`: the names a case looks at; a tool's `requires` is the spec's. Steps: `user` (a user message: the request that carries it), `tool` (a tool step's request: no user message), `set` (something changes — any of the start's fields, or `met`; `by: model` when the model's own tool did it, whose result told the model: `plan` from plan_write, `mode` from plan_submit's approval, `memory` and `files` it changed itself; otherwise the person or the clock did), `call` (a call of `tool` now), `compact` (the summary is asked for: `{{transcript}}` and `{{previous_summary}}` empty — an earlier summary is already the first message), `wrapup` (the request after a step limit or a budget: the same prompt and tools, the wrap-up message as its last user message, no note; a call in its reply is not run) and `model` (the session moves to another model). A request answers `{system, tools, note}`: `system` numbers the system prompts the session has made, from 1, the same number being the same bytes; `tools` the case's tools it offers; `note` the note after the person's words, or null. `call` answers the tool's `unavailable` text, or null where it runs; `compact` answers the request's prompt, its tools and `ask`, the user message that follows every message as it was; `wrapup` answers like a request; `set` and `model` answer null; a value that became empty reads `context.note.empty`. `plan_dir` fills modes/plan.md. LLeMbas: services/context.py; CLI: session/prefix.ts, session/engine.ts.",
"cases": [
{
"about": "A date that changes during a session is a note on the next user message; the system prompt and the tools stay the same bytes.",
"start": {
"date": "2026-11-04",
"mode": "manual",
"could": [
"plan"
]
},
"tools": [
"read",
"plan_submit"
],
"steps": [
{
"op": "user"
},
{
"op": "tool"
},
{
"op": "user"
},
{
"op": "set",
"date": "2026-11-05"
},
{
"op": "tool"
},
{
"op": "user"
}
],
"expect": [
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-04.\n</session-note>"
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": null
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": null
},
null,
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": null
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
}
]
},
{
"about": "plan_submit is offered in every mode and answers its unavailable text outside plan mode; a mode the person chose is a note with the mode's text, one the approval set is not.",
"start": {
"date": "2026-11-05",
"mode": "manual",
"could": [
"plan"
]
},
"tools": [
"read",
"plan_write",
"plan_submit"
],
"steps": [
{
"op": "user"
},
{
"op": "call",
"tool": "plan_submit"
},
{
"op": "set",
"mode": "plan"
},
{
"op": "user"
},
{
"op": "call",
"tool": "plan_submit"
},
{
"op": "set",
"mode": "edit",
"by": "model"
},
{
"op": "tool"
},
{
"op": "user"
}
],
"expect": [
{
"system": 1,
"tools": [
"read",
"plan_write",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
"Not in plan mode: plan_submit is how a plan made in plan mode is put to the person for approval. Carry on with the work; keep the plan current with plan_update.",
null,
{
"system": 1,
"tools": [
"read",
"plan_write",
"plan_submit"
],
"note": "<session-note>\nThe permission mode is now plan:\nPermission mode: plan. You are planning, not implementing. Read, search and investigate as much as you need, but change nothing: no file but a plan under .agent/plans, no command that is not already allowed, nothing saved to the library.\n\nWrite the plan with plan_write, so that someone else could follow it: findings with file:line references, what will be true when it is done (objectives), and the work as phases of tasks in order, with the risks and how to verify it as the last phase. Ask with ask_user if a decision is the user's. When the plan is written, call plan_submit — that is how the user approves it, and after approval you carry on and implement it, keeping the plan current with plan_update.\n</session-note>"
},
null,
null,
{
"system": 1,
"tools": [
"read",
"plan_write",
"plan_submit"
],
"note": null
},
{
"system": 1,
"tools": [
"read",
"plan_write",
"plan_submit"
],
"note": null
}
]
},
{
"about": "The compaction request is the same prompt and tools, every message, then the compaction text with no transcript; after it the prompt is made again and the next user message says the date again.",
"start": {
"date": "2026-11-05",
"mode": "edit",
"could": [
"plan"
]
},
"tools": [
"read",
"plan_submit"
],
"steps": [
{
"op": "user"
},
{
"op": "tool"
},
{
"op": "compact"
},
{
"op": "user"
},
{
"op": "compact"
}
],
"expect": [
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": null
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"ask": "Summarise the conversation below so it can be carried forward after the earlier turns are dropped from your context. This is a working record, not a report for a reader.\n\nKeep, under these headings and in this order:\n\n## What we are doing\nThe goal, and where we have got to.\n\n## Decisions\nAnything settled, and why. A decision without its reason gets argued again.\n\n## Facts established\nNames, numbers, versions, file paths, commands, URLs and identifiers, copied exactly. Do not round them, paraphrase them or reconstruct one from memory — if it is not in the transcript, leave it out.\n\n## Files touched\nEach file read or changed that still matters, and what was done to it.\n\n## Open threads\nWhat is unfinished, and what was about to happen next.\n\nLeave out pleasantries, retracted ideas and anything already superseded. Do not answer the conversation: you are recording it. Write in the language of the conversation, and stay under 600 words."
},
{
"system": 2,
"tools": [
"read",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
{
"system": 2,
"tools": [
"read",
"plan_submit"
],
"ask": "Summarise the conversation below so it can be carried forward after the earlier turns are dropped from your context. This is a working record, not a report for a reader.\n\nKeep, under these headings and in this order:\n\n## What we are doing\nThe goal, and where we have got to.\n\n## Decisions\nAnything settled, and why. A decision without its reason gets argued again.\n\n## Facts established\nNames, numbers, versions, file paths, commands, URLs and identifiers, copied exactly. Do not round them, paraphrase them or reconstruct one from memory — if it is not in the transcript, leave it out.\n\n## Files touched\nEach file read or changed that still matters, and what was done to it.\n\n## Open threads\nWhat is unfinished, and what was about to happen next.\n\nLeave out pleasantries, retracted ideas and anything already superseded. Do not answer the conversation: you are recording it. Write in the language of the conversation, and stay under 600 words."
}
]
},
{
"about": "What the model changed itself is not said again; what the person changed is, once, in loop.json's order; an MCP server that connected later is said by its own instructions.",
"start": {
"date": "2026-11-05",
"mode": "manual",
"plan": "",
"memory": "Prefers tabs.",
"personality": "",
"files": {
"/p/AGENTS.md": "Build with make."
},
"mcp": {
"docs": "Search the docs first."
},
"could": [
"plan"
]
},
"tools": [
"read"
],
"steps": [
{
"op": "user"
},
{
"op": "set",
"by": "model",
"plan": "# Fix\n## p1 P (pending)\n[t1] [ ] a",
"memory": "Prefers tabs.\n§\nUses fish.",
"files": {
"/p/AGENTS.md": "Build with make; test with make test."
}
},
{
"op": "tool"
},
{
"op": "user"
},
{
"op": "set",
"plan": "",
"memory": "Uses fish.",
"personality": "Be brief.",
"files": {
"/p/AGENTS.md": "Build with make; test with make test.",
"/p/src/AGENTS.md": "Strict types."
},
"mcp": {
"docs": "Search the docs first.",
"tickets": "Name the ticket in every commit."
}
},
{
"op": "user"
},
{
"op": "set",
"files": {
"/p/AGENTS.md": "Build with make; test with make test."
}
},
{
"op": "user"
}
],
"expect": [
{
"system": 1,
"tools": [
"read"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
null,
{
"system": 1,
"tools": [
"read"
],
"note": null
},
{
"system": 1,
"tools": [
"read"
],
"note": null
},
null,
{
"system": 1,
"tools": [
"read"
],
"note": "<session-note>\nThe person changed the plan; it is now:\n(none)\n\nThe person changed what is remembered; it is now:\nUses fish.\n\n/p/src/AGENTS.md changed; it now reads:\nStrict types.\n\nThe person chose a different personality:\nBe brief.\n\nInstructions from an MCP server that connected since the session began:\n<server name=\"tickets\">\nName the ticket in every commit.\n</server>\n</session-note>"
},
null,
{
"system": 1,
"tools": [
"read"
],
"note": "<session-note>\n/p/src/AGENTS.md changed; it now reads:\n(none)\n</session-note>"
}
]
},
{
"about": "A move to another model makes the prompt again; the date said in the conversation still holds.",
"start": {
"date": "2026-11-05",
"mode": "auto",
"could": [
"plan"
]
},
"tools": [
"read"
],
"steps": [
{
"op": "user"
},
{
"op": "model"
},
{
"op": "user"
}
],
"expect": [
{
"system": 1,
"tools": [
"read"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
null,
{
"system": 2,
"tools": [
"read"
],
"note": null
}
]
},
{
"about": "A display the session could have is offered from the start and answers unavailable while there is none; one it never could have leaves show out.",
"start": {
"date": "2026-11-05",
"mode": "manual",
"could": [
"plan",
"display"
],
"met": []
},
"tools": [
"read",
"show"
],
"steps": [
{
"op": "user"
},
{
"op": "call",
"tool": "show"
},
{
"op": "set",
"met": [
"display"
]
},
{
"op": "call",
"tool": "show"
}
],
"expect": [
{
"system": 1,
"tools": [
"read",
"show"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
"Nothing can be shown now: this session has no screen to show a file on. Give the user the path instead.",
null,
null
]
},
{
"start": {
"date": "2026-11-05",
"mode": "manual",
"could": [
"plan"
]
},
"tools": [
"read",
"show"
],
"steps": [
{
"op": "user"
}
],
"expect": [
{
"system": 1,
"tools": [
"read"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
}
]
},
{
"about": "The wrap-up keeps the session's tool list and prompt, carries no note, and the next user message carries what changed meanwhile.",
"start": {
"date": "2026-11-05",
"mode": "edit",
"could": [
"plan"
]
},
"tools": [
"read",
"plan_submit"
],
"steps": [
{
"op": "user"
},
{
"op": "tool"
},
{
"op": "set",
"date": "2026-11-06"
},
{
"op": "wrapup"
},
{
"op": "user"
}
],
"expect": [
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": null
},
null,
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": null
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-06.\n</session-note>"
}
]
}
]
}
+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
+124 -8
View File
@@ -1,6 +1,7 @@
# Context hygiene # Context hygiene
Spec 3.0.0. What a session does to keep tool results from filling the model's context — the same in 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`; a chat and in an agent session, in both projects. The texts and numbers are `loop.json` `context`;
the cases are `conformance/context.json`; what the model is told is `prompts/blocks/context.md`. the cases are `conformance/context.json`; what the model is told is `prompts/blocks/context.md`.
@@ -11,7 +12,8 @@ rewritten.** Changing earlier context invalidates the prompt cache from that poi
server processes everything after it again, and a cloud API charges full input price for it again — server processes everything after it again, and a cloud API charges full input price for it again —
on a long context that costs more than the measure saves. So no earlier result is ever shortened, on a long context that costs more than the measure saves. So no earlier result is ever shortened,
replaced by a stub or removed afterwards. The one deliberate break is compaction, which already replaced by a stub or removed afterwards. The one deliberate break is compaction, which already
replaces the history with a summary. replaces the history with a summary. (From 3.2.0 a turn's reasoning is sent back during the turn and
not after it, §8: the common chat templates render the history the same either way.)
## 1. Every result has an id ## 1. Every result has an id
@@ -125,8 +127,16 @@ two results.
## 7. Compaction ## 7. Compaction
Compaction is the one deliberate break of the cache, and it already exists. In 3.0.0 two things Compaction is the one deliberate break of the cache, and it already exists. **From 3.1.0 the
come with it: summary is asked for as the next turn of the same conversation**: the same system prompt, the same
tools, every message as it was, and then one more user message — `prompts/tasks/compact.md`, whose
`{{transcript}}` and `{{previous_summary}}` are empty in this form, since the model has the
conversation, an earlier summary included, itself. So the request
that makes the summary costs only that message; a separate request with a pasted transcript was a
full re-read of everything, and a transcript capped to fit it lost the session's middle. A tool
call in the summary's reply is not run; its text before the call is the summary, and a reply with
none fails the compaction as an empty one does. The summary request is never shown as a turn of the
session, and it is not stored as one. In 3.0.0 two things came with compaction, and still do:
- After the summary the harness adds — itself, not the model — a table of the **reads the summary - After the summary the harness adds — itself, not the model — a table of the **reads the summary
covers**: the full reads made since the previous compaction (or since the session began) in turns covers**: the full reads made since the previous compaction (or since the session began) in turns
@@ -151,16 +161,122 @@ come with it:
- **The dedupe table is emptied**, so no stub ever points at content that is no longer in the - **The dedupe table is emptied**, so no stub ever points at content that is no longer in the
context. A read after compaction is a full read; a `recall` of any earlier id still works. context. A read after compaction is a full read; a `recall` of any earlier id still works.
## 8. Earlier reasoning ## 10. The prefix: what the model has already read
Left to the model's own chat template: the harness sends the history as it does, and neither strips A model's server keeps what it has read of a request and reuses it for the next one only as far as
reasoning a template keeps nor adds what it drops. the two are the same from the first byte. The system prompt and the tool list come first, so **a
change to either is a full re-read** of everything after them — on a long session minutes of work
on a local server, and the full input price again on a cloud API. Measured (2026-11-05, a local
model): a date that changed at midnight sent a 16,912-token session from 99 % cached to 0 %,
139 seconds. So, from 3.1.0:
1. **The system prompt is made once per session and sent unchanged.** It is built when the session
starts, kept with the session, and every request sends those exact bytes. It is built again only
where everything is read again anyway: after a compaction (§7), or when the session moves to
another model. A session that is resumed sends what it kept.
2. **Nothing in it is a clock.** No date or time. The date goes in a note (rule 3): the first user
message of a session carries it, and so does the next one after the date has changed.
3. **What changes during a session goes at the end, as a note** on the next user message, never
into earlier text: the permission mode (`modes/<mode>.md` as the note's body when it changes,
and its first line is in the prompt only as it was at the start), the plan when the person changed
it (the model's own `plan_write` and `plan_update` already answer with it), a memory or
instruction file the person edited, a personality chosen, an MCP server's instructions that
arrived later, the date. The note is a block after the person's words:
```
<session-note>
The permission mode is now plan: …
</session-note>
```
(`loop.json` `context.note`.) Each change is one block of the note, the blocks separated by a
blank line, in the order `context.note` lists them; a value that became empty reads
`context.note.empty`; the date is the person's own (their zone, else the machine's). A note is
part of that message from then on, like anything sent. A request with no user message (a tool
step), and words the person adds to a turn already running, carry no note; it waits for the next
message that starts a turn. A change the model made itself (its own `plan_write`, `memory`,
`settings`, a file it wrote) needs no note: its tool result already said it.
4. **The tool list is the same for the whole session.** A tool whose `requires` can come and go
during a session — `page`, `display`, `forge`, and plan mode for `plan_submit` — is offered from
the start whenever the session could ever meet it, and a call made while it is not met answers
the tool's `unavailable` text. Only what is fixed for the session may leave a tool out: the
session kind (`sessions`), the model (`vision`), a subagent's narrowing, what the project's
configuration or the person switched off before the session began. The tools stay listed in
the wrap-up request too (`loop.json` `wrap_up`): its message says they are withdrawn, and a call
in that reply is not run. A tool withdrawn after its final refusal stays in the list and answers
that refusal. **The one exception is an MCP server**: a session waits up to `mcp_wait_ms` for
the servers it is configured with before its first request, so their tools are in the first
list; a server that connects later adds its tools when it connects — one re-read, once — and its
instructions come as a note.
5. **Nothing already sent is rewritten** — the one rule above, for results; old tool output is
never pruned in place.
6. **Every request's cache use is recorded** where the provider reports it (cached input tokens),
beside the request's other usage, so a slow prompt has a cause on record.
What a server does with a matching prefix is its own business; a harness that keeps these rules has
done what it can.
## 8. Reasoning goes back to the model during its turn
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 ## 9. What the model is told
`prompts/blocks/context.md`, in every session that has `recall`. `prompts/blocks/context.md`, in every session that has `recall`.
## Not in 3.0.0 ## Not in 3.2.0
Eliding old content in batches behind a threshold, and measuring quality and tokens with and without Eliding old content in batches behind a threshold, and measuring quality and tokens with and without
these measures, are left for a later spec. Every measure above ships with its unit and conformance these measures, are left for a later spec. Every measure above ships with its unit and conformance
+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, - **Absolute** paths begin with `/`, the root of the tree: the organisation's root on the server,
the file system's root on a device. the file system's root on a device.
- **Relative** paths resolve against the session's **start directory** (its working directory). - **Relative** paths resolve against the session's **start directory** (its working directory) —
on a device, from spec 3.2.0, against the project root (*Node kinds*, below).
- `.` and `..` are resolved by their text, before anything is looked up; `..` above `/` stays at - `.` and `..` are resolved by their text, before anything is looked up; `..` above `/` stays at
`/`. Repeated `/` count as one, and a trailing `/` changes nothing. On the server there are no `/`. Repeated `/` count as one, and a trailing `/` changes nothing. On the server there are no
links: a path is what it says. On a device, a link is followed, and where the CLI's rules look at links: a path is what it says. On a device, a link is followed, and where the CLI's rules look at
@@ -42,7 +43,11 @@ one access level for the whole working directory, decided by the CLI's permissio
A session has a **boundary** (what it may see at all: the root, a directory, one repository) and a A session has a **boundary** (what it may see at all: the root, a directory, one repository) and a
**start directory** inside it. On a device the boundary is the project root (the directory the **start directory** inside it. On a device the boundary is the project root (the directory the
CLI's trust is given to) and the start directory is where the session started. CLI's trust is given to) and the start directory is where the session started. From spec 3.2.0 a
device resolves relative paths against the **project root**, not the start directory (`context.md`
§11): the model is told the project and, when it started below the root, that directory relative to
the root — one base for every relative path, whatever the directory. The start directory still
ends the instruction chain (below).
## Access levels ## Access levels
+121 -4
View File
@@ -23,7 +23,7 @@
} }
}, },
"wrap_up": { "wrap_up": {
"about": "One more request with no tools and a user message saying which limit was reached: answer now — what is done, what is not, what would come next. The transcript says the prompt was stopped, so an answer given because the room ran out is never mistaken for one the model chose to give.", "about": "One more request, with the session's tool list unchanged (context.md §10, rule 4) and a user message saying which limit was reached: answer now — what is done, what is not, what would come next. The transcript says the prompt was stopped, so an answer given because the room ran out is never mistaken for one the model chose to give. A tool call in that reply is not run.",
"message": "The {{limit}} for this prompt has been reached and the tools have been withdrawn. Answer now from what you have: what is done, what is not, and what would come next." "message": "The {{limit}} for this prompt has been reached and the tools have been withdrawn. Answer now from what you have: what is done, what is not, and what would come next."
}, },
"nudges": { "nudges": {
@@ -34,7 +34,7 @@
"about": "Calls in one step run together, at most 4; a call that waits on the user (ask_user, plan_submit) runs alone; two calls on one file run in order. A tool's error is its result, never the end of the prompt.", "about": "Calls in one step run together, at most 4; a call that waits on the user (ask_user, plan_submit) runs alone; two calls on one file run in order. A tool's error is its result, never the end of the prompt.",
"max": 4 "max": 4
}, },
"capacity": "A subagent (task) is not started on a model whose server cannot hold it beside the prompt's own model — or beside the model of a subagent already running: another model of a connection that holds one model at a time, or the same model when it serves one request at a time (models/schema.json capacity).", "capacity": "A subagent (task) is not started on a model whose server cannot hold it beside the prompt's own model — or beside the model of a subagent already running: another model of a connection that holds one model at a time, or the same model when it already serves as many of this session's requests as it can at once (models/schema.json capacity: `parallel`, `single_session` being parallel 1). A team's members are not refused there: they wait for a slot (team.md, Capacity).",
"jobs": { "jobs": {
"about": "Background work owned by a session: a command (bash `background: true`, or one still running when its timeout passed) or a subagent (task `background: true`). One id sequence per session (job1, job2, …), one list (job_list), one way to read (job_output), to stop (job_stop) and, for a subagent, to message (job_send); a subagent's own tools see only the jobs it started. A job that ends wakes the session that owns it: while a prompt runs, as a user message at the next step boundary; otherwise as a new turn of its own, marked `machine` so a UI shows it as the harness's, not the person's. A session that is not open keeps the note until it is resumed. Every such message begins with its mark, said rather than hidden: a model refuses an unmarked one as an injection (conformance/jobs.json gives the texts).", "about": "Background work owned by a session: a command (bash `background: true`, or one still running when its timeout passed) or a subagent (task `background: true`). One id sequence per session (job1, job2, …), one list (job_list), one way to read (job_output), to stop (job_stop) and, for a subagent, to message (job_send); a subagent's own tools see only the jobs it started. A job that ends wakes the session that owns it: while a prompt runs, as a user message at the next step boundary; otherwise as a new turn of its own, marked `machine` so a UI shows it as the harness's, not the person's. A session that is not open keeps the note until it is resumed. Every such message begins with its mark, said rather than hidden: a model refuses an unmarked one as an injection (conformance/jobs.json gives the texts).",
"marks": { "marks": {
@@ -61,8 +61,99 @@
}, },
"lifecycle": "A subagent's own background commands stop when it ends, and its report says so. A session's jobs stop when the session is deleted. A front end asked to exit while jobs run asks the person first; one with nobody to ask waits for the jobs the prompt started, then stops the rest." "lifecycle": "A subagent's own background commands stop when it ends, and its report says so. A session's jobs stop when the session is deleted. A front end asked to exit while jobs run asks the person first; one with nobody to ask waits for the jobs the prompt started, then stops the rest."
}, },
"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": { "context": {
"about": "Context hygiene (spec 3.0.0, context.md): every measure acts when a result is inserted, and nothing already sent to the model is ever rewritten — changing earlier context throws away the prompt cache from that point. conformance/context.json gives the texts.", "about": "Context hygiene (spec 3.0.0, the prefix from 3.1.0; context.md): every measure acts when a result is inserted, and nothing already sent to the model is ever rewritten — changing earlier context throws away the prompt cache from that point. conformance/context.json gives the texts.",
"ids": { "ids": {
"about": "Every tool result gets an id: `#` and its number in the session, counting from 1, in lowercase hex, at least 4 digits (`#0001`, `#000a`, `#10000`) — unique in the session and never reused (not after a compaction either), so `recall` finds any of them. It is the first thing in the result, in brackets.", "about": "Every tool result gets an id: `#` and its number in the session, counting from 1, in lowercase hex, at least 4 digits (`#0001`, `#000a`, `#10000`) — unique in the session and never reused (not after a compaction either), so `recall` finds any of them. It is the first thing in the result, in brackets.",
"digits": 4 "digits": 4
@@ -105,6 +196,32 @@
"line": "Read a file again for its content; recall shows a result by its id.", "line": "Read a file again for its content; recall shows a result by its id.",
"rows": 50 "rows": 50
}, },
"reasoning": "Earlier turns' reasoning is left to the model's chat template: the harness sends the history as it does, neither stripping reasoning the template keeps nor adding what it drops." "reasoning": "Spec 3.2.0 (context.md §8): every assistant message of the current turn — the messages after the last one that started a turn (the person's, or a machine message that starts a turn of its own; a delivery at a step boundary does not) — carries the reasoning it came with (OpenAI chat: `reasoning_content`; Anthropic: its thinking blocks, unchanged); assistant messages of earlier turns carry none. What is stored keeps it. conformance/reasoning.json.",
"env": {
"about": "Spec 3.2.0 (context.md §11): the environment block's lines about where the session works, in an agent session. `project` always, `name` being the project root's last path component; `started` only when the session started in a directory below the root, `path` being that directory relative to the root. No absolute path. conformance/paths.json.",
"project": "Project: {{name}} (paths are relative to its root)",
"started": "Started in: {{path}} (relative to the project root)"
},
"note": {
"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}}.",
"mode": "The permission mode is now {{mode}}:\n{{mode_text}}",
"plan_ended": "Plan mode has ended: make the changes the work needs; nothing is refused for planning any more.",
"plan": "The person changed the plan; it is now:\n{{plan}}",
"plan_own": "Your plan, as your plan tools left it:\n{{plan}}",
"memory": "The person changed what is remembered; it is now:\n{{memory}}",
"instructions": "{{path}} changed; it now reads:\n{{text}}",
"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." }, "max_output": { "type": "integer", "minimum": 1, "description": "The most tokens one reply may have." },
"temperature": { "type": "number", "minimum": 0, "maximum": 2, "description": "The sampling temperature the model is set up with. LLeMbas: the administrator's, which it also applies to a request that does not set one." }, "temperature": { "type": "number", "minimum": 0, "maximum": 2, "description": "The sampling temperature the model is set up with. LLeMbas: the administrator's, which it also applies to a request that does not set one." },
"top_p": { "type": "number", "minimum": 0, "maximum": 1, "description": "Nucleus sampling, as temperature." }, "top_p": { "type": "number", "minimum": 0, "maximum": 1, "description": "Nucleus sampling, as temperature." },
"efforts": { "type": "array", "items": { "enum": ["minimal", "low", "medium", "high", "xhigh", "max"] }, "description": "The reasoning efforts the model takes, in offering order. Narrowed when a server refuses one (quirks.json effort-refused)." }, "efforts": { "type": "array", "items": { "enum": ["minimal", "low", "medium", "high", "xhigh", "max"] }, "description": "The reasoning efforts the model takes, in offering order. Narrowed when a server refuses one (quirks.json effort-refused). Absent: each project's fallback (the CLI's: low, medium, high); [] — the model takes none, and its one effort level is `default`." },
"effort": { "enum": ["minimal", "low", "medium", "high", "xhigh", "max", "off"], "description": "The effort a new conversation starts with; off sends none." }, "thinking_switch": { "type": "boolean", "description": "Spec 3.2.0: the model's server turns thinking off for a request that sends `chat_template_kwargs.enable_thinking: false` (Qwen3.x and Nemotron 3.x templates on llama.cpp, vLLM, SGLang). Only then is the level `off` offered. Absent: false." },
"effort": { "enum": ["minimal", "low", "medium", "high", "xhigh", "max", "off", "default"], "description": "The level a new conversation starts with, when it is one of the levels the model offers (see `levels`); otherwise `medium` when the model takes it, else its first effort, else `default`. `off` — thinking switched off — exists only with thinking_switch; `default` — no effort sent, the model's own — only for a model that takes no efforts." },
"effort_style": { "enum": ["top", "kwargs", "both"], "description": "OpenAI Chat dialect: where the effort goes — top-level `reasoning_effort`, `chat_template_kwargs.reasoning_effort` (all llama.cpp reads), or both (the default)." }, "effort_style": { "enum": ["top", "kwargs", "both"], "description": "OpenAI Chat dialect: where the effort goes — top-level `reasoning_effort`, `chat_template_kwargs.reasoning_effort` (all llama.cpp reads), or both (the default)." },
"vision": { "type": "boolean", "description": "Takes images. Never sent images otherwise: most servers refuse the whole request." }, "vision": { "type": "boolean", "description": "Takes images. Never sent images otherwise: most servers refuse the whole request." },
"tools": { "type": "boolean", "description": "Takes tool calls. false: it can only talk; never sent a tools array." }, "tools": { "type": "boolean", "description": "Takes tool calls. false: it can only talk; never sent a tools array." },
@@ -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.", "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": { "properties": {
"group": { "type": "string", "description": "Models that share one server holding one model at a time (llama-swap): starting another model of the group unloads this one. The CLI: a model's `group`, or its connection when the connection sets one_model_at_a_time. LLeMbas: the connection's name when it sets one_model_at_a_time, which is what /v1/models says." }, "group": { "type": "string", "description": "Models that share one server holding one model at a time (llama-swap): starting another model of the group unloads this one. 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 "additionalProperties": false
} }
}, },
"levels": {
"about": "Spec 3.2.0. The effort levels a chat offers for a model, in order: `off` when thinking_switch is true, then the model's `efforts` — or, when it takes none, the one level `default`. What each sends (OpenAI chat): `off` — `chat_template_kwargs.enable_thinking: false` and no `reasoning_effort` anywhere; `default` — neither; an effort — `reasoning_effort` where effort_style puts it, and no `enable_thinking`. The Anthropic dialect: `off` sends no `thinking`. A level the model does not offer (an `off` for a model without the switch, from an older client or a stored chat) is read as the start level (`effort`'s rule). How it is shown: one level — no effort control at all; two, one of them `off` — a Thinking on/off switch, on being the other level; more — a slider or a list, in this order. conformance/levels.json.",
"control": ["none", "switch", "slider"]
},
"required": ["id"], "required": ["id"],
"additionalProperties": true "additionalProperties": true
} }
+4
View File
@@ -26,6 +26,10 @@
"job_stop": "allow", "job_stop": "allow",
"job_send": "allow", "job_send": "allow",
"report": "allow", "report": "allow",
"team_add": "allow",
"team_send": "allow",
"team_read": "allow",
"team_stop": "allow",
"web_search": "allow", "web_search": "allow",
"memory": "allow", "memory": "allow",
"session_search": "allow", "session_search": "allow",
+2 -1
View File
@@ -32,7 +32,8 @@
"read": "rules", "read": "rules",
"write": "allow only for files under the plans directory; anything else is refused", "write": "allow only for files under the plans directory; anything else is refused",
"execute": "only what the rules already allow; anything else is refused", "execute": "only what the rules already allow; anything else is refused",
"interact": "rules" "interact": "rules",
"refused": "Plan mode: nothing changes but the plan. Put what this would do into the plan with plan_write or plan_update; it can be done once the person approves the plan."
} }
}, },
"unattended": "With nobody to answer, whatever a mode would ask is refused instead, and the model is told so up front (prompts/modes/unattended.md)." "unattended": "With nobody to answer, whatever a mode would ask is refused instead, and the model is told so up front (prompts/modes/unattended.md)."
+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. --> kept by the agent, and how git is used there is the user's answer, asked once and written down. -->
## AGENTS.md ## AGENTS.md
This project has no AGENTS.md yet. It is the file every later session here starts from, so making it is part of the work: This project has no AGENTS.md yet. It is the file every later session here starts from, so making it is part of the work:
- Make it at the first natural pause in real work, once you know what the project is — not before you have done anything, and not in plan mode. It goes at the project root: {{root}}/AGENTS.md. - Make it at the first natural pause in real work, once you know what the project is — not before you have done anything, and not in plan mode. It goes at the project root: `AGENTS.md`.
- Before you write it, ask the user how git is to be used here, with ask_user, in one call: {{git_question}} Mark the options you would recommend. - Before you write it, ask the user how git is to be used here, with ask_user, in one call: {{git_question}} Mark the options you would recommend.
- Write their answer under a "## Git" heading. Above it, say briefly what the project is, how to build, run and test it, and the conventions that matter. - Write their answer under a "## Git" heading. Above it, say briefly what the project is, how to build, run and test it, and the conventions that matter.
- If the user does not want the file, do not bring it up again this session. - If the user does not want the file, do not bring it up again this session.
+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", "show": "when the session has a screen (`show` is offered): the sentence saying to open a file the user should see with show — a page, a chart, a document you made — rather than pasting it; otherwise empty",
"python": "when `python` is offered: the sentence saying quick calculations and file work can be done in Python in the user's browser, and that it cannot run when no browser page is open; otherwise empty" "python": "when `python` is offered: the sentence saying quick calculations and file work can be done in Python in the user's browser, and that it cannot run when no browser page is open; otherwise empty"
} }
},
"blocks/team_lead.md": {
"scope": "shared",
"llembas": [],
"llembas_text": "identical",
"why": "spec 3.2.0: LLeMbas reads it from the vendored spec (harness_spec.prompt), not from a fragment"
},
"blocks/team_member.md": {
"scope": "shared",
"llembas": [],
"llembas_text": "identical",
"why": "spec 3.2.0: 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"
}
} }
} }
} }
-2
View File
@@ -23,6 +23,4 @@ Leave out pleasantries, retracted ideas and anything already superseded. Do not
{{previous_summary}} {{previous_summary}}
## Transcript
{{transcript}} {{transcript}}
+1 -1
View File
@@ -1,6 +1,6 @@
<!-- Source: OpenCode command/template/initialize.txt (MIT, © 2025 opencode), shortened, and made to <!-- Source: OpenCode command/template/initialize.txt (MIT, © 2025 opencode), shortened, and made to
ask about git the way blocks/agents-missing.md does (LLeMbas CLI's /init). --> ask about git the way blocks/agents-missing.md does (LLeMbas CLI's /init). -->
Create or update `AGENTS.md` at the project root, {{root}}/AGENTS.md: the file every later session here starts from. Every line should answer "would an agent likely get this wrong without being told?" — if not, leave it out. Create or update `AGENTS.md` at the project root: the file every later session here starts from. Every line should answer "would an agent likely get this wrong without being told?" — if not, leave it out.
{{focus}} {{focus}}
+3 -2
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.", "about": "What OpenAI-compatible servers really do, and how both harnesses cope. Each quirk is detected, handled, and — where marked — learned, so the next request does not trip it again. The CLI keeps what it learned in ~/.local/state/lembas/learned.json; LLeMbas on the model row or in process memory.",
"quirks": [ "quirks": [
{ "id": "stream-options-refused", "servers": "older llama.cpp, some proxies", "symptom": "400 or 422 to a request carrying stream_options", "handling": "retry once without it, before anything was streamed", "learned": true }, { "id": "stream-options-refused", "servers": "older llama.cpp, some proxies", "symptom": "400 or 422 to a request carrying stream_options", "handling": "retry once without it, before anything was streamed", "learned": true },
{ "id": "effort-refused", "servers": "llama.cpp chat templates (Qwen3.8 refuses high)", "symptom": "an error whose text names the effort and says unexpected or supported (conformance/effort.json)", "handling": "retry once without an effort, before anything was streamed; narrow the model's efforts to those the message advertises", "learned": true }, { "id": "effort-refused", "servers": "llama.cpp chat templates (Qwen3.8 refuses high)", "symptom": "an error whose text names the effort and says unexpected or supported (conformance/effort.json)", "handling": "retry once without an effort (and without enable_thinking), before anything was streamed; narrow the model's efforts to those the message advertises. A narrowed list keeps the level off when the model has thinking_switch (models/schema.json levels)", "learned": true },
{ "id": "effort-placement", "servers": "llama.cpp", "symptom": "a top-level reasoning_effort is silently dropped", "handling": "send it top-level and as chat_template_kwargs.reasoning_effort (effort_style both)", "learned": false }, { "id": "effort-placement", "servers": "llama.cpp", "symptom": "a top-level reasoning_effort is silently dropped", "handling": "send it top-level and as chat_template_kwargs.reasoning_effort (effort_style both)", "learned": false },
{ "id": "prompt-progress", "servers": "llama.cpp with return_progress", "symptom": "prompt_progress {total, cache, processed, time_ms} frames before the reply", "handling": "ask for it, show how far the prompt has been read; a 400 to the field is learned and it is not asked again", "learned": true }, { "id": "prompt-progress", "servers": "llama.cpp with return_progress", "symptom": "prompt_progress {total, cache, processed, time_ms} frames before the reply", "handling": "ask for it, show how far the prompt has been read; a 400 to the field is learned and it is not asked again", "learned": true },
{ "id": "reasoning-fields", "servers": "llama.cpp, vLLM, DeepSeek, OpenRouter", "symptom": "reasoning arrives as reasoning_content, reasoning, or inline <think>/<thinking>/<reasoning> tags (conformance/think.json)", "handling": "all three read as reasoning; reasoning is never sent back as context", "learned": false }, { "id": "reasoning-fields", "servers": "llama.cpp, vLLM, DeepSeek, OpenRouter", "symptom": "reasoning arrives as reasoning_content, reasoning, or inline <think>/<thinking>/<reasoning> tags (conformance/think.json)", "handling": "all three read as reasoning. From spec 3.2.0 an assistant message of the current turn carries its reasoning back as reasoning_content, and earlier turns' carry none (context.md §8); inline tags are never sent back as text", "learned": false },
{ "id": "error-in-200-stream", "servers": "llama.cpp", "symptom": "an error object inside a 200 event stream, sometimes as an unterminated last line", "handling": "read as the error it is, not as an empty reply", "learned": false }, { "id": "error-in-200-stream", "servers": "llama.cpp", "symptom": "an error object inside a 200 event stream, sometimes as an unterminated last line", "handling": "read as the error it is, not as an empty reply", "learned": false },
{ "id": "tool-call-without-index", "servers": "some llama.cpp builds", "symptom": "tool-call fragments with no index", "handling": "fragments without an index continue the last call", "learned": false }, { "id": "tool-call-without-index", "servers": "some llama.cpp builds", "symptom": "tool-call fragments with no index", "handling": "fragments without an index continue the last call", "learned": false },
{ "id": "swap-banner", "servers": "llama-swap", "symptom": "a loading banner streamed before the reply while the model loads", "handling": "not shown as the reply", "learned": false }, { "id": "swap-banner", "servers": "llama-swap", "symptom": "a loading banner streamed before the reply while the model loads", "handling": "not shown as the reply", "learned": false },
{ "id": "model-unloaded", "servers": "llama-swap", "symptom": "a 'model unloaded' frame mid-reply, when another client loaded a different model", "handling": "treated as a server error: retried once", "learned": false }, { "id": "model-unloaded", "servers": "llama-swap", "symptom": "a 'model unloaded' frame mid-reply, when another client loaded a different model", "handling": "treated as a server error: retried once", "learned": false },
{ "id": "zero-usage", "servers": "several", "symptom": "a usage block of all zeros", "handling": "ignored; the count is estimated (marked ~) instead", "learned": false }, { "id": "zero-usage", "servers": "several", "symptom": "a usage block of all zeros", "handling": "ignored; the count is estimated (marked ~) instead", "learned": false },
{ "id": "no-context-length", "servers": "llama-swap /v1/models, most OpenAI-compatible servers", "symptom": "/models lists ids without a context length", "handling": "ask llama-server's /props (through llama-swap's /upstream/<model>), Ollama's /api/show, else unknown — never assume one", "learned": true }, { "id": "no-context-length", "servers": "llama-swap /v1/models, most OpenAI-compatible servers", "symptom": "/models lists ids without a context length", "handling": "ask llama-server's /props (through llama-swap's /upstream/<model>), Ollama's /api/show, else unknown — never assume one", "learned": true },
{ "id": "thinking-default", "servers": "llama.cpp, vLLM and SGLang serving Qwen3.x and Nemotron 3.x", "symptom": "a request that sends no effort still thinks, at the effort the server was started with — no effort is not no thinking", "handling": "thinking is switched off only by chat_template_kwargs.enable_thinking: false, on a model marked thinking_switch (models/schema.json levels); a request's own reasoning_effort overrides the server's default, and a value the template does not know is effort-refused", "learned": false },
{ "id": "max-completion-tokens", "servers": "OpenAI reasoning models", "symptom": "max_tokens refused", "handling": "send max_completion_tokens where configured", "learned": false } { "id": "max-completion-tokens", "servers": "OpenAI reasoning models", "symptom": "max_tokens refused", "handling": "send max_completion_tokens where configured", "learned": false }
] ]
} }
+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.
+4
View File
@@ -6,6 +6,10 @@
"exclusive": true, "exclusive": true,
"block": "tool", "block": "tool",
"purpose": false, "purpose": false,
"requires": [
"plan"
],
"unavailable": "Not in plan mode: plan_submit is how a plan made in plan mode is put to the person for approval. Carry on with the work; keep the plan current with plan_update.",
"aliases": { "aliases": {
"cli": [ "cli": [
"plan_exit" "plan_exit"
+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", "name": "lembas-cli",
"version": "2.0.0-beta.1", "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.", "description": "LLeMbas CLI: a terminal coding agent and project manager for a LLeMbas server — OpenTUI, memory, skills, MCP, git-native.",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
+34 -1
View File
@@ -870,7 +870,7 @@
"type": "string" "type": "string"
}, },
"effort": { "effort": {
"description": "The effort a session starts with, when the model takes it; otherwise the model's own default.", "description": "The effort level a session starts with, when the model offers it: an effort, off (thinking switched off, on a model with the switch) or default; otherwise the model's own start level.",
"anyOf": [ "anyOf": [
{ {
"type": "string", "type": "string",
@@ -886,9 +886,42 @@
{ {
"type": "string", "type": "string",
"const": "off" "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": { "settings_tool": {
"description": "Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool.", "description": "Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool.",
"type": "string", "type": "string",
+17 -3
View File
@@ -127,7 +127,7 @@
"maximum": 1 "maximum": 1
}, },
"efforts": { "efforts": {
"description": "The model's own effort vocabulary, in offering order.", "description": "The model's own effort vocabulary, in offering order. [] — it takes none (its one level is `default`).",
"type": "array", "type": "array",
"items": { "items": {
"type": "string", "type": "string",
@@ -141,8 +141,12 @@
] ]
} }
}, },
"thinking_switch": {
"description": "The server turns thinking off for a request with chat_template_kwargs.enable_thinking false: only then is the level `off` offered.",
"type": "boolean"
},
"effort": { "effort": {
"description": "Default effort; `off` sends none.", "description": "The level a conversation starts with, when the model offers it: an effort, `off` (thinking switched off, with thinking_switch) or `default` (nothing sent, for a model that takes no efforts).",
"anyOf": [ "anyOf": [
{ {
"type": "string", "type": "string",
@@ -158,6 +162,10 @@
{ {
"type": "string", "type": "string",
"const": "off" "const": "off"
},
{
"type": "string",
"const": "default"
} }
] ]
}, },
@@ -202,9 +210,15 @@
"maxLength": 300 "maxLength": 300
}, },
"single_session": { "single_session": {
"description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused.", "description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused. The same as parallel: 1.",
"type": "boolean" "type": "boolean"
}, },
"parallel": {
"description": "How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). A team is offered only on a model that serves at least 2.",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"group": { "group": {
"description": "Models sharing one GPU server that holds one model at a time (llama-swap), as the LLeMbas server says (capacity.group): a subagent on another model of the same group would unload this one, so it is refused.", "description": "Models sharing one GPU server that holds one model at a time (llama-swap), as the LLeMbas server says (capacity.group): a subagent on another model of the same group would unload this one, so it is refused.",
"type": "string" "type": "string"
+14 -9
View File
@@ -33,7 +33,8 @@ import { addPendingDeleted, clearPendingDeleted, pendingDeleted } from "./pendin
import { shellLaunch } from "./shellrc.ts" import { shellLaunch } from "./shellrc.ts"
import { sessionTurnFile, sessionTurns, TurnLog } from "./turns.ts" import { sessionTurnFile, sessionTurns, TurnLog } from "./turns.ts"
import { expandHome } from "../config/paths.ts" import { expandHome } from "../config/paths.ts"
import { asMode, EFFORTS, MODES, type Effort, type Mode } from "../config/schema.ts" import { asMode, MODES, type Mode } from "../config/schema.ts"
import { isLevel, readLevel, type Level } from "../provider/effort.ts"
import { HARNESS_VERSION } from "../harness.ts" import { HARNESS_VERSION } from "../harness.ts"
import { findProject, trustedRoots, trustedWithin, trustOf } from "../project/root.ts" import { findProject, trustedRoots, trustedWithin, trustOf } from "../project/root.ts"
import { VERSION } from "../version.ts" import { VERSION } from "../version.ts"
@@ -42,6 +43,7 @@ import type { Message } from "../provider/types.ts"
import type { Hub } from "./hub.ts" import type { Hub } from "./hub.ts"
import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts" import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts"
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs } from "./jobs.ts" import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs } from "./jobs.ts"
import { isNote } from "../session/prefix.ts"
export const ACP_VERSION = 1 export const ACP_VERSION = 1
/** The link's version (LLeMbas `services/device_link.py:PROTOCOL`, harness/acp.md *Versions*). 3: /** The link's version (LLeMbas `services/device_link.py:PROTOCOL`, harness/acp.md *Versions*). 3:
@@ -130,10 +132,10 @@ interface Terminal {
/** Most terminals one link may hold at once. */ /** Most terminals one link may hold at once. */
const MAX_TERMINALS = 4 const MAX_TERMINALS = 4
/** An effort as the instance sends it: a level, or "off"; anything else is not one. */ /** An effort level as the instance sends it (an effort, "off" or "default"); anything else is not
export function effortOf(v: unknown): Effort | "off" | undefined { * one. Whether the model offers it is the session's to decide (readLevel). */
if (v === "off" || v === "" || v === null) return "off" export function effortOf(v: unknown): Level | undefined {
return typeof v === "string" && (EFFORTS as readonly string[]).includes(v) ? (v as Effort) : undefined return isLevel(v) ? v : undefined
} }
const MODE_RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 } const MODE_RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
@@ -369,7 +371,9 @@ export function planReplyFrom(answer: any, maxMode?: Mode): PlanReply {
} }
/** Text the expander added to a user message (an @file, an image, a listing, a `!` command's output). */ /** Text the expander added to a user message (an @file, an image, a listing, a `!` command's output). */
const ATTACHMENT = /^<(file|directory|shell|image) / // What the model was given beside the words typed — attachments, and a session note
// (session/prefix.ts) — is not shown as typed.
const ATTACHMENT = { test: (t: string) => /^<(file|directory|shell|image) /.test(t) || isNote(t) }
/** A stored session as turns the web UI can draw: each person's message, then what the /** A stored session as turns the web UI can draw: each person's message, then what the
* model said, thought and ran in answer, up to the next one. A message's attachments are chips, * model said, thought and ran in answer, up to the next one. A message's attachments are chips,
@@ -616,7 +620,7 @@ export class AcpAgent {
sessionId: id, sessionId: id,
modes: { currentModeId: session.app.engine.mode, availableModes: this.modes().map((m) => ({ id: m, name: m })) }, modes: { currentModeId: session.app.engine.mode, availableModes: this.modes().map((m) => ({ id: m, name: m })) },
// `instance` (spec 3.0.0): the model is one this link's instance serves, as the model event says. // `instance` (spec 3.0.0): the model is one this link's instance serves, as the model event says.
_meta: { lembas: { model: session.app.engine.model.ref, mode: session.app.engine.mode, effort: session.app.engine.effort ?? "off", instance: this.isInstance(session.app.engine.model.connectionName) } }, _meta: { lembas: { model: session.app.engine.model.ref, mode: session.app.engine.mode, effort: session.app.engine.effort, instance: this.isInstance(session.app.engine.model.connectionName) } },
} }
} }
@@ -669,8 +673,9 @@ export class AcpAgent {
s.app.switchModel(ref) s.app.switchModel(ref)
} }
const effort = effortOf(p?.effort) const effort = effortOf(p?.effort)
if (effort !== undefined) s.app.engine.effort = effort === "off" ? null : effort // A level the model does not offer is read as its start level; the answer says the one used.
return { model: s.app.engine.model.ref, effort: s.app.engine.effort ?? "off" } if (effort !== undefined) s.app.engine.effort = readLevel(s.app.engine.model, effort)
return { model: s.app.engine.model.ref, effort: s.app.engine.effort }
} }
private modes(): Mode[] { 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 { commandSet, sourcesOf } from "../session/commands.ts"
import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts" import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts"
import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts" import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts"
import { readLevel } from "../provider/effort.ts"
import { clampMode, effortOf, markRemote, permissionParams, planParams, planReplyFrom, questionParams, questionReplyFrom, replyFrom, STOP, type PromptResult } from "./agent.ts" import { clampMode, effortOf, markRemote, permissionParams, planParams, planReplyFrom, questionParams, questionReplyFrom, replyFrom, STOP, type PromptResult } from "./agent.ts"
import { jobList, jobOutput, jobSend, jobStop, jobTranscript } from "./jobs.ts" import { jobList, jobOutput, jobSend, jobStop, jobTranscript } from "./jobs.ts"
import type { SessionContext } from "../app.ts" import type { SessionContext } from "../app.ts"
@@ -390,7 +391,7 @@ export class Share {
// is the instance's. // is the instance's.
modelConnection: this.app.engine.model.connectionName, modelConnection: this.app.engine.model.connectionName,
mode: this.app.engine.mode, mode: this.app.engine.mode,
effort: this.app.engine.effort ?? "off", effort: this.app.engine.effort,
busy: Boolean(this.app.turns.current), busy: Boolean(this.app.turns.current),
explicit, explicit,
}, },
@@ -596,9 +597,9 @@ export class Share {
// same model as `deepseek/x`, and switching to it again would reset the effort. // same model as `deepseek/x`, and switching to it again would reset the effort.
if (typeof p?.model === "string" && p.model && this.app.canonicalRef(p.model) !== this.app.engine.model.ref) this.app.switchModel(p.model) if (typeof p?.model === "string" && p.model && this.app.canonicalRef(p.model) !== this.app.engine.model.ref) this.app.switchModel(p.model)
const effort = effortOf(p?.effort) const effort = effortOf(p?.effort)
if (effort !== undefined) this.app.engine.effort = effort === "off" ? null : effort if (effort !== undefined) this.app.engine.effort = readLevel(this.app.engine.model, effort)
this.hooks.changed() this.hooks.changed()
return { model: this.app.engine.model.ref, effort: this.app.engine.effort ?? "off" } return { model: this.app.engine.model.ref, effort: this.app.engine.effort }
}) })
} }
} }
+213 -50
View File
@@ -12,12 +12,12 @@ import { boardSummary } from "./project/board.ts"
import { unsafeProjectFile } from "./project/safe.ts" import { unsafeProjectFile } from "./project/safe.ts"
import { Bus, type Asker } from "./bus/index.ts" import { Bus, type Asker } from "./bus/index.ts"
import { loadConfig, type Loaded } from "./config/load.ts" import { loadConfig, type Loaded } from "./config/load.ts"
import type { Effort, Mode } from "./config/schema.ts" import type { Mode } from "./config/schema.ts"
import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts" import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts"
import { hardlineRules } from "./permission/hardline.ts" import { hardlineRules } from "./permission/hardline.ts"
import { defaultEffort } from "./provider/effort.ts"
import { clientFor, familyOf, resolveModel } from "./provider/index.ts" import { clientFor, familyOf, resolveModel } from "./provider/index.ts"
import { assembleSystem, instructionFiles, knowledgeScope } from "./prompt/assemble.ts" import { assembleSystem, instructionFiles, knowledgeScope, modeText } from "./prompt/assemble.ts"
import type { Changing } from "./session/prefix.ts"
import { describeRepo, repoState } from "./git/repo.ts" import { describeRepo, repoState } from "./git/repo.ts"
import { findProject, persistProjectRule, trustOf, type Project } from "./project/root.ts" import { findProject, persistProjectRule, trustOf, type Project } from "./project/root.ts"
import type { Message } from "./provider/types.ts" import type { Message } from "./provider/types.ts"
@@ -41,17 +41,20 @@ import { Library, type Embedder } from "./library/store.ts"
import { instances as lembasInstances, keyFile as lembasKeyFile } from "./lembas/login.ts" import { instances as lembasInstances, keyFile as lembasKeyFile } from "./lembas/login.ts"
import { PERSONAL_KEYS, personalConfig, pushPersonalization } from "./lembas/personal.ts" import { PERSONAL_KEYS, personalConfig, pushPersonalization } from "./lembas/personal.ts"
import type { Plan } from "./plan/plan.ts" import type { Plan } from "./plan/plan.ts"
import { LIBRARY_SERVER } from "./mcp/index.ts" import { LIBRARY_SERVER, mcpInstructions } from "./mcp/index.ts"
import type { McpServer } from "./config/schema.ts" import type { McpServer } from "./config/schema.ts"
import { embedderFor } from "./library/embed.ts" import { embedderFor } from "./library/embed.ts"
import { effortsFor } from "./provider/effort.ts" import { isLevel, levelsFor, readLevel, type Level } from "./provider/effort.ts"
import { readingBashByDefault, Team, TEAM, TEAM_TOOLS, type Capacity, type TeamAgent } from "./team/team.ts"
import { TeamRuntime, type MemberHandle } from "./team/runtime.ts"
export interface AppOptions { export interface AppOptions {
cwd: string cwd: string
model?: string model?: string
mode?: Mode mode?: Mode
/** undefined = the model's default; "off" = none */ /** The effort level asked for (an effort, "off" or "default"); undefined, or one the model does
effort?: Effort | "off" * not offer: its start level. */
effort?: string
asker: Asker asker: Asker
store?: Store | false store?: Store | false
/** Nobody can answer an approval (headless, no --yes). Said in the system prompt up front. */ /** Nobody can answer an approval (headless, no --yes). Said in the system prompt up front. */
@@ -148,6 +151,8 @@ export interface App {
context: SessionContext context: SessionContext
/** Replace it (`{}`: none), naming the instance it came from for the prompt's heading. */ /** Replace it (`{}`: none), naming the instance it came from for the prompt's heading. */
setContext(ctx: SessionContext, from?: string): void setContext(ctx: SessionContext, from?: string): void
/** The session's team (spec 3.2.0, team.md): its rules and its members at work. */
team: TeamRuntime
/** The chat a session the client opened belongs to (its id): sent with every call to the server's /** The chat a session the client opened belongs to (its id): sent with every call to the server's
* memory, which answers in that chat's data group (harness/acp.md *Memory*); undefined for a * memory, which answers in that chat's data group (harness/acp.md *Memory*); undefined for a
* session of this device's own. The memory for the prompt is read again in it. */ * session of this device's own. The memory for the prompt is read again in it. */
@@ -214,13 +219,10 @@ export function createApp(o: AppOptions): App {
// An untrusted project is read-only: plan mode, and its own config is ignored. // An untrusted project is read-only: plan mode, and its own config is ignored.
let mode: Mode = o.mode ?? loaded.config.mode ?? "manual" let mode: Mode = o.mode ?? loaded.config.mode ?? "manual"
if (trust === "readonly") mode = "plan" if (trust === "readonly") mode = "plan"
// `effort:` in config when this model takes it, else the model's own default. // `effort:` in config when this model offers that level, else the model's own start level
const startEffort = (m: ResolvedModel) => { // (harness models/schema.json `levels`).
const e = loaded.config.effort const startEffort = (m: ResolvedModel): Level => readLevel(m, loaded.config.effort)
if (e === "off") return null const effort: Level = o.effort !== undefined && isLevel(o.effort) && (levelsFor(model).levels as string[]).includes(o.effort) ? o.effort : startEffort(model)
return e && effortsFor(m).includes(e) ? e : defaultEffort(m)
}
const effort = o.effort === "off" ? null : (o.effort ?? startEffort(model))
// One object for the session's lifetime, so a setting changed later reaches the tools. // One object for the session's lifetime, so a setting changed later reaches the tools.
loaded.config.search ??= {} loaded.config.search ??= {}
const bus = o.bus ?? new Bus() const bus = o.bus ?? new Bus()
@@ -253,8 +255,9 @@ export function createApp(o: AppOptions): App {
return s ? describeRepo(s) : undefined return s ? describeRepo(s) : undefined
} }
// Memory and skills go into the system prompt as they stood when the session started: a write // Memory and skills go into the system prompt as they stood when it was made: a write during the
// during the session reaches the file, not the prompt (Hermes' frozen snapshot). // session reaches the file, not the prompt (Hermes' frozen snapshot) — the person's own edit
// reaches the model as a note (session/prefix.ts).
const memoryOn = loaded.config.memory?.enabled !== false const memoryOn = loaded.config.memory?.enabled !== false
const memory = new MemoryStore({ memory: loaded.config.memory?.memory_chars ?? DEFAULT_LIMITS.memory, user: loaded.config.memory?.user_chars ?? DEFAULT_LIMITS.user }) const memory = new MemoryStore({ memory: loaded.config.memory?.memory_chars ?? DEFAULT_LIMITS.memory, user: loaded.config.memory?.user_chars ?? DEFAULT_LIMITS.user })
const projectDir = () => (trusted && existsSync(project.dir) ? project.dir : undefined) const projectDir = () => (trusted && existsSync(project.dir) ? project.dir : undefined)
@@ -263,17 +266,18 @@ export function createApp(o: AppOptions): App {
? new MemoryStore({ memory: loaded.config.memory?.memory_chars ?? DEFAULT_LIMITS.memory, user: 0 }, join(project.dir, "local"), { memory: "PROJECT MEMORY (this project only)" }) ? new MemoryStore({ memory: loaded.config.memory?.memory_chars ?? DEFAULT_LIMITS.memory, user: 0 }, join(project.dir, "local"), { memory: "PROJECT MEMORY (this project only)" })
: undefined : undefined
const skillList = () => loadSkills({ projectDir: projectDir(), external: loaded.config.skills?.external_dirs, disabled: loaded.config.skills?.disabled }) const skillList = () => loadSkills({ projectDir: projectDir(), external: loaded.config.skills?.external_dirs, disabled: loaded.config.skills?.disabled })
// Everything in the system prompt that could change during a session is taken once, here, for // What the system prompt holds that nothing during the session updates: taken as the session
// the same reason as the git block: the prompt must stay byte for byte the same, or the server // starts, for the same reason as the git block. The engine makes the prompt once and resends it
// reads the whole conversation again. // byte for byte (harness context.md §10); what may change and is said in a note when it does —
// memory, instruction files, personality, MCP instructions — is read by `changing` below.
const freeze = () => ({ const freeze = () => ({
// The server's memory, when it is the server's: as last read for this session (readServerMemory).
memory: !memoryOn ? undefined : serverMemory !== undefined ? serverMemory.text : [memory.snapshot(), projectMemory?.block("memory") ?? ""].filter(Boolean).join("\n\n"),
skills: skillLines(skillList()), skills: skillLines(skillList()),
git: gitSummary(), git: gitSummary(),
tasks: projectDir() && !unsafeProjectFile(join(project.dir, "tasks.md"), project.root) ? boardSummary(project.dir) : undefined, tasks: projectDir() && !unsafeProjectFile(join(project.dir, "tasks.md"), project.root) ? boardSummary(project.dir) : undefined,
files: instructionFiles(o.cwd, project.root, loaded.instructions),
}) })
/** The memory block as it is now: the server's when it is the server's (as last read for this
* session, readServerMemory), else this machine's files; undefined when memory is off. */
const memoryNow = () => (!memoryOn ? undefined : serverMemory !== undefined ? serverMemory.text : [memory.snapshot(), projectMemory?.block("memory") ?? ""].filter(Boolean).join("\n\n"))
// `library: lembas`: the server keeps the memory (acp.md *Memory*); its entries for the prompt are // `library: lembas`: the server keeps the memory (acp.md *Memory*); its entries for the prompt are
// read from it as a session starts. Until the read answers, none. // read from it as a session starts. Until the read answers, none.
const memoryOnServer = loaded.config.library === "lembas" && memoryOn const memoryOnServer = loaded.config.library === "lembas" && memoryOn
@@ -299,7 +303,7 @@ export function createApp(o: AppOptions): App {
const lembasLibrary = memoryOnServer ? lembasLibraryServer(loaded.warnings) : undefined const lembasLibrary = memoryOnServer ? lembasLibraryServer(loaded.warnings) : undefined
if (lembasLibrary) loaded.mcp[LIBRARY_SERVER] = lembasLibrary if (lembasLibrary) loaded.mcp[LIBRARY_SERVER] = lembasLibrary
// Without a way to the server's memory, this machine's is used for now. // Without a way to the server's memory, this machine's is used for now.
if (memoryOnServer && !lembasLibrary) serverMemory = undefined, frozen = freeze() if (memoryOnServer && !lembasLibrary) serverMemory = undefined
const mcp = new McpManager(loaded.mcp, { const mcp = new McpManager(loaded.mcp, {
root: project.root, root: project.root,
version: VERSION, version: VERSION,
@@ -340,8 +344,29 @@ export function createApp(o: AppOptions): App {
const prototypesBlock = () => (context.prototypes?.offered || (!context.prototypes && loaded.config.prototypes === true) ? { sources: context.prototypes?.sources ?? "" } : undefined) const prototypesBlock = () => (context.prototypes?.offered || (!context.prototypes && loaded.config.prototypes === true) ? { sources: context.prototypes?.sources ?? "" } : undefined)
/** The three personal keys this session uses: the client's for it, else the config's. */ /** The three personal keys this session uses: the client's for it, else the config's. */
const personal = () => (context.personalization ? personalConfig({ enabled: true, available: true, ...context.personalization }) : loaded.config) const personal = () => (context.personalization ? personalConfig({ enabled: true, available: true, ...context.personalization }) : loaded.config)
const system = (m: Mode, current: ResolvedModel, subagent?: { name: string; instructions: string; report: boolean; skills: boolean; recall: boolean }, at?: { cwd: string; root: string }, plan?: Plan) => /** What may change during the session, as it is now (session/prefix.ts `Changing`): what the
assembleSystem({ * prompt is made from, and what a note compares. A subagent's prompt holds no memory and no
* personality; a worktree subagent reads its own checkout's instruction files. */
type Source = { files: { path: string; text: string; chain?: true }[]; memory?: string; personality?: string; userInstructions?: string; instance?: { name: string; instructions: string }; mcp: Record<string, string> }
const changing = (subagent?: boolean, at?: { cwd: string; root: string }): Changing => {
const files = instructionFiles(at?.cwd ?? o.cwd, at?.root ?? project.root, loaded.instructions)
const p = subagent ? {} : personalityOf(personal())
const src: Source = {
files,
memory: subagent ? undefined : memoryNow(),
...p,
...(!subagent && context.instructions ? { instance: { name: contextFrom, instructions: context.instructions } } : {}),
mcp: mcp.serverInstructions(),
}
const named: Record<string, string> = Object.fromEntries(files.map((f) => [f.path, f.text]))
// What a note names the person's own instructions by (loop.json context.note.personal).
if (src.userInstructions) named[LOOP.context.note.personal] = src.userInstructions
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, member?: { name: string; manager: string; role: string; goal: string; lead: boolean }) => {
const src = ch.source as Source
return assembleSystem({
modelRef: current.ref, modelRef: current.ref,
family: familyOf(current), family: familyOf(current),
cwd: at?.cwd ?? o.cwd, cwd: at?.cwd ?? o.cwd,
@@ -355,22 +380,29 @@ export function createApp(o: AppOptions): App {
// A worktree subagent works elsewhere: its own snapshot, taken as it starts. // A worktree subagent works elsewhere: its own snapshot, taken as it starts.
git: at ? gitSummary() : frozen.git, git: at ? gitSummary() : frozen.git,
tasks: frozen.tasks, tasks: frozen.tasks,
files: at ? undefined : frozen.files, files: src.files,
subagent, subagent,
memory: frozen.memory, memory: src.memory,
// The skills block tells of skill_view; a subagent without the tool gets no block. // The skills block tells of skill_view; a subagent without the tool gets no block.
skills: subagent && !subagent.skills ? undefined : frozen.skills, skills: subagent && !subagent.skills ? undefined : frozen.skills,
manageSkills: true, manageSkills: true,
// Read on every prompt: /personality and /settings change it without a restart. // As the prompt is made; /personality and /settings later change it by a note.
...personalityOf(personal()), personality: src.personality,
mcp: mcp.instructions(), userInstructions: src.userInstructions,
mcp: mcpInstructions(src.mcp),
roster: roster(current.ref), roster: roster(current.ref),
plan, plan,
...(context.instructions ? { instance: { name: contextFrom, instructions: context.instructions } } : {}), ...(src.instance ? { instance: src.instance } : {}),
library: libraryBlock(), library: libraryBlock(),
prototypes: prototypesBlock(), prototypes: prototypesBlock(),
context: subagent ? subagent.recall : true, context: subagent ? subagent.recall : true,
...(member ? { member } : {}),
// The lead's prompt says how to lead whenever the team tools are offered (team.md §10).
teamLead: !subagent && teamRt.team.offered,
}) })
}
/** A mode's text for a note, as the prompt would have it. */
const modeNote = (m: Mode) => modeText(m, planDir, o.unattended ? UNATTENDED[m] : undefined)
// The other configured models, for the task tool's `model`, or a switch. // The other configured models, for the task tool's `model`, or a switch.
const roster = (current: string) => const roster = (current: string) =>
loaded.refs loaded.refs
@@ -392,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 inWords = (sec: number) => (sec % 60 === 0 ? `${sec / 60} minute${sec === 60 ? "" : "s"}` : `${sec} seconds`)
const vars: Record<string, Record<string, string>> = { const vars: Record<string, Record<string, string>> = {
task: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") }, task: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") },
team_add: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") },
bash: { bash: {
timeout: inWords(loaded.config.limits?.bash_timeout ?? 120), timeout: inWords(loaded.config.limits?.bash_timeout ?? 120),
timeout_max: inWords(600), timeout_max: inWords(600),
@@ -439,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 // The model asked for (task's `model`), else the agent's own, else the session's — and whether
// its server can take it beside the session's and beside every subagent already running // its server can take it beside the session's and beside every subagent already running
// (src/session/capacity.ts), before anything is made. // (src/session/capacity.ts), before anything is made.
const member = opts.member
const m = opts.model ? resolveModel(loaded, opts.model) : spec.model ? resolveModel(loaded, spec.model) : engine.model 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 (refused) throw new ToolError(`${refused} Do this part yourself, or hand it to a model on another connection.`) 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). // A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation).
let wt: Worktree | undefined let wt: Worktree | undefined
if (opts.worktree || spec.isolation === "worktree") { if (opts.worktree || spec.isolation === "worktree") {
@@ -469,8 +508,14 @@ export function createApp(o: AppOptions): App {
} }
: perm : perm
// `report` is every subagent's: its way of speaking to the main agent before its final reply. // `report` is every subagent's: its way of speaking to the main agent before its final reply.
const childTools = [...tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && (!spec.tools || spec.tools.includes(t.name))), ...BUILTIN_TOOLS.filter((t) => SUBAGENT_ONLY.has(t.name))] // A team member also has team_send and team_read, and one that may lead team_add and team_stop
const description = opts.description ?? prompt.split("\n")[0]!.slice(0, 60) // (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 ownerSession = jobsSessionId()
const childRow = store?.createSession(at?.root ?? project.root, m.ref, engine.sessionId) const childRow = store?.createSession(at?.root ?? project.root, m.ref, engine.sessionId)
if (childRow) store!.setTitle(childRow.id, `${spec.name}: ${description}`) if (childRow) store!.setTitle(childRow.id, `${spec.name}: ${description}`)
@@ -496,7 +541,8 @@ export function createApp(o: AppOptions): App {
store, store,
sessionId: childRow?.id, sessionId: childRow?.id,
// An agent's own mode can make it stricter than its parent, never looser. // An agent's own mode can make it stricter than its parent, never looser.
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), effort: m.ref === engine.model.ref ? engine.effort : startEffort(m),
maxSteps: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default, maxSteps: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default,
compaction, compaction,
@@ -505,16 +551,29 @@ export function createApp(o: AppOptions): App {
...baseToolCtx(), ...baseToolCtx(),
...(at ? { root: at.root, cwd: at.cwd, projectDir: undefined } : {}), ...(at ? { root: at.root, cwd: at.cwd, projectDir: undefined } : {}),
jobs: { registry, owner: () => ({ sessionId: childSessionId, job }) }, jobs: { registry, owner: () => ({ sessionId: childSessionId, job }) },
...(member ? { team: { runtime: teamRt, as: member.name } } : {}),
}, },
...(member
? {
team: {
fix: () => teamRt.team.offered,
offered: () => teamRt.team.offered,
refuse: (tool: Tool, args: unknown) => teamRt.refuse(member.name, tool, args),
},
}
: {}),
steerMark: MARKS.to_subagent, steerMark: MARKS.to_subagent,
system: (mode, current, plan) => changing: () => changing(true, at),
modeText: modeNote,
system: (mode, current, plan, ch) =>
system( system(
mode, mode,
current, current,
ch,
{ {
name: spec.name, name: spec.name,
instructions: wt instructions: wt
? `${spec.instructions}\n\nYou are working in a separate checkout of the repository, at ${at!.root}, on the branch ${wt.branch} (made from the last commit — uncommitted work of the main agent is not here). Work only inside it. When you finish, your changes are committed on that branch for the main agent to merge: do not commit, merge or push yourself.` ? `${spec.instructions}\n\nYou are working in a separate checkout of the repository — its root is your project root, so relative paths reach it — on the branch ${wt.branch} (made from the last commit — uncommitted work of the main agent is not here). Work only inside it. When you finish, your changes are committed on that branch for the main agent to merge: do not commit, merge or push yourself.`
: spec.instructions, : spec.instructions,
report: childTools.some((t) => t.name === "report"), report: childTools.some((t) => t.name === "report"),
skills: childTools.some((t) => t.name === "skill_view"), skills: childTools.some((t) => t.name === "skill_view"),
@@ -522,6 +581,7 @@ export function createApp(o: AppOptions): App {
}, },
at, at,
plan, 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 // Its own background commands wake it at its next step while it runs; once it has ended they
@@ -537,7 +597,14 @@ export function createApp(o: AppOptions): App {
const end = registry.finishSubagent(job, { reason, report: text, ...(error ? { error } : {}), ...(worktree ? { worktree } : {}) }) const end = registry.finishSubagent(job, { reason, report: text, ...(error ? { error } : {}), ...(worktree ? { worktree } : {}) })
return { text: worktree ? `${text}\n\n[worktree] ${worktree}` : text, steps: job.steps ?? 0, tools: job.tools ?? 0, reason, ...(error ? { error } : {}), ...(end.stopped ? { stopped: end.stopped } : {}) } return { text: worktree ? `${text}\n\n[worktree] ${worktree}` : text, steps: job.steps ?? 0, tools: job.tools ?? 0, reason, ...(error ? { error } : {}), ...(end.stopped ? { stopped: end.stopped } : {}) }
} }
const run = (text: string) => child.prompt(text).then(finish, (e) => (job.lastError = (e as Error).message, finish("error"))) // A task 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 = { const handle: SubagentHandle = {
bus: childBus, bus: childBus,
engine: child, engine: child,
@@ -545,7 +612,23 @@ export function createApp(o: AppOptions): App {
restart: (text) => run(text).then(() => undefined), restart: (text) => run(text).then(() => undefined),
cancel: () => child.cancel(), cancel: () => child.cancel(),
} }
job = registry.startSubagent({ sessionId: ownerSession, ownerCallId: opts.callId, ownerJobId: baseOwner().job?.id, agent: spec.name, model: m.ref, description, handle, childSessionId, silent: !opts.background }) job = registry.startSubagent({ sessionId: ownerSession, ownerCallId: opts.callId, ownerJobId: baseOwner().job?.id, agent: spec.name, model: m.ref, description, handle, childSessionId, silent: !opts.background || member !== undefined })
if (member) {
// A member's turn that ends is its report (team.md §5): the team, not a job note, carries it.
job.team = member.name
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. // A synchronous subagent goes with its parent's turn (Esc stops both); a background one does not.
const onAbort = () => child.cancel() const onAbort = () => child.cancel()
opts.signal?.addEventListener("abort", onAbort, { once: true }) opts.signal?.addEventListener("abort", onAbort, { once: true })
@@ -571,12 +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 }, budget: { wall_seconds: loaded.config.limits?.wall_seconds, output_bytes: loaded.config.limits?.output_bytes, completion_tokens: loaded.config.limits?.completion_tokens },
compaction, compaction,
perm, perm,
toolCtx: { ...baseToolCtx(), jobs: { registry, owner: baseOwner } }, toolCtx: { ...baseToolCtx(), jobs: { registry, owner: baseOwner }, team: { get runtime() { return teamRt }, as: TEAM.names.lead } },
system: (m, current, plan) => system(m, current, undefined, undefined, plan), system: (m, current, plan, ch) => system(m, current, ch, undefined, undefined, plan),
team: {
fix: (ref, offered) => teamRt.team.fix(ref, offered),
offered: () => teamRt.team.offered,
refuse: (tool, args) => teamRt.refuse(TEAM.names.lead, tool, args),
note: () => teamRt.team.noteLines(),
forget: () => teamRt.team.forgetTold(),
beforeTurn: (signal) => teamRt.beforeLeadTurn(signal),
},
changing: () => changing(),
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, 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). // MCP tools join the engine's list as servers connect (the engine reads it at each prompt).
let mcpNames = new Set<string>() let mcpNames = new Set<string>()
const syncMcp = () => { const syncMcp = () => {
@@ -597,14 +740,13 @@ export function createApp(o: AppOptions): App {
}) })
const mcpReady = mcp.start() const mcpReady = mcp.start()
/** The server's memory entries for the prompt (`lembas://memory`), read as a session starts, in /** The server's memory entries for the prompt (`lembas://memory`), read as a session starts, in
* the session's scope: put into the prompt while nothing has been sent yet, kept for the next * the session's scope: in the prompt when it is made after this, else said in a note on the next
* session otherwise — the prompt does not change under a session. */ * user message — the prompt does not change under a session. */
const readServerMemory = async () => { const readServerMemory = async () => {
if (!lembasLibrary || !serverMemory) return if (!lembasLibrary || !serverMemory) return
try { try {
const text = await mcp.readResource(LIBRARY_SERVER, MEMORY_RESOURCE, engine.o.toolCtx.libraryScope) const text = await mcp.readResource(LIBRARY_SERVER, MEMORY_RESOURCE, engine.o.toolCtx.libraryScope)
serverMemory = { text } serverMemory = { text }
if (!engine.messages.length && memoryOn) frozen = { ...frozen, memory: text }
} catch (e) { } catch (e) {
bus.emit({ type: "notice", level: "warn", message: `memory: the server's entries could not be read (${(e as Error).message}); none are in this session's prompt` }) bus.emit({ type: "notice", level: "warn", message: `memory: the server's entries could not be read (${(e as Error).message}); none are in this session's prompt` })
} }
@@ -687,19 +829,28 @@ export function createApp(o: AppOptions): App {
}, },
live(key) { live(key) {
if (key === "model") return engine.model.ref if (key === "model") return engine.model.ref
if (key === "effort") return engine.effort ?? "off" if (key === "effort") return engine.effort
if (key === "mode") return engine.mode if (key === "mode") return engine.mode
// The team's size as the session holds it (the cap applied).
if (key === "agents.min") return teamRt.team.min
if (key === "agents.max") return teamRt.team.max
return key.split(".").reduce<unknown>((x, k) => (x && typeof x === "object" ? (x as Record<string, unknown>)[k] : undefined), loaded.config) return key.split(".").reduce<unknown>((x, k) => (x && typeof x === "object" ? (x as Record<string, unknown>)[k] : undefined), loaded.config)
}, },
check(key, v) { check(key, v) {
if (key === "model" || key === "small_model") resolveModel(loaded, String(v)) if (key === "model" || key === "small_model") resolveModel(loaded, String(v))
if (key === "effort" && v !== "off" && !effortsFor(engine.model).includes(v as Effort)) if (key === "effort" && !(levelsFor(engine.model).levels as string[]).includes(String(v)))
throw new Error(`${engine.model.ref} takes ${effortsFor(engine.model).join(", ")} or off`) throw new Error(`${engine.model.ref} offers the levels ${levelsFor(engine.model).levels.join(", ")}`)
if (key === "knowledge") for (const b of v as string[]) if (!library.base(b)) throw new Error(`there is no knowledge base "${b}" — lembas kb lists them`) if (key === "knowledge") for (const b of v as string[]) if (!library.base(b)) throw new Error(`there is no knowledge base "${b}" — lembas kb lists them`)
// The team's size (team.md §2): refused outside 1 ≤ min ≤ max ≤ cap, with the bounds.
if (key === "agents.min" || key === "agents.max") {
const t = teamRt.team
const no = Team.check(key === "agents.min" ? Number(v) : t.min, key === "agents.max" ? Number(v) : t.max, t.cap)
if (no) throw new Error(no)
}
}, },
apply(key, v) { apply(key, v) {
if (key === "model") switchModel(String(v)) if (key === "model") switchModel(String(v))
else if (key === "effort") engine.effort = v === "off" ? null : (v as Effort) else if (key === "effort") engine.effort = v as Level
else if (key === "mode") engine.mode = v as Mode else if (key === "mode") engine.mode = v as Mode
else if (key === "limits.steps") engine.o.maxSteps = Number(v) else if (key === "limits.steps") engine.o.maxSteps = Number(v)
else if (key === "busy_input") engine.busyInput = v as "steer" | "queue" else if (key === "busy_input") engine.busyInput = v as "steer" | "queue"
@@ -707,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 === "limits.wall_seconds" || key === "limits.output_bytes" || key === "limits.completion_tokens") engine.o.budget = { ...engine.o.budget, [key.slice("limits.".length)]: Number(v) }
else if (key === "compaction.auto_at") compaction.autoAt = Number(v) else if (key === "compaction.auto_at") compaction.autoAt = Number(v)
else if (key === "knowledge") libraryCtx.bases = (v as string[]).length ? (v as string[]) : undefined else if (key === "knowledge") libraryCtx.bases = (v as string[]).length ? (v as string[]) : undefined
else if (key === "agents.min" || key === "agents.max") {
// Said to the lead as a note on its next message (team.md §2), never in the prompt.
const t = teamRt.team
t.setSetting(key === "agents.min" ? Number(v) : t.min, key === "agents.max" ? Number(v) : t.max)
teamSize.min = t.min
teamSize.max = t.max
bus.emit({ type: "team" })
}
if (key !== "model" && key !== "effort" && key !== "mode") setIn(key, v) if (key !== "model" && key !== "effort" && key !== "mode") setIn(key, v)
bus.emit({ type: "setting", key, value: v }) bus.emit({ type: "setting", key, value: v })
}, },
@@ -758,6 +917,7 @@ export function createApp(o: AppOptions): App {
mcpReady, mcpReady,
small, small,
close: () => { close: () => {
teamRt.stopAll()
detach() detach()
for (const d of childDetach.splice(0)) d() for (const d of childDetach.splice(0)) d()
return mcp.close() return mcp.close()
@@ -772,6 +932,7 @@ export function createApp(o: AppOptions): App {
jobsSessionId, jobsSessionId,
jobs: () => registry.list(jobsSessionId()), jobs: () => registry.list(jobsSessionId()),
wake, wake,
team: teamRt,
get context() { get context() {
return context return context
}, },
@@ -797,6 +958,7 @@ export function createApp(o: AppOptions): App {
const s = store?.createSession(project.root, engine.model.ref) const s = store?.createSession(project.root, engine.model.ref)
keepCwd(s?.id) keepCwd(s?.id)
standIn = `local_${crypto.randomUUID().slice(0, 8)}` standIn = `local_${crypto.randomUUID().slice(0, 8)}`
teamRt.reset(newTeam())
engine.newSession(s?.id) engine.newSession(s?.id)
turns.reset() turns.reset()
if (s) bus.emit({ type: "session", id: s.id, model: engine.model.ref, mode: engine.mode }) if (s) bus.emit({ type: "session", id: s.id, model: engine.model.ref, mode: engine.mode })
@@ -810,6 +972,7 @@ export function createApp(o: AppOptions): App {
const before = engine.sessionId const before = engine.sessionId
frozen = freeze() frozen = freeze()
void readServerMemory() void readServerMemory()
teamRt.reset(newTeam())
engine.resume(id, store.context(id)) engine.resume(id, store.context(id))
// A session from before titles gets its first prompt's line now, and the model's after the next reply. // A session from before titles gets its first prompt's line now, and the model's after the next reply.
firstShown = "" firstShown = ""
+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 /** The note about that job was given to the model: inside a turn at its next step, or as the
* `machine` turn whose `prompt` follows. */ * `machine` turn whose `prompt` follows. */
| { type: "job_note"; jobId: string; text: string } | { type: "job_note"; jobId: string; text: string }
/** The session's team changed (spec 3.2.0): a member added, started, finished or stopped, a
* message in the room. Read the team itself for what. */
| { type: "team" }
/** The permission mode changed from inside a turn (plan approved). */ /** The permission mode changed from inside a turn (plan approved). */
| { type: "mode"; mode: Mode } | { type: "mode"; mode: Mode }
/** The session's model or effort changed, for whatever reason — /model, /effort, the settings /** The session's model or effort changed, for whatever reason — /model, /effort, the settings
+8 -8
View File
@@ -5,13 +5,13 @@ import { parseArgs } from "node:util"
import { createApp } from "./app.ts" import { createApp } from "./app.ts"
import { attachmentsFor } from "./project/attach.ts" import { attachmentsFor } from "./project/attach.ts"
import { ConfigError, loadConfig } from "./config/load.ts" import { ConfigError, loadConfig } from "./config/load.ts"
import { asMode, EFFORTS, MODES, type Effort, type Mode } from "./config/schema.ts" import { asMode, EFFORTS, MODES, type Mode } from "./config/schema.ts"
import { isLocal, localRoot } from "./config/paths.ts" import { isLocal, localRoot } from "./config/paths.ts"
import { SettingError } from "./config/settings.ts" import { SettingError } from "./config/settings.ts"
import { renderJson, renderPlain, settleJobs, terminalAsker } from "./headless.ts" import { renderJson, renderPlain, settleJobs, terminalAsker } from "./headless.ts"
import { enterLocal, localFor, LocalError, localRefusal, localTmp, LOCAL_FLAGS } from "./project/local.ts" import { enterLocal, localFor, LocalError, localRefusal, localTmp, LOCAL_FLAGS } from "./project/local.ts"
import { findProject, setTrust, trustOf } from "./project/root.ts" import { findProject, setTrust, trustOf } from "./project/root.ts"
import { effortsFor } from "./provider/effort.ts" import { isLevel, levelsFor } from "./provider/effort.ts"
import { clientFor, ModelError, NOT_LOGGED_IN, resolveModel } from "./provider/index.ts" import { clientFor, ModelError, NOT_LOGGED_IN, resolveModel } from "./provider/index.ts"
import { Store } from "./session/store.ts" import { Store } from "./session/store.ts"
import { promptTitle } from "./session/title.ts" import { promptTitle } from "./session/title.ts"
@@ -84,7 +84,7 @@ Local mode:
Options for run: Options for run:
-m, --model conn/model model to use (default: config.yaml \`model\`) -m, --model conn/model model to use (default: config.yaml \`model\`)
--mode MODE manual | edit | auto | plan --mode MODE manual | edit | auto | plan
-e, --effort LEVEL ${EFFORTS.join(" | ")} | off -e, --effort LEVEL ${EFFORTS.join(" | ")} | off | default (one the model offers)
-y, --yes approve every ask (still never the hardline) -y, --yes approve every ask (still never the hardline)
--json events as JSON lines --json events as JSON lines
--reasoning show the model's reasoning --reasoning show the model's reasoning
@@ -240,7 +240,7 @@ async function main() {
cwd: process.cwd(), cwd: process.cwd(),
model: values.model, model: values.model,
mode: asMode(values.mode), mode: asMode(values.mode),
effort: values.effort as Effort | "off" | undefined, effort: values.effort,
resume: values.session ?? (values.continue ? "last" : undefined), resume: values.session ?? (values.continue ? "last" : undefined),
}) })
if (done.reload) await (await import("./reload.ts")).restart(done.reload) if (done.reload) await (await import("./reload.ts")).restart(done.reload)
@@ -299,14 +299,13 @@ async function main() {
if (!prompt && !process.stdin.isTTY) prompt = (await new Response(Bun.stdin.stream()).text()).trim() if (!prompt && !process.stdin.isTTY) prompt = (await new Response(Bun.stdin.stream()).text()).trim()
if (!prompt) fail('nothing to do: lembas run "your prompt"', 2) if (!prompt) fail('nothing to do: lembas run "your prompt"', 2)
if (values.mode && !asMode(values.mode)) fail(`--mode must be one of ${MODES.join(", ")}`, 2) if (values.mode && !asMode(values.mode)) fail(`--mode must be one of ${MODES.join(", ")}`, 2)
if (values.effort && values.effort !== "off" && !(EFFORTS as readonly string[]).includes(values.effort)) if (values.effort && !isLevel(values.effort)) fail(`--effort must be one of ${EFFORTS.join(", ")}, off, default`, 2)
fail(`--effort must be one of ${EFFORTS.join(", ")}, off`, 2)
const app = createApp({ const app = createApp({
cwd: process.cwd(), cwd: process.cwd(),
model: values.model, model: values.model,
mode: asMode(values.mode), mode: asMode(values.mode),
effort: values.effort as Effort | "off" | undefined, effort: values.effort,
asker: terminalAsker({ yes: values.yes ?? false, unattended: loadConfig().config.question?.unattended, theme: loadConfig().config.theme }), asker: terminalAsker({ yes: values.yes ?? false, unattended: loadConfig().config.question?.unattended, theme: loadConfig().config.theme }),
unattended: !values.yes && !process.stdin.isTTY, unattended: !values.yes && !process.stdin.isTTY,
store: values["no-store"] ? false : undefined, store: values["no-store"] ? false : undefined,
@@ -342,7 +341,8 @@ async function main() {
spec.max_output ? `out ${spec.max_output}` : "", spec.max_output ? `out ${spec.max_output}` : "",
spec.vision ? "vision" : "", spec.vision ? "vision" : "",
spec.tools === false ? "no-tools" : "", spec.tools === false ? "no-tools" : "",
spec.effort || spec.efforts ? `effort ${spec.effort ?? "off"} of ${effortsFor(m).join("/")}` : "", ((l) => (l.control === "none" ? "" : `effort ${l.start} of ${l.levels.join("/")}`))(levelsFor(m)),
spec.parallel ? `parallel ${spec.parallel}` : spec.single_session ? "parallel 1" : "",
].filter(Boolean) ].filter(Boolean)
console.log(` ${m.ref}${bits.length ? " — " + bits.join(", ") : ""}`) console.log(` ${m.ref}${bits.length ? " — " + bits.join(", ") : ""}`)
} }
+6
View File
@@ -154,6 +154,12 @@ function readConfig(file: string, label: string, notes: string[], dropped?: stri
delete all[k] delete all[k]
dropped.push(k) dropped.push(k)
} }
// The team's cap is the global configuration's: a project never raises it (harness team.md §2).
if (dropped && all.agents && typeof all.agents === "object" && !Array.isArray(all.agents) && "cap" in all.agents) {
const { cap: _cap, ...agents } = all.agents as Record<string, unknown>
all.agents = agents
dropped.push("agents.cap")
}
// mcp, voice and search are filled in later, one server / half / service at a time: a key that // mcp, voice and search are filled in later, one server / half / service at a time: a key that
// cannot be read turns off that one thing instead of the whole file. // cannot be read turns off that one thing instead of the whole file.
// update: filled in when an update is looked for — a token file missing must not stop a start. // update: filled in when an update is looked for — a token file missing must not stop a start.
+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."), max_output: z.number().int().positive().optional().describe("Max tokens per reply."),
temperature: z.number().min(0).max(2).optional().describe("Sampling temperature; absent = the server's default."), temperature: z.number().min(0).max(2).optional().describe("Sampling temperature; absent = the server's default."),
top_p: z.number().min(0).max(1).optional().describe("Nucleus sampling; absent = the server's default."), top_p: z.number().min(0).max(1).optional().describe("Nucleus sampling; absent = the server's default."),
efforts: z.array(z.enum(EFFORTS)).optional().describe("The model's own effort vocabulary, in offering order."), efforts: z.array(z.enum(EFFORTS)).optional().describe("The model's own effort vocabulary, in offering order. [] — it takes none (its one level is `default`)."),
effort: z.union([z.enum(EFFORTS), z.literal("off")]).optional().describe("Default effort; `off` sends none."), thinking_switch: z.boolean().optional().describe("The server turns thinking off for a request with chat_template_kwargs.enable_thinking false: only then is the level `off` offered."),
effort: z.union([z.enum(EFFORTS), z.literal("off"), z.literal("default")]).optional().describe("The level a conversation starts with, when the model offers it: an effort, `off` (thinking switched off, with thinking_switch) or `default` (nothing sent, for a model that takes no efforts)."),
effort_style: z.enum(["top", "kwargs", "both"]).optional().describe("Where the effort goes in a request: top-level, in chat_template_kwargs, or both (the default: llama.cpp drops top-level)."), effort_style: z.enum(["top", "kwargs", "both"]).optional().describe("Where the effort goes in a request: top-level, in chat_template_kwargs, or both (the default: llama.cpp drops top-level)."),
vision: z.boolean().optional().describe("The model reads images: @image files, pasted paths and view_image."), vision: z.boolean().optional().describe("The model reads images: @image files, pasted paths and view_image."),
tools: z.boolean().optional().describe("false: a model without tool calls — it can only talk."), tools: z.boolean().optional().describe("false: a model without tool calls — it can only talk."),
headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers for this model's requests."), headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers for this model's requests."),
body: z.record(z.string(), z.unknown()).optional().describe("Merged into every request body for this model."), body: z.record(z.string(), z.unknown()).optional().describe("Merged into every request body for this model."),
notes: z.string().max(300).optional().describe("What this model is good at, in a line: the model in use is told which others there are, for handing work over (task) or switching."), notes: z.string().max(300).optional().describe("What this model is good at, in a line: the model in use is told which others there are, for handing work over (task) or switching."),
single_session: z.boolean().optional().describe("The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused."), single_session: z.boolean().optional().describe("The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused. 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."), group: z.string().optional().describe("Models sharing one GPU server that holds one model at a time (llama-swap), as the LLeMbas server says (capacity.group): a subagent on another model of the same group would unload this one, so it is refused."),
}) })
export type ModelSpec = z.infer<typeof ModelSpec> export type ModelSpec = z.infer<typeof ModelSpec>
@@ -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."), mcp: z.record(z.string(), McpServer).optional().describe("MCP servers by name. `{env:}`/`{file:}` are filled in per server (a missing one disables that server only). A project's servers count only once the project is trusted."),
knowledge: z.array(z.string()).optional().describe("The knowledge bases searched (lembas kb); absent: all of them. A project's own config narrows it to what that project needs."), knowledge: z.array(z.string()).optional().describe("The knowledge bases searched (lembas kb); absent: all of them. A project's own config narrows it to what that project needs."),
embedding: z.string().optional().describe("Global config only. An embedding model the LLeMbas server serves, by its id there: knowledge bases are then searched by meaning as well as by words. lembas kb reindex after changing it."), embedding: z.string().optional().describe("Global config only. An embedding model the LLeMbas server serves, by its id there: knowledge bases are then searched by meaning as well as by words. lembas kb reindex after changing it."),
effort: z.union([z.enum(EFFORTS), z.literal("off")]).optional().describe("The effort a session starts with, when the model takes it; otherwise the model's own default."), effort: z.union([z.enum(EFFORTS), z.literal("off"), z.literal("default")]).optional().describe("The effort level a session starts with, when the model offers it: an effort, off (thinking switched off, on a model with the switch) or default; otherwise the model's own start level."),
agents: z
.strictObject({
min: z.number().int().min(1).optional().describe("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."), settings_tool: z.enum(["ask", "allow", "off"]).optional().describe("Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool."),
update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."), update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."),
prototypes: z.boolean().optional().describe("Global config only. Offer the model prototypes: a complete HTML page in a `prototype-chat` or `prototype-page` fence, saved as a file under the state directory (prototypes/<session>/) and listed by /prototype; the LLeMbas web UI renders the same fences live. Default false."), prototypes: z.boolean().optional().describe("Global config only. Offer the model prototypes: a complete HTML page in a `prototype-chat` or `prototype-page` fence, saved as a file under the state directory (prototypes/<session>/) and listed by /prototype; the LLeMbas web UI renders the same fences live. Default false."),
+6 -3
View File
@@ -19,7 +19,7 @@ export type Source = "default" | "global" | "project" | "session"
export interface SettingDef { export interface SettingDef {
key: string key: string
/** model: a model the server serves, by its id; effort: one the model takes, or off; list: comma-separated. */ /** model: a model the server serves, by its id; effort: one of the model's levels; list: comma-separated. */
kind: "enum" | "model" | "effort" | "number" | "boolean" | "list" | "string" kind: "enum" | "model" | "effort" | "number" | "boolean" | "list" | "string"
values?: readonly string[] values?: readonly string[]
/** The agent may change it (through the settings tool, with approval). */ /** The agent may change it (through the settings tool, with approval). */
@@ -36,7 +36,10 @@ const USER: Scope[] = ["session", "global"]
export const SETTINGS: SettingDef[] = [ export const SETTINGS: SettingDef[] = [
{ key: "model", kind: "model", agent: true, scopes: ALL }, { key: "model", kind: "model", agent: true, scopes: ALL },
{ key: "effort", kind: "effort", agent: true, scopes: ALL, fallback: "the model's default" }, { key: "effort", kind: "effort", agent: true, scopes: ALL, fallback: "the model's start level" },
// The team's size is the person's: the agent never sets how many agents it may have.
{ key: "agents.min", kind: "number", agent: false, scopes: ALL, fallback: "1" },
{ key: "agents.max", kind: "number", agent: false, scopes: ALL, fallback: "4" },
{ key: "mode", kind: "enum", values: MODES, agent: true, scopes: ALL, fallback: "manual" }, { key: "mode", kind: "enum", values: MODES, agent: true, scopes: ALL, fallback: "manual" },
{ key: "small_model", kind: "model", agent: true, scopes: ALL, fallback: "the session's model" }, { key: "small_model", kind: "model", agent: true, scopes: ALL, fallback: "the session's model" },
{ key: "knowledge", kind: "list", agent: true, scopes: ALL, fallback: "every base" }, { key: "knowledge", kind: "list", agent: true, scopes: ALL, fallback: "every base" },
@@ -104,7 +107,7 @@ export function parseValue(def: SettingDef, raw: unknown): unknown {
if (def.key === "mode") v = asMode(v) ?? v if (def.key === "mode") v = asMode(v) ?? v
// "none" is how a dialog or a person says it; the file keeps the web UI's empty string. // "none" is how a dialog or a person says it; the file keeps the web UI's empty string.
if (def.key === "personality" && typeof v === "string" && ["none", "off", "default"].includes(v.toLowerCase())) v = "" if (def.key === "personality" && typeof v === "string" && ["none", "off", "default"].includes(v.toLowerCase())) v = ""
if (def.kind === "effort" && v !== "off" && !(EFFORTS as readonly string[]).includes(String(v))) throw new SettingError(`effort: one of ${EFFORTS.join(", ")} or off`) if (def.kind === "effort" && !["off", "default", ...EFFORTS].includes(String(v))) throw new SettingError(`effort: one of ${EFFORTS.join(", ")}, off or default`)
const schema = schemaAt(def.key.split(".")) const schema = schemaAt(def.key.split("."))
const r = schema?.safeParse(v) const r = schema?.safeParse(v)
if (!r || !r.success) throw new SettingError(`${def.key}: ${def.values ? `one of ${def.values.join(", ")}` : (r?.error.issues.map((i) => i.message).join("; ") ?? "not settable")}`) if (!r || !r.success) throw new SettingError(`${def.key}: ${def.values ? `one of ${def.values.join(", ")}` : (r?.error.issues.map((i) => i.message).join("; ") ?? "not settable")}`)
+40 -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_tag from "../harness/tools/tag.json"
import t_task from "../harness/tools/task.json" import t_task from "../harness/tools/task.json"
import t_tasks from "../harness/tools/tasks.json" import t_tasks from "../harness/tools/tasks.json"
import t_team_add from "../harness/tools/team_add.json"
import t_team_read from "../harness/tools/team_read.json"
import t_team_send from "../harness/tools/team_send.json"
import t_team_stop from "../harness/tools/team_stop.json"
import t_view_image from "../harness/tools/view_image.json" import t_view_image from "../harness/tools/view_image.json"
import t_web_fetch from "../harness/tools/web_fetch.json" import t_web_fetch from "../harness/tools/web_fetch.json"
import t_web_search from "../harness/tools/web_search.json" import t_web_search from "../harness/tools/web_search.json"
@@ -76,8 +80,11 @@ export interface ToolSpecDef {
offered?: "subagent" offered?: "subagent"
argument_aliases?: Record<string, string> argument_aliases?: Record<string, string>
/** Spec 3.0.0: what must hold for it to be offered — `display` (the session is shown where a file /** Spec 3.0.0: what must hold for it to be offered — `display` (the session is shown where a file
* can be opened: over the link, to a client that offers `show`), `page`, `forge` (the chat's). */ * can be opened: over the link, to a client that offers `show`), `page`, `forge` (the chat's);
requires?: ("display" | "page" | "forge")[] * `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).
* `team` (3.2.0): the team setting allows more than one agent (harness/team.md §2). */
requires?: ("display" | "page" | "forge" | "plan" | "team")[]
/** What a call answers, word for word, when a requirement went away after the offer. */ /** What a call answers, word for word, when a requirement went away after the offer. */
unavailable?: string unavailable?: string
/** `always`: put to the person in every mode, auto included; never remembered. */ /** `always`: put to the person in every mode, auto included; never remembered. */
@@ -111,6 +118,17 @@ export const LOOP: {
running_max: { default: number } running_max: { default: number }
subagent_steps: { 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 /** Context hygiene (spec 3.0.0, harness/context.md): the result ids, the texts of a read's
* header, a stub, the elided line, recall; the output cap; what is deduplicated; the table * header, a stub, the elided line, recall; the output cap; what is deduplicated; the table
* compaction adds. */ * compaction adds. */
@@ -131,6 +149,25 @@ export const LOOP: {
dedupe: { by_hash: string[]; by_result: string[] } dedupe: { by_hash: string[]; by_result: string[] }
compaction: { heading: string; line: string; rows: number } compaction: { heading: string; line: string; rows: number }
reasoning: string reasoning: string
/** The environment block's lines about where an agent session works (spec 3.2.0, §11). */
env: { project: string; started: string }
/** A note on the next user message (spec 3.1.0, context.md §10): its tags and its lines, in
* 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
/** What a note names the person's own instructions by (the instructions block's `{{path}}`). */
personal: string
/** For a chat, whose modes have no text of their own; the CLI says modes/<mode>.md. */
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
} }
} = LOOP_JSON } = LOOP_JSON
@@ -208,7 +245,7 @@ export function narrowParameters(parameters: Record<string, unknown>, narrow: Na
} }
export const TOOL_SPECS: Record<string, ToolSpecDef> = Object.fromEntries( export const TOOL_SPECS: Record<string, ToolSpecDef> = Object.fromEntries(
([t_apply_patch, t_ask_user, t_bash, t_branch, t_commit, t_decisions, t_edit, t_glob, t_grep, t_job_list, t_job_output, t_job_send, t_job_stop, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_merge, t_mkdir, t_multiedit, t_mv, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_pr, t_python, t_read, t_recall, t_release, t_report, t_rm, t_search, t_session_search, t_settings, t_show, t_skill_manage, t_skill_view, t_skills_list, t_tag, t_task, t_tasks, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]), ([t_apply_patch, t_ask_user, t_bash, t_branch, t_commit, t_decisions, t_edit, t_glob, t_grep, t_job_list, t_job_output, t_job_send, t_job_stop, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_merge, t_mkdir, t_multiedit, t_mv, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_pr, t_python, t_read, t_recall, t_release, t_report, t_rm, t_search, t_session_search, t_settings, t_show, t_skill_manage, t_skill_view, t_skills_list, t_tag, t_task, t_tasks, t_team_add, t_team_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 /** 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 /** Set when the note's wake should not happen: a synchronous subagent whose result goes back
* as the task call's output. */ * as the task call's output. */
silent?: boolean silent?: boolean
/** A team member's name (spec 3.2.0): not a job to the model — the job tools do not take it —
* but a subagent job to a client over the link (team.md §4, §11). */
team?: string
} }
/** A front end holding a session: where its jobs' events go, and how a note wakes it. The /** A front end holding a session: where its jobs' events go, and how a note wakes it. The
+3 -1
View File
@@ -58,10 +58,12 @@ export interface ServedModel {
top_p?: number top_p?: number
efforts?: string[] efforts?: string[]
effort?: string effort?: string
/** Spec 3.2.0: the server turns thinking off for enable_thinking false. */
thinking_switch?: boolean
vision?: boolean vision?: boolean
tools?: boolean tools?: boolean
notes?: string notes?: string
capacity?: { group?: string; single_session?: boolean } capacity?: { group?: string; single_session?: boolean; parallel?: number }
default?: boolean default?: boolean
/** The provider's slug — the part of `id` before its `/` — and what kind of model it is. */ /** The provider's slug — the part of `id` before its `/` — and what kind of model it is. */
provider?: string provider?: string
+5 -2
View File
@@ -18,7 +18,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import { parse } from "yaml" import { parse } from "yaml"
import { connectionFiles, isLocal, paths } from "../config/paths.ts" import { connectionFiles, isLocal, paths } from "../config/paths.ts"
import type { Connection, ModelSpec, WebuiConnection } from "../config/schema.ts" import { EFFORTS, type Connection, type ModelSpec, type WebuiConnection } from "../config/schema.ts"
import { substituteDeep } from "../config/substitute.ts" import { substituteDeep } from "../config/substitute.ts"
import { instanceInfo, LembasError, models as listModels, normalise, personalization, type InstanceInfo, type Personalization, type ServedModel, type Tls } from "./client.ts" import { instanceInfo, LembasError, models as listModels, normalise, personalization, type InstanceInfo, type Personalization, type ServedModel, type Tls } from "./client.ts"
@@ -94,12 +94,15 @@ export function specFor(m: ServedModel, connection: string): ModelSpec {
if (m.max_output) out.max_output = m.max_output if (m.max_output) out.max_output = m.max_output
if (typeof m.temperature === "number" && m.temperature >= 0 && m.temperature <= 2) out.temperature = m.temperature if (typeof m.temperature === "number" && m.temperature >= 0 && m.temperature <= 2) out.temperature = m.temperature
if (typeof m.top_p === "number" && m.top_p >= 0 && m.top_p <= 1) out.top_p = m.top_p if (typeof m.top_p === "number" && m.top_p >= 0 && m.top_p <= 1) out.top_p = m.top_p
if (m.efforts?.length) out.efforts = m.efforts // [] is said: the model takes no efforts (its one level is `default`), unlike an absent list.
if (Array.isArray(m.efforts)) out.efforts = m.efforts.filter((e) => (EFFORTS as readonly string[]).includes(e))
if (m.effort) out.effort = m.effort if (m.effort) out.effort = m.effort
if (m.thinking_switch === true) out.thinking_switch = true
if (m.vision !== undefined) out.vision = m.vision if (m.vision !== undefined) out.vision = m.vision
if (m.tools !== undefined) out.tools = m.tools if (m.tools !== undefined) out.tools = m.tools
if (m.notes) out.notes = m.notes.slice(0, 300) if (m.notes) out.notes = m.notes.slice(0, 300)
if (m.capacity?.single_session) out.single_session = true if (m.capacity?.single_session) out.single_session = true
if (Number.isInteger(m.capacity?.parallel) && m.capacity!.parallel! >= 1) out.parallel = m.capacity!.parallel
if (m.capacity?.group) out.group = `${connection}:${m.capacity.group}` if (m.capacity?.group) out.group = `${connection}:${m.capacity.group}`
return out as ModelSpec return out as ModelSpec
} }
+14 -2
View File
@@ -25,6 +25,7 @@ import type { ImagePart } from "../provider/types.ts"
import { ToolError, truncate, type Tool, type ToolResult } from "../tool/tool.ts" import { ToolError, truncate, type Tool, type ToolResult } from "../tool/tool.ts"
import { match } from "../permission/wildcard.ts" import { match } from "../permission/wildcard.ts"
import { codeFrom, forget, OAuthProvider, waitForCallback, CALLBACK_PORT } from "./oauth.ts" import { codeFrom, forget, OAuthProvider, waitForCallback, CALLBACK_PORT } from "./oauth.ts"
import { mcpBlock } from "../session/prefix.ts"
export type ServerConfig = McpServer & { source: "global" | "project" } export type ServerConfig = McpServer & { source: "global" | "project" }
export type Status = "disabled" | "connecting" | "connected" | "failed" | "needs_auth" export type Status = "disabled" | "connecting" | "connected" | "failed" | "needs_auth"
@@ -480,8 +481,13 @@ export class McpManager {
/** The system prompt's block of server instructions, or "". */ /** The system prompt's block of server instructions, or "". */
instructions(): string { instructions(): string {
const parts = [...this.servers.values()].filter((s) => s.status === "connected" && s.instructions).map((s) => `<server name="${s.name}">\n${s.instructions}\n</server>`) return mcpInstructions(this.serverInstructions())
return parts.length ? `Instructions from the MCP servers you have tools from (their tools are named mcp__<server>__…):\n<mcp_instructions>\n${parts.join("\n")}\n</mcp_instructions>` : "" }
/** Each connected server's own instructions, by server: what the prompt shows (mcpInstructions),
* and what a session note compares (session/prefix.ts). */
serverInstructions(): Record<string, string> {
return Object.fromEntries([...this.servers.values()].filter((s) => s.status === "connected" && s.instructions).map((s) => [s.name, s.instructions!]))
} }
/** Sign in to a remote server: show the page, catch the redirect (or take a pasted one). */ /** Sign in to a remote server: show the page, catch the redirect (or take a pasted one). */
@@ -554,3 +560,9 @@ function withTimeout<T>(p: Promise<T>, ms: number, message: string): Promise<T>
let t: ReturnType<typeof setTimeout> let t: ReturnType<typeof setTimeout>
return Promise.race([p, new Promise<T>((_, reject) => (t = setTimeout(() => reject(new Error(message)), ms)))]).finally(() => clearTimeout(t)) return Promise.race([p, new Promise<T>((_, reject) => (t = setTimeout(() => reject(new Error(message)), ms)))]).finally(() => clearTimeout(t))
} }
/** MCP servers' instructions as the system prompt carries them, "" for none. */
export function mcpInstructions(by: Record<string, string>): string {
const parts = Object.entries(by).map(([name, text]) => mcpBlock(name, text))
return parts.length ? `Instructions from the MCP servers you have tools from (their tools are named mcp__<server>__…):\n<mcp_instructions>\n${parts.join("\n")}\n</mcp_instructions>` : ""
}
+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. // them, and counted as read so the model may edit them at once. The prompt text is unchanged.
import { readdirSync, readFileSync, statSync } from "node:fs" import { readdirSync, readFileSync, statSync } from "node:fs"
import { join, resolve } from "node:path" import { join, resolve } from "node:path"
import { promptPath } from "../prompt/assemble.ts"
import type { ImagePart } from "../provider/types.ts" import type { ImagePart } from "../provider/types.ts"
import type { ToolContext } from "../tool/tool.ts" import type { ToolContext } from "../tool/tool.ts"
import { isImage, loadImage } from "./image.ts" import { isImage, loadImage } from "./image.ts"
@@ -34,6 +35,8 @@ export function attachmentsFor(text: string, ctx: Pick<ToolContext, "root" | "cw
const rel = m[1]!.replace(/\\([ #])/g, "$1").replace(/[.,;:!?)]+$/, "") const rel = m[1]!.replace(/\\([ #])/g, "$1").replace(/[.,;:!?)]+$/, "")
const abs = resolve(ctx.cwd, rel) const abs = resolve(ctx.cwd, rel)
if (allow && !allow(abs)) continue if (allow && !allow(abs)) continue
// Named to the model as its tools read paths: from the project root, ~/… under home (context.md §11).
const named = promptPath(ctx.root, abs) + (rel.endsWith("/") && abs !== "/" ? "/" : "")
const key = `${abs}#${m[2] ?? ""}-${m[3] ?? ""}` const key = `${abs}#${m[2] ?? ""}-${m[3] ?? ""}`
if (seen.has(key)) continue if (seen.has(key)) continue
seen.add(key) seen.add(key)
@@ -47,25 +50,25 @@ export function attachmentsFor(text: string, ctx: Pick<ToolContext, "root" | "cw
const entries = readdirSync(abs, { withFileTypes: true }) const entries = readdirSync(abs, { withFileTypes: true })
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name)) .map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
.sort() .sort()
out.push({ path: rel, text: `<directory path="${rel}">\n${entries.join("\n")}\n</directory>` }) out.push({ path: rel, text: `<directory path="${named}">\n${entries.join("\n")}\n</directory>` })
continue continue
} }
if (isImage(abs)) { if (isImage(abs)) {
if (!vision) { if (!vision) {
out.push({ path: rel, text: `<image path="${rel}">(not attached: this model has no vision)</image>` }) out.push({ path: rel, text: `<image path="${named}">(not attached: this model has no vision)</image>` })
continue continue
} }
const img = loadImage(abs) const img = loadImage(abs)
out.push(typeof img === "string" ? { path: rel, text: `<image path="${rel}">(not attached: ${img})</image>` } : { path: rel, text: `<image path="${rel}"/>`, image: img }) out.push(typeof img === "string" ? { path: rel, text: `<image path="${named}">(not attached: ${img})</image>` } : { path: rel, text: `<image path="${named}"/>`, image: img })
continue continue
} }
if (st.size > MAX_BYTES && !m[2]) { if (st.size > MAX_BYTES && !m[2]) {
out.push({ path: rel, text: `<file path="${rel}">\n(${st.size} bytes — too large to attach whole; read it in parts, or reference a range like @${rel}#1-200)\n</file>` }) out.push({ path: rel, text: `<file path="${named}">\n(${st.size} bytes — too large to attach whole; read it in parts, or reference a range like @${named}#1-200)\n</file>` })
continue continue
} }
const raw = readFileSync(abs) const raw = readFileSync(abs)
if (raw.subarray(0, 8192).includes(0)) { if (raw.subarray(0, 8192).includes(0)) {
out.push({ path: rel, text: `<file path="${rel}">\n(binary file, ${st.size} bytes)\n</file>` }) out.push({ path: rel, text: `<file path="${named}">\n(binary file, ${st.size} bytes)\n</file>` })
continue continue
} }
const lines = raw.toString("utf8").split("\n") const lines = raw.toString("utf8").split("\n")
@@ -75,7 +78,7 @@ export function attachmentsFor(text: string, ctx: Pick<ToolContext, "root" | "cw
const body = lines.slice(from - 1, to).map((l, i) => `${from + i}: ${l}`).join("\n") const body = lines.slice(from - 1, to).map((l, i) => `${from + i}: ${l}`).join("\n")
const range = m[2] ? ` lines="${from}-${to}"` : "" const range = m[2] ? ` lines="${from}-${to}"` : ""
const more = !m[2] && lines.length > to ? `\n(${lines.length - to} more lines — read with offset ${to + 1})` : "" const more = !m[2] && lines.length > to ? `\n(${lines.length - to} more lines — read with offset ${to + 1})` : ""
out.push({ path: rel, text: `<file path="${rel}"${range}>\n${body}${more}\n</file>` }) out.push({ path: rel, text: `<file path="${named}"${range}>\n${body}${more}\n</file>` })
if (!m[2]) { if (!m[2]) {
ctx.readFiles.add(abs) ctx.readFiles.add(abs)
ctx.fileStamps.set(abs, st.mtimeMs) ctx.fileStamps.set(abs, st.mtimeMs)
+81 -17
View File
@@ -1,6 +1,7 @@
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs" import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
import { platform, release } from "node:os" import { homedir, platform, release } from "node:os"
import { dirname, join, relative, resolve, sep } from "node:path" import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
import { LOOP } from "../harness.ts"
import type { Mode } from "../config/schema.ts" import type { Mode } from "../config/schema.ts"
import { expandHome, paths } from "../config/paths.ts" import { expandHome, paths } from "../config/paths.ts"
import { render, type Plan } from "../plan/plan.ts" import { render, type Plan } from "../plan/plan.ts"
@@ -17,6 +18,8 @@ import blockLibrary from "../../harness/prompts/blocks/library.md" with { type:
import blockPrototypes from "../../harness/prompts/blocks/prototypes.md" with { type: "text" } import blockPrototypes from "../../harness/prompts/blocks/prototypes.md" with { type: "text" }
import blockSubagent from "../../harness/prompts/blocks/subagent.md" with { type: "text" } import blockSubagent from "../../harness/prompts/blocks/subagent.md" with { type: "text" }
import blockContext from "../../harness/prompts/blocks/context.md" with { type: "text" } import blockContext from "../../harness/prompts/blocks/context.md" with { type: "text" }
import blockTeamLead from "../../harness/prompts/blocks/team_lead.md" with { type: "text" }
import blockTeamMember from "../../harness/prompts/blocks/team_member.md" with { type: "text" }
import agentExplore from "../../harness/prompts/agents/explore.md" with { type: "text" } import agentExplore from "../../harness/prompts/agents/explore.md" with { type: "text" }
import agentGeneral from "../../harness/prompts/agents/general.md" with { type: "text" } import agentGeneral from "../../harness/prompts/agents/general.md" with { type: "text" }
import modeManual from "../../harness/prompts/modes/manual.md" with { type: "text" } import modeManual from "../../harness/prompts/modes/manual.md" with { type: "text" }
@@ -56,6 +59,8 @@ const BUILTIN: Record<string, string> = {
"blocks/prototypes.md": blockPrototypes, "blocks/prototypes.md": blockPrototypes,
"blocks/subagent.md": blockSubagent, "blocks/subagent.md": blockSubagent,
"blocks/context.md": blockContext, "blocks/context.md": blockContext,
"blocks/team_lead.md": blockTeamLead,
"blocks/team_member.md": blockTeamMember,
"agents/explore.md": agentExplore, "agents/explore.md": agentExplore,
"agents/general.md": agentGeneral, "agents/general.md": agentGeneral,
"modes/manual.md": modeManual, "modes/manual.md": modeManual,
@@ -231,8 +236,14 @@ export interface PromptContext {
/** Set for a subagent: who it is and what it is for; replaces the mode's own instructions. /** Set for a subagent: who it is and what it is for; replaces the mode's own instructions.
* `report`: it has the report tool. */ * `report`: it has the report tool. */
subagent?: { name: string; instructions: string; report?: boolean } subagent?: { name: string; instructions: string; report?: boolean }
/** The memory snapshot taken when the session started ("" when both files are empty); /** Spec 3.2.0 (team.md §4): a team member — blocks/team_member.md in place of blocks/subagent.md,
* undefined when memory is off. */ * 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 memory?: string
/** The skills list (skillLines), taken when the session started. */ /** The skills list (skillLines), taken when the session started. */
skills?: string skills?: string
@@ -246,7 +257,8 @@ export interface PromptContext {
mcp?: string mcp?: string
/** The other configured models, one line each (with their notes). */ /** The other configured models, one line each (with their notes). */
roster?: string roster?: string
/** The session's plan (plan_write / plan_update), put into the prompt while it exists. */ /** The session's plan (plan_write / plan_update) as it stood when the prompt was made; a change
* after that is the model's own tool result, or a note when the person made it. */
plan?: Plan plan?: Plan
/** An instance's own instructions for this session (the chat's authored prompt, over the link), /** An instance's own instructions for this session (the chat's authored prompt, over the link),
* appended after the shared text under `## Instructions from <name>`. */ * appended after the shared text under `## Instructions from <name>`. */
@@ -263,6 +275,36 @@ export interface PromptContext {
const MAX_INSTRUCTION_CHARS = 40_000 const MAX_INSTRUCTION_CHARS = 40_000
/** The environment block's lines about where the session works (loop.json `context.env`, spec
* 3.2.0): the project by its root's last path component, and — started below the root — that
* directory relative to it. No absolute path. */
export function envLines(root: string, started: string): string[] {
const r = resolve(root)
const name = basename(r) || r
const lines = [fillPrompt(LOOP.context.env.project, { name })]
const rel = relative(r, resolve(started))
if (rel && !rel.startsWith("..") && !isAbsolute(rel)) lines.push(fillPrompt(LOOP.context.env.started, { path: rel }))
return lines
}
/** A path as a prompt names it (context.md §11): relative to the project root inside it, `~/…`
* under the person's home, absolute only outside both. */
export function promptPath(root: string, path: string): string {
const rel = relative(resolve(root), path)
if (rel === "") return "."
if (!rel.startsWith("..") && !isAbsolute(rel)) return rel
const home = homedir()
if (home && home !== "/" && (path === home || path.startsWith(home + sep))) return `~${path.slice(home.length)}`
return path
}
/** The system prompt (harness spec; its stability is context.md §10, from 3.1.0). It is made once
* 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
* its own: no date (a note on the first user message says it, session/prefix.ts), the permission
* mode's text as it was at the start, the plan as it stood then, memory, instruction files,
* personality and MCP instructions as they were read for it. What changes later reaches the model
* as a `<session-note>` on the next user message, never by making this again. */
export function assembleSystem(ctx: PromptContext): string { export function assembleSystem(ctx: PromptContext): string {
const vars = { const vars = {
agent_name: ctx.agentName ?? "LLeMbas", agent_name: ctx.agentName ?? "LLeMbas",
@@ -293,11 +335,11 @@ export function assembleSystem(ctx: PromptContext): string {
sections.push( sections.push(
[ [
"<env>", "<env>",
`Working directory: ${ctx.cwd}`, // No absolute path (context.md §11): the project by its name, relative paths against its root.
`Project root: ${ctx.root}`, ...envLines(ctx.root, ctx.cwd),
`Git repository: ${ctx.isGit ? "yes" : "no"}`, `Git repository: ${ctx.isGit ? "yes" : "no"}`,
`Platform: ${platform()} ${release()}`, `Platform: ${platform()} ${release()}`,
`Today: ${new Date().toISOString().slice(0, 10)}`, // No date: it would change at midnight and the whole session be read again (§10 rule 2).
`Model: ${ctx.modelRef}`, `Model: ${ctx.modelRef}`,
"</env>", "</env>",
].join("\n"), ].join("\n"),
@@ -310,18 +352,18 @@ export function assembleSystem(ctx: PromptContext): string {
const files = ctx.files ?? instructionFiles(ctx.cwd, ctx.root, ctx.instructions) const files = ctx.files ?? instructionFiles(ctx.cwd, ctx.root, ctx.instructions)
for (const f of files) { for (const f of files) {
let text = f.text.trim() let text = f.text.trim()
// A file in the project by its path from the root, one under home as ~/… (context.md §11).
const named = promptPath(ctx.root, f.path)
if (text.length > MAX_INSTRUCTION_CHARS) if (text.length > MAX_INSTRUCTION_CHARS)
text = text.slice(0, MAX_INSTRUCTION_CHARS) + `\n[…truncated: kept ${MAX_INSTRUCTION_CHARS} of ${f.text.length} characters; read ${f.path} for the rest]` text = text.slice(0, MAX_INSTRUCTION_CHARS) + `\n[…truncated: kept ${MAX_INSTRUCTION_CHARS} of ${f.text.length} characters; read ${named} for the rest]`
// The chain's files by their path in the project (the spec's "relative to the boundary").
const named = f.chain ? relative(ctx.root, f.path) || f.path : f.path
sections.push(`Instructions from ${named}:\n<instructions>\n${text}\n</instructions>`) sections.push(`Instructions from ${named}:\n<instructions>\n${text}\n</instructions>`)
} }
if (ctx.mcp) sections.push(ctx.mcp) if (ctx.mcp) sections.push(ctx.mcp)
if (!ctx.subagent) sections.push(agentsBlock(ctx, files)) if (!ctx.subagent) sections.push(agentsBlock(ctx, files))
// Memory and skills: snapshots taken when the session started, so the prompt does not change // Memory and skills: as they were when the prompt was made, so the prompt does not change under
// under a session (Hermes' frozen snapshot). // a session (Hermes' frozen snapshot); the person's later edit to memory goes as a note.
if (ctx.memory !== undefined && !ctx.subagent) { if (ctx.memory !== undefined && !ctx.subagent) {
sections.push(promptText("blocks/memory.md")) sections.push(promptText("blocks/memory.md"))
if (ctx.memory) sections.push(ctx.memory) if (ctx.memory) sections.push(ctx.memory)
@@ -344,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.library && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/library.md"), { knowledge_scope: ctx.library.scope }))
if (ctx.prototypes && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/prototypes.md"), { prototype_sources: ctx.prototypes.sources, prototype_surface: PROTOTYPE_SURFACE })) if (ctx.prototypes && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/prototypes.md"), { prototype_sources: ctx.prototypes.sources, prototype_surface: PROTOTYPE_SURFACE }))
if (ctx.subagent) if (ctx.teamLead && !ctx.subagent) sections.push(promptText("blocks/team_lead.md"))
if (ctx.subagent && ctx.member)
sections.push(
fillPrompt(promptText("blocks/team_member.md"), {
name: ctx.member.name,
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( sections.push(
fillPrompt(promptText("blocks/subagent.md"), { fillPrompt(promptText("blocks/subagent.md"), {
name: ctx.subagent.name, name: ctx.subagent.name,
@@ -353,8 +410,7 @@ export function assembleSystem(ctx: PromptContext): string {
read_only: ctx.mode === "plan" ? SUBAGENT_READ_ONLY : "", read_only: ctx.mode === "plan" ? SUBAGENT_READ_ONLY : "",
}), }),
) )
else sections.push(fillPrompt(promptText(`modes/${ctx.mode}.md`), vars)) else sections.push(modeText(ctx.mode, ctx.planDir, ctx.unattended))
if (ctx.unattended) sections.push(fillPrompt(promptText("modes/unattended.md"), { ...vars, refused: ctx.unattended }))
// What the person said about themselves goes last, under the web UI's headings and in its order. // What the person said about themselves goes last, under the web UI's headings and in its order.
// Not for a subagent: it answers the main agent, not the person. // Not for a subagent: it answers the main agent, not the person.
if (ctx.userInstructions && !ctx.subagent) sections.push(`## How the person you are talking to wants to be helped\n\n${ctx.userInstructions}`) if (ctx.userInstructions && !ctx.subagent) sections.push(`## How the person you are talking to wants to be helped\n\n${ctx.userInstructions}`)
@@ -362,6 +418,14 @@ export function assembleSystem(ctx: PromptContext): string {
return sections.join("\n\n") return sections.join("\n\n")
} }
/** A permission mode's text (modes/<mode>.md), with the unattended block when nobody can approve
* (`refused`: what will be refused, in words): the prompt's own as it was made, and the body of a
* note when the mode changes later (session/prefix.ts). */
export function modeText(mode: Mode, planDir: string, refused?: string): string {
const own = fillPrompt(promptText(`modes/${mode}.md`), { plan_dir: planDir })
return refused ? `${own}\n\n${fillPrompt(promptText("modes/unattended.md"), { plan_dir: planDir, refused })}` : own
}
const GIT_QUESTION = const GIT_QUESTION =
"do you commit on your own once a change is finished and checked, only when they ask, or never; and which branch the work goes on (the current one, or a new branch for each task)." "do you commit on your own once a change is finished and checked, only when they ask, or never; and which branch the work goes on (the current one, or a new branch for each task)."
const GIT_QUESTION_NO_REPO = "this folder is not a git repository yet — should it be one, and if so, do you commit on your own once a change is finished and checked, or only when they ask." const GIT_QUESTION_NO_REPO = "this folder is not a git repository yet — should it be one, and if so, do you commit on your own once a change is finished and checked, or only when they ask."
@@ -374,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. // Files handed in without the mark (an older caller): anything but the global file, as before.
const marked = files.some((f) => f.chain) const marked = files.some((f) => f.chain)
const own = files.filter((f) => (marked ? f.chain : f.path !== join(paths.config, "AGENTS.md"))) const own = files.filter((f) => (marked ? f.chain : f.path !== join(paths.config, "AGENTS.md")))
const vars = { root: ctx.root, git_question: ctx.isGit ? GIT_QUESTION : GIT_QUESTION_NO_REPO } const vars = { git_question: ctx.isGit ? GIT_QUESTION : GIT_QUESTION_NO_REPO }
if (!own.length) return fillPrompt(promptText("blocks/agents-missing.md"), vars) if (!own.length) return fillPrompt(promptText("blocks/agents-missing.md"), vars)
const keep = promptText("blocks/agents-keep.md") const keep = promptText("blocks/agents-keep.md")
return own.some((f) => /^#{1,3}\s*git\b/im.test(f.text)) ? keep : `${fillPrompt(promptText("blocks/agents-git.md"), vars)}\n\n${keep}` return own.some((f) => /^#{1,3}\s*git\b/im.test(f.text)) ? keep : `${fillPrompt(promptText("blocks/agents-git.md"), vars)}\n\n${keep}`
+15
View File
@@ -168,3 +168,18 @@ export function historyArgs(args: string): string {
} catch {} } catch {}
return "{}" return "{}"
} }
/** Harness context.md §8 (spec 3.2.0): the history as a request sends it — every assistant message
* of the current turn (after the last user message that started a turn) keeps its reasoning,
* earlier ones carry none. What is stored is unchanged; only the request is. */
export function turnReasoning(messages: Message[]): Message[] {
let start = -1
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!
if (m.role === "user" && m.turn !== false) {
start = i
break
}
}
return messages.map((m, i) => (m.role === "assistant" && i < start && m.parts.some((p) => p.type === "reasoning") ? { ...m, parts: m.parts.filter((p) => p.type !== "reasoning") } : m))
}
+80 -16
View File
@@ -10,29 +10,83 @@ import { learned } from "./learned.ts"
export const DEFAULT_EFFORTS: Effort[] = ["low", "medium", "high"] export const DEFAULT_EFFORTS: Effort[] = ["low", "medium", "high"]
/** The efforts this model accepts, in offering order: learned > configured > the common three. */ /** The efforts this model accepts, in offering order: learned > the server's > the common three.
* `[]` from the server: it takes none (its one level is `default`). */
export function effortsFor(m: ResolvedModel): Effort[] { export function effortsFor(m: ResolvedModel): Effort[] {
const list = learned().efforts[m.ref] ?? m.spec.efforts ?? DEFAULT_EFFORTS const list = learned().efforts[m.ref] ?? m.spec.efforts ?? DEFAULT_EFFORTS
return EFFORTS.filter((e) => list.includes(e)) return EFFORTS.filter((e) => list.includes(e))
} }
/** The model's default effort, or null for off / not a reasoning model. */ /** An effort level (harness models/schema.json `levels`, spec 3.2.0): an effort; `off` — thinking
export function defaultEffort(m: ResolvedModel): Effort | null { * switched off, only on a model with `thinking_switch`; `default` — nothing sent, the model's own,
const e = m.spec.effort * only on a model that takes no efforts. */
if (!e || e === "off") return null export type Level = Effort | "off" | "default"
return effortsFor(m).includes(e) ? e : null export const LEVEL_WORDS: readonly Level[] = [...EFFORTS, "off", "default"]
export type Control = "none" | "switch" | "slider"
/** What the levels are read from: the model's metadata, efforts as given (never absent here: an
* absent list is each project's fallback, effortsFor's). */
export interface LevelMeta {
thinking_switch?: boolean
efforts: readonly Effort[]
effort?: string
effort_style?: "top" | "kwargs" | "both"
} }
/** Put the effort into an openai-chat request body. An effort outside the vocabulary is dropped. */ export function isLevel(v: unknown): v is Level {
export function applyEffort(body: Record<string, unknown>, m: ResolvedModel, effort: Effort | null | undefined) { return typeof v === "string" && (LEVEL_WORDS as readonly string[]).includes(v)
if (!effort || !effortsFor(m).includes(effort)) return }
const style = m.spec.effort_style ?? "both"
if (style !== "kwargs") body.reasoning_effort = effort /** The levels a chat offers, how they are shown, and where a new conversation starts. */
if (style !== "top") { export function levelsOf(meta: LevelMeta): { levels: Level[]; control: Control; start: Level } {
const kwargs = { ...((body.chat_template_kwargs as Record<string, unknown>) ?? {}) } const levels: Level[] = [...(meta.thinking_switch === true ? (["off"] as Level[]) : []), ...(meta.efforts.length ? meta.efforts : (["default"] as Level[]))]
kwargs.reasoning_effort = effort const control: Control = levels.length === 1 ? "none" : levels.length === 2 && levels.includes("off") ? "switch" : "slider"
body.chat_template_kwargs = kwargs const start: Level =
} meta.effort && (levels as string[]).includes(meta.effort) ? (meta.effort as Level) : meta.efforts.includes("medium") ? "medium" : (meta.efforts[0] ?? "default")
return { levels, control, start }
}
/** A model's levels: its switch, its efforts (learned > the server's > the common three). */
export function levelsFor(m: ResolvedModel) {
return levelsOf({ thinking_switch: m.spec.thinking_switch, efforts: effortsFor(m), effort: m.spec.effort, effort_style: m.spec.effort_style })
}
/** The level a request uses for `asked`: itself when the model offers it, else the start level (a
* level the model does not offer — an `off` from an older client — is read as the start level). */
export function levelOf(meta: LevelMeta, asked: string | null | undefined): Level {
const { levels, start } = levelsOf(meta)
return asked && (levels as string[]).includes(asked) ? (asked as Level) : start
}
/** A model's level for `asked` (levelOf), or its start level when nothing is asked. */
export function readLevel(m: ResolvedModel, asked?: string | null): Level {
return levelOf({ thinking_switch: m.spec.thinking_switch, efforts: effortsFor(m), effort: m.spec.effort }, asked)
}
/** The model's start level. */
export function defaultEffort(m: ResolvedModel): Level {
return levelsFor(m).start
}
/** What an OpenAI chat request body carries for a level: `off` — enable_thinking false in
* chat_template_kwargs and no effort anywhere; `default` — nothing; an effort — where
* effort_style puts it, and no enable_thinking. A level the model does not offer is read as its
* start level. */
export function levelFields(meta: LevelMeta, asked: string | null | undefined): { reasoning_effort?: Effort; chat_template_kwargs?: Record<string, unknown> } {
const level = levelOf(meta, asked)
if (level === "default") return {}
if (level === "off") return { chat_template_kwargs: { enable_thinking: false } }
const style = meta.effort_style ?? "both"
return { ...(style !== "kwargs" ? { reasoning_effort: level } : {}), ...(style !== "top" ? { chat_template_kwargs: { reasoning_effort: level } } : {}) }
}
/** Put the level into an openai-chat request body. `null`/undefined: nothing sent (a small job
* of the harness's own — a title, a commit message — sends no level). */
export function applyEffort(body: Record<string, unknown>, m: ResolvedModel, level: Level | null | undefined) {
if (!level) return
const f = levelFields({ thinking_switch: m.spec.thinking_switch, efforts: effortsFor(m), effort: m.spec.effort, effort_style: m.spec.effort_style }, level)
if (f.reasoning_effort) body.reasoning_effort = f.reasoning_effort
if (f.chat_template_kwargs) body.chat_template_kwargs = { ...((body.chat_template_kwargs as Record<string, unknown>) ?? {}), ...f.chat_template_kwargs }
} }
export function stripEffort(body: Record<string, unknown>): Record<string, unknown> { export function stripEffort(body: Record<string, unknown>): Record<string, unknown> {
@@ -40,6 +94,7 @@ export function stripEffort(body: Record<string, unknown>): Record<string, unkno
delete out.reasoning_effort delete out.reasoning_effort
const kwargs = { ...((out.chat_template_kwargs as Record<string, unknown>) ?? {}) } const kwargs = { ...((out.chat_template_kwargs as Record<string, unknown>) ?? {}) }
delete kwargs.reasoning_effort delete kwargs.reasoning_effort
delete kwargs.enable_thinking
if (Object.keys(kwargs).length) out.chat_template_kwargs = kwargs if (Object.keys(kwargs).length) out.chat_template_kwargs = kwargs
else delete out.chat_template_kwargs else delete out.chat_template_kwargs
return out return out
@@ -77,3 +132,12 @@ export function effortsFromTemplate(template: string): Effort[] {
const out = EFFORTS.filter((e) => found.has(e)) const out = EFFORTS.filter((e) => found.has(e))
return out.length >= 2 ? out : [] return out.length >= 2 ? out : []
} }
/** The level as a status line says it, by how the model's levels are shown: nothing for a model
* with one level, "thinking" / "thinking off" for a Thinking switch, else the level's name. */
export function levelLabel(m: ResolvedModel, level: Level): string {
const { control } = levelsFor(m)
if (control === "none") return ""
if (control === "switch") return level === "off" ? "thinking off" : "thinking"
return level
}
+25 -5
View File
@@ -8,7 +8,7 @@ import { resolveKey } from "../config/load.ts"
import { advertisedEfforts, applyEffort, effortRefused, effortsFor, stripEffort } from "./effort.ts" import { advertisedEfforts, applyEffort, effortRefused, effortsFor, stripEffort } from "./effort.ts"
import { authHeaders, request, joinUrl, tlsFor } from "./http.ts" import { authHeaders, request, joinUrl, tlsFor } from "./http.ts"
import { learned, learnEfforts, learnNoProgress, learnNoStreamOptions } from "./learned.ts" import { learned, learnEfforts, learnNoProgress, learnNoStreamOptions } from "./learned.ts"
import { historyArgs, Parts, record, recordFailure, retrying, statusOf, SwapBanner } from "./common.ts" import { historyArgs, Parts, record, recordFailure, retrying, statusOf, SwapBanner, turnReasoning } from "./common.ts"
import { sseJson } from "./sse.ts" import { sseJson } from "./sse.ts"
import { ThinkSplitter } from "./think.ts" import { ThinkSplitter } from "./think.ts"
import { estimateTokens, estimateValue } from "./tokens.ts" import { estimateTokens, estimateValue } from "./tokens.ts"
@@ -30,7 +30,7 @@ type Assistant = Extract<Message, { role: "assistant" }>
export function toOpenAIMessages(system: string, messages: Message[], vision: boolean): unknown[] { export function toOpenAIMessages(system: string, messages: Message[], vision: boolean): unknown[] {
const out: unknown[] = [] const out: unknown[] = []
if (system) out.push({ role: "system", content: system }) if (system) out.push({ role: "system", content: system })
for (const m of messages) { for (const m of turnReasoning(messages)) {
if (m.role === "user") { if (m.role === "user") {
const images = m.parts.filter((p) => p.type === "image") const images = m.parts.filter((p) => p.type === "image")
if (images.length === 0 || !vision) { if (images.length === 0 || !vision) {
@@ -50,6 +50,10 @@ export function toOpenAIMessages(system: string, messages: Message[], vision: bo
const text = m.parts.filter((p): p is TextPart => p.type === "text").map((p) => p.text).join("") const text = m.parts.filter((p): p is TextPart => p.type === "text").map((p) => p.text).join("")
const calls = m.parts.filter((p): p is ToolCallPart => p.type === "tool_call") const calls = m.parts.filter((p): p is ToolCallPart => p.type === "tool_call")
const msg: Record<string, unknown> = { role: "assistant", content: text || null } const msg: Record<string, unknown> = { role: "assistant", content: text || null }
// The current turn's reasoning goes back as the model gave it (context.md §8) — inline <think>
// tags were read as reasoning, and go back here, never as text. None: no field.
const reasoning = m.parts.filter((p): p is ReasoningPart => p.type === "reasoning").map((p) => p.text).join("")
if (reasoning) msg.reasoning_content = reasoning
if (calls.length) if (calls.length)
msg.tool_calls = calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: historyArgs(c.args) } })) msg.tool_calls = calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: historyArgs(c.args) } }))
out.push(msg) out.push(msg)
@@ -207,6 +211,10 @@ export class OpenAIChatClient implements Client {
const addText = (kind: "text" | "reasoning", text: string) => out.add(kind, text) const addText = (kind: "text" | "reasoning", text: string) => out.add(kind, text)
let finish = "stop" let finish = "stop"
let usage: StreamEvent | undefined let usage: StreamEvent | undefined
// llama.cpp's own count, in its last chunk: `timings` with `cache_n` (read from its cache) and
// `prompt_n` (read now). The cached part of a request when `usage` does not say it.
let timings: { cache: number; read: number; out?: number } | undefined
let progressCache: number | undefined
for await (const { data } of sseJson(stream)) { for await (const { data } of sseJson(stream)) {
const chunk = data as any const chunk = data as any
@@ -217,6 +225,9 @@ export class OpenAIChatClient implements Client {
// string code; statusOf() still reads it as the server error it is. // string code; statusOf() still reads it as the server error it is.
throw new ProviderError(`${m.ref}: ${String(msg).trim()}`, statusOf(err), undefined, !out.output) throw new ProviderError(`${m.ref}: ${String(msg).trim()}`, statusOf(err), undefined, !out.output)
} }
const tm = chunk?.timings
if (tm && typeof tm === "object" && Number.isFinite(tm.cache_n))
timings = { cache: Number(tm.cache_n), read: Number(tm.prompt_n) || 0, ...(Number.isFinite(tm.predicted_n) ? { out: Number(tm.predicted_n) } : {}) }
if (chunk?.usage && typeof chunk.usage === "object") { if (chunk?.usage && typeof chunk.usage === "object") {
const u = chunk.usage const u = chunk.usage
usage = { usage = {
@@ -232,8 +243,10 @@ export class OpenAIChatClient implements Client {
// llama.cpp, while it reads the prompt: { total, cache, processed, time_ms }. Anything not // llama.cpp, while it reads the prompt: { total, cache, processed, time_ms }. Anything not
// shaped like that is ignored, never an error. // shaped like that is ignored, never an error.
const pp = chunk?.prompt_progress const pp = chunk?.prompt_progress
if (pp && typeof pp === "object" && Number.isFinite(pp.total) && pp.total > 0 && !out.output) if (pp && typeof pp === "object" && Number.isFinite(pp.total) && pp.total > 0 && !out.output) {
yield { type: "progress", total: Number(pp.total), cache: Number(pp.cache) || 0, processed: Number(pp.processed) || 0, ms: Number(pp.time_ms) || 0 } progressCache = Number(pp.cache) || 0
yield { type: "progress", total: Number(pp.total), cache: progressCache, processed: Number(pp.processed) || 0, ms: Number(pp.time_ms) || 0 }
}
const choice = chunk?.choices?.[0] const choice = chunk?.choices?.[0]
if (!choice) continue if (!choice) continue
const delta = choice.delta ?? choice.message ?? {} const delta = choice.delta ?? choice.message ?? {}
@@ -272,9 +285,16 @@ export class OpenAIChatClient implements Client {
if (calls.length && finish !== "length") finish = "tool_calls" if (calls.length && finish !== "length") finish = "tool_calls"
const message = out.message() const message = out.message()
const parts = message.parts const parts = message.parts
// The cached input tokens (harness context.md §10 rule 6): the usage's own field
// (`prompt_tokens_details.cached_tokens`, or `cache_read_input_tokens` where an Anthropic-shaped
// count comes through), else llama.cpp's timings, else its last progress report.
const cached = timings?.cache ?? progressCache
if (usage?.type === "usage" && usage.usage.cached === undefined && cached !== undefined) usage.usage.cached = cached
if (!usage && timings?.out !== undefined)
usage = { type: "usage", usage: { input: timings.cache + timings.read, output: timings.out, cached: timings.cache } }
if (!usage) { if (!usage) {
const out = parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.name + p.args : p.text), 0) const out = parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.name + p.args : p.text), 0)
usage = { type: "usage", usage: { input: estimateValue(body.messages), output: out, estimated: true } } usage = { type: "usage", usage: { input: estimateValue(body.messages), output: out, estimated: true, ...(cached !== undefined ? { cached } : {}) } }
} }
yield usage yield usage
yield { type: "finish", reason: finish, message } yield { type: "finish", reason: finish, message }
+14 -3
View File
@@ -13,7 +13,17 @@ export type ToolCallPart = { type: "tool_call"; id: string; name: string; args:
export type Message = export type Message =
/** `turnId`: the turn a prompt started, kept with it in the store for /** `turnId`: the turn a prompt started, kept with it in the store for
* `_lembas/session/history`; never sent to a model. */ * `_lembas/session/history`; never sent to a model. */
| { role: "user"; parts: (TextPart | ImagePart)[]; turnId?: string; /** The harness wrote it (a job's note), not the person: shown as such. */ machine?: true } | {
role: "user"
parts: (TextPart | ImagePart)[]
turnId?: string
/** The harness wrote it (a job's note), not the person: shown as such. */
machine?: true
/** false: it does not start a turn — delivered at a step boundary while a turn runs (a job's
* note, a team message, words the person added), or the harness's own nudge within the turn.
* Absent: it does (harness context.md §8). */
turn?: false
}
| { role: "assistant"; parts: (TextPart | ReasoningPart | ToolCallPart)[] } | { role: "assistant"; parts: (TextPart | ReasoningPart | ToolCallPart)[] }
| { role: "tool"; callId: string; name: string; content: string; isError?: boolean } | { role: "tool"; callId: string; name: string; content: string; isError?: boolean }
@@ -50,8 +60,9 @@ export interface ChatRequest {
system: string system: string
messages: Message[] messages: Message[]
tools: ToolSpec[] tools: ToolSpec[]
/** null = off */ /** The effort level (provider/effort.ts); null or absent: nothing sent (a small job of the
effort?: Effort | null * harness's own). */
effort?: Effort | "off" | "default" | null
signal?: AbortSignal signal?: AbortSignal
} }
+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 // Whether a subagent may run on a model beside the session's own, judged by what its server can do
// at once — the harness spec's capacity rule, after LLeMbas services/helpers.py (© Jaroslav Beneš, // at once — the harness spec's capacity rule, after LLeMbas services/helpers.py (© Jaroslav Beneš,
// MIT). Both flags name the restrictive state and default off, so a configuration that // MIT). Both flags name the restrictive state and default off, so a configuration that
// never sets them works exactly as before. // never sets them works exactly as before. From spec 3.2.0 a model says how many requests it serves
// at once (`parallel`; single_session is 1).
// //
// - A connection that holds one model at a time (llama-swap in front of one GPU): a subagent on // - A connection that holds one model at a time (llama-swap in front of one GPU): a subagent on
// another of its models unloads the session's model, and the session's cached prompt with it, // another of its models unloads the session's model, and the session's cached prompt with it,
@@ -11,10 +12,23 @@
// minutes, on a long session with a small GPU. // minutes, on a long session with a small GPU.
import type { ResolvedModel } from "../provider/types.ts" import type { ResolvedModel } from "../provider/types.ts"
export function capacityRefusal(main: ResolvedModel, helper: ResolvedModel): string { /** How many requests a model's server serves at once (models/schema.json capacity, spec 3.2.0):
* `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.ref === main.ref) {
if (helper.spec.single_session) const n = parallelOf(helper)
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.` 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 "" return ""
} }
const shared = groupOf(main) 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 /** The same rule between two subagents: a new one is not started where its model would unload, or
* take the only slot of, a subagent already running (the spec's "beside the model of a subagent * take the only slot of, a subagent already running (the spec's "beside the model of a subagent
* already running"). */ * already running"). */
export function capacityBeside(running: ResolvedModel, helper: ResolvedModel): string { export function capacityBeside(running: ResolvedModel, helper: ResolvedModel, busy = 1): string {
if (helper.ref === running.ref) { 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 "" return ""
} }
const shared = groupOf(running) 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 { fill, promptText } from "../prompt/assemble.ts"
import { skillMessage, slug, type Skill } from "../skill/index.ts" import { skillMessage, slug, type Skill } from "../skill/index.ts"
import { CONTINUE_PROMPT } from "./engine.ts" import { CONTINUE_PROMPT } from "./engine.ts"
import { TEAM, Team } from "../team/team.ts"
export type CommandKind = "custom" | "skill" | "builtin" 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", "agents", "continue", "help", "model", "effort", "mode", "plan", "plans", "prototype", "review", "changelog", "init", "release", "tasks", "decisions", "decide",
"jobs", "sessions", "resume", "remote", "delete", "new", "clear", "copy", "undo", "redo", "diff", "checkpoint", "checkpoints", "branch", "commit", "context", "jobs", "sessions", "resume", "remote", "delete", "new", "clear", "copy", "undo", "redo", "diff", "checkpoint", "checkpoints", "branch", "commit", "context",
"compact", "memory", "skills", "skill", "personality", "voice", "speak", "mcp", "login", "logout", "settings", "usage", "kb", "theme", "skin", "terminal", "compact", "memory", "skills", "skill", "personality", "voice", "speak", "mcp", "login", "logout", "settings", "usage", "kb", "theme", "skin", "terminal",
"icons", "trust", "reload", "restart", "quit", "exit", "q", "icons", "trust", "reload", "restart", "quit", "exit", "q", "team",
] as const ] as const
/** The built-ins that work without a screen of their own. Descriptions as the TUI's (a test). */ /** The built-ins that work without a screen of their own. Descriptions as the TUI's (a test). */
@@ -47,8 +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: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" },
{ name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" }, { name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" },
{ name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" }, { name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" },
{ name: "team", description: "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 /** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and
* a client over ACP show. Making one (`new`) and handing one a task need a screen: the TUI's. */ * a client over ACP show. Making one (`new`) and handing one a task need a screen: the TUI's. */
export function agentsText(app: App, arg: string): string { export function agentsText(app: App, arg: string): string {
@@ -151,7 +195,6 @@ export function changelogPrompt(app: App, arg: string): { prompt: string } | { m
/** `/init [focus]`: make or update AGENTS.md. */ /** `/init [focus]`: make or update AGENTS.md. */
export function initPrompt(app: App, arg: string): string { export function initPrompt(app: App, arg: string): string {
return fill(promptText("tasks/init.md"), { return fill(promptText("tasks/init.md"), {
root: app.project.root,
focus: arg.trim() ? `What the user wants it to focus on: ${arg.trim()}` : "", focus: arg.trim() ? `What the user wants it to focus on: ${arg.trim()}` : "",
}).replace(/\n{3,}/g, "\n\n") }).replace(/\n{3,}/g, "\n\n")
} }
@@ -176,6 +219,8 @@ export function expandSlash(app: App, text: string): Expanded | undefined {
// List and describe anywhere; `new` and `<name> <task>` are the TUI's own. // List and describe anywhere; `new` and `<name> <task>` are the TUI's own.
if (/^(new)\b/.test(arg) || arg.trim().split(/\s+/).length > 1) return { kind: "message", name, text: "/agents here lists and describes the subagents; making one or handing one a task is done at the terminal." } if (/^(new)\b/.test(arg) || arg.trim().split(/\s+/).length > 1) return { kind: "message", name, text: "/agents here lists and describes the subagents; making one or handing one a task is done at the terminal." }
return { kind: "message", name, text: agentsText(app, arg) } return { kind: "message", name, text: agentsText(app, arg) }
case "team":
return { kind: "message", name, text: teamText(app, arg) }
case "init": case "init":
// The prompt is ours; only what was typed after the name may attach files. // The prompt is ours; only what was typed after the name may attach files.
return { kind: "prompt", name, prompt: initPrompt(app, arg), shown: t, attachFrom: arg } return { kind: "prompt", name, prompt: initPrompt(app, arg), shown: t, attachFrom: arg }
+10 -1
View File
@@ -41,7 +41,7 @@ export function contextBreakdown(engine: Engine, system: string, instructions: {
if (imageCount) rows.push({ label: `images (${imageCount}, rough)`, tokens: by.images }) if (imageCount) rows.push({ label: `images (${imageCount}, rough)`, tokens: by.images })
const estimated = rows.filter((r) => !r.indent).reduce((n, r) => n + r.tokens, 0) const estimated = rows.filter((r) => !r.indent).reduce((n, r) => n + r.tokens, 0)
const reported = engine.usage ? engine.contextUsed() : undefined const reported = engine.usage ? engine.contextUsed() : undefined
return { rows, estimated, reported, window: contextWindow(engine.model), model: engine.model.ref } return { rows, estimated, reported, window: contextWindow(engine.model), model: engine.model.ref, last: engine.lastRequest }
} }
/** Plain text, for the transcript. */ /** Plain text, for the transcript. */
@@ -58,5 +58,14 @@ export function renderBreakdown(b: ReturnType<typeof contextBreakdown>): string
const used = b.reported ?? b.estimated const used = b.reported ?? b.estimated
lines.push(` ${"in use".padEnd(33)} ${fmt(used).padStart(7)} ${bar(used)}${b.window ? ` ${Math.round((used / b.window) * 100)}%` : ""}`) lines.push(` ${"in use".padEnd(33)} ${fmt(used).padStart(7)} ${bar(used)}${b.window ? ` ${Math.round((used / b.window) * 100)}%` : ""}`)
lines.push(b.reported !== undefined ? " (the server's own count from the last reply, plus an estimate since)" : " (estimated: ≈4 characters a token — no reply yet to count from)") lines.push(b.reported !== undefined ? " (the server's own count from the last reply, plus an estimate since)" : " (estimated: ≈4 characters a token — no reply yet to count from)")
// What the server reused of the last request (harness context.md §10 rule 6): a slow reply with
// little of it cached was a re-read.
const last = b.last?.usage
if (last && !last.estimated)
lines.push(
last.cached !== undefined
? ` last request${b.last!.kind === "compaction" ? " (compaction)" : ""}: ${fmt(last.input)} in, ${fmt(last.cached)} of it cached${last.input ? ` (${Math.round((last.cached / last.input) * 100)}%)` : ""}`
: ` last request: ${fmt(last.input)} in — the server did not say how much was cached`,
)
return lines.join("\n") return lines.join("\n")
} }
+290 -83
View File
@@ -4,21 +4,25 @@
// backstop, not a budget (LLeMbas generation._run / _wrap_up). // backstop, not a budget (LLeMbas generation._run / _wrap_up).
// The text is the harness spec's, so LLeMbas's Continue sends the same words. // The text is the harness spec's, so LLeMbas's Continue sends the same words.
import continueText from "../../harness/prompts/tasks/continue.md" with { type: "text" } import continueText from "../../harness/prompts/tasks/continue.md" with { type: "text" }
import { isAbsolute } from "node:path"
import type { Asker, AttachmentInfo, Bus } from "../bus/index.ts" import type { Asker, AttachmentInfo, Bus } from "../bus/index.ts"
import type { Effort, Mode } from "../config/schema.ts" import type { Mode } from "../config/schema.ts"
import type { Level } from "../provider/effort.ts"
import { evaluate, shadowsDeny, type Context as PermContext, type Rule } from "../permission/evaluate.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 { ProviderError, type Client, type ImagePart, type Message, type ResolvedModel, type ToolCallPart, type Usage } from "../provider/types.ts"
import type { Plan } from "../plan/plan.ts" import type { Plan } from "../plan/plan.ts"
import { LOOP } from "../harness.ts" import { LOOP } from "../harness.ts"
import { fillPrompt } from "../prompt/fill.ts" import { fillPrompt } from "../prompt/fill.ts"
import { splitPurpose, toSpec, ToolError, type Tool, type ToolContext, type ToolResult } from "../tool/tool.ts" import { splitPurpose, toSpec, ToolError, type Tool, type ToolContext, type ToolResult } from "../tool/tool.ts"
import { fill, promptText } from "../prompt/assemble.ts" import { promptText } from "../prompt/assemble.ts"
import { resolveCall } from "../tool/names.ts" import { resolveCall } from "../tool/names.ts"
import { contextWindow } from "../provider/index.ts" import { contextWindow } from "../provider/index.ts"
import { summaryTurns, type Store } from "./store.ts" import { summaryTurns, type Store } from "./store.ts"
import { estimateTokens, estimateValue } from "../provider/tokens.ts" import { estimateTokens, estimateValue } from "../provider/tokens.ts"
import { duration } from "../duration.ts" import { duration } from "../duration.ts"
import { memoryResults, presentIds, ResultContext } from "./results.ts" import { memoryResults, presentIds, ResultContext } from "./results.ts"
import { render } from "../plan/plan.ts"
import { compactAsk, isNote, NO_CHANGING, noteFor, offeredFor, PREFIX_META, summaryOf, today, toldAt, unavailable, type Changing, type Kept, type Now, type Requirement, type Told } from "./prefix.ts"
export interface EngineOptions { export interface EngineOptions {
bus: Bus bus: Bus
@@ -28,10 +32,23 @@ export interface EngineOptions {
tools: Tool[] tools: Tool[]
store?: Store store?: Store
sessionId?: string sessionId?: string
/** The system prompt for a step; `plan`: the session's plan, for `{{plan_context}}`. */ /** The session's system prompt, made from `changing` (what may change during the session, as it
system: (mode: Mode, model: ResolvedModel, plan?: Plan) => string * is now); `plan`: the session's plan, for `{{plan_context}}`. Called once per session, after a
* compaction and on a model switch — never per request (harness context.md §10). */
system: (mode: Mode, model: ResolvedModel, plan: Plan | undefined, changing: Changing) => string
/** What may change during the session, as it is now: compared at every user message, and a
* difference said in a note. Absent: nothing (a subagent's prompt holds none of it). */
changing?: () => Changing
/** The text a note gives for a permission mode (modes/<mode>.md as the prompt fills it). */
modeText?: (mode: Mode) => string
/** Awaited before a prompt is made at a turn's start: the app waits up to loop.json
* `context.mcp_wait_ms` for its MCP servers. */
beforeFirst?: () => Promise<void>
/** Today's date, as the note says it (tests set it). */
today?: () => string
mode: Mode mode: Mode
effort: Effort | null /** The effort level (provider/effort.ts): an effort, `off` or `default`. */
effort: Level
perm: Omit<PermContext, "mode"> perm: Omit<PermContext, "mode">
toolCtx: Omit<ToolContext, "signal"> toolCtx: Omit<ToolContext, "signal">
maxSteps?: number maxSteps?: number
@@ -46,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 /** What a message sent mid-turn starts with: the user's words for a session, the main agent's for
* a subagent (loop.json jobs.marks to_subagent). */ * a subagent (loop.json jobs.marks to_subagent). */
steerMark?: string steerMark?: string
/** The team this engine's agent is on (spec 3.2.0, team.md): whether it is offered (decided as
* the prompt is made, for `model`), what a call answers before its permission check (the team
* tools' requirement, task's size, a manager's work), the lead's note lines, and — the lead —
* a slot of the model before a turn runs. */
team?: {
/** Decide whether the team is offered for a new prompt on `model` — or, for a prompt the
* session kept (a resume), take what it decided then (`offered`). */
fix(model: string, offered?: boolean): boolean
offered(): boolean
refuse(tool: Tool, args: unknown): string | undefined
note?(): string[]
/** What the lead was told of the team is to be said again: a new prompt, or a rewind. */
forget?(): void
beforeTurn?(signal: AbortSignal): Promise<boolean>
}
} }
/** What a prompt carries besides its text. */ /** What a prompt carries besides its text. */
@@ -74,7 +106,8 @@ export function mergeUsers(messages: Message[]): Message[] {
const out: Message[] = [] const out: Message[] = []
for (const m of messages) { for (const m of messages) {
const last = out[out.length - 1] const last = out[out.length - 1]
if (m.role === "user" && last?.role === "user") out[out.length - 1] = { role: "user", parts: [...last.parts, ...m.parts] } // One that starts a turn makes the pair start one (context.md §8).
if (m.role === "user" && last?.role === "user") out[out.length - 1] = { role: "user", parts: [...last.parts, ...m.parts], ...(last.turn === false && m.turn === false ? { turn: false as const } : {}) }
else out.push(m) else out.push(m)
} }
return out return out
@@ -94,7 +127,7 @@ export const PLAN_META = "plan"
export class Engine { export class Engine {
messages: Message[] = [] messages: Message[] = []
mode: Mode mode: Mode
private _effort: Effort | null private _effort: Level
usage: Usage | undefined usage: Usage | undefined
/** messages.length when `usage` was reported: everything after it is not yet counted. */ /** messages.length when `usage` was reported: everything after it is not yet counted. */
usageAt = 0 usageAt = 0
@@ -126,8 +159,9 @@ export class Engine {
plan: Plan | undefined plan: Plan | undefined
private sessionRules: Rule[] = [] private sessionRules: Rule[] = []
private recentCalls: string[] = [] private recentCalls: string[] = []
/** Tools refused with no one able to approve them: no longer offered. */ /** Tools refused with no one able to approve them, and the refusal: still listed (the tool list
private withdrawn = new Set<string>() * is the session's, harness context.md §10), and every further call answers that refusal. */
private withdrawn = new Map<string, string>()
/** This prompt's spending, against `budget`: time waiting for the user, tool output, tokens written. */ /** This prompt's spending, against `budget`: time waiting for the user, tool output, tokens written. */
private turnWaitedMs = 0 private turnWaitedMs = 0
private turnOutputBytes = 0 private turnOutputBytes = 0
@@ -140,6 +174,18 @@ export class Engine {
* (session/results.ts, harness context.md). */ * (session/results.ts, harness context.md). */
results: ResultContext results: ResultContext
/** The prefix (harness context.md §10): the system prompt this session sends, the tools left out
* of its list, and what the model has been told — made at the first request, kept with the
* session, made again only after a compaction or on a model switch. */
private kept: Kept | undefined
/** What was told when the prompt was made: where a rewind (/undo) goes back to, since the notes
* after it may be gone from the conversation. */
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) { constructor(readonly o: EngineOptions) {
this.mode = o.mode this.mode = o.mode
this._effort = o.effort this._effort = o.effort
@@ -148,6 +194,139 @@ export class Engine {
this.sessionId = o.sessionId this.sessionId = o.sessionId
this.plan = this.storedPlan() this.plan = this.storedPlan()
this.results = this.resultsFor(o.sessionId) this.results = this.resultsFor(o.sessionId)
this.kept = this.storedPrefix()
}
/** The prefix the store holds for this session, if any: a resumed session sends what it kept. */
private storedPrefix(): Kept | undefined {
if (!this.o.store || !this.sessionId) return undefined
const k = this.o.store.meta<Kept | null>(this.sessionId, PREFIX_META)
if (!k || typeof k !== "object" || typeof k.system !== "string" || !Array.isArray(k.left) || !k.told) return undefined
this.keptBase = { ...structuredClone(k.told), date: undefined }
return k
}
private saveKept() {
if (this.o.store && this.sessionId) this.o.store.setMeta(this.sessionId, PREFIX_META, this.kept ?? null)
}
/** What could ever be met in this session (§10 rule 4), decided as the prompt is made: plan mode
* always can be entered; vision, a project directory, knowledge with documents and a display
* are as they are now, for the whole session. */
private could(): Set<Requirement> {
const could = new Set<Requirement>(["plan"])
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")
return could
}
/** What is met now, for a call: the mode for plan, the display as it is; the rest are fixed. */
private met(): Set<Requirement> {
const met = this.could()
if (this.mode !== "plan") met.delete("plan")
if (this.o.toolCtx.show === undefined) met.delete("display")
return met
}
/** Everything the note compares, as it is now. */
private now(changing = this.o.changing?.() ?? NO_CHANGING): Now {
const { source: _source, ...rest } = changing
return { ...structuredClone(rest), date: (this.o.today ?? today)(), mode: this.mode, plan: this.plan ? render(this.plan) : "" }
}
/** 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) {
// 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()
// Left out for the whole session: what it can never meet, and task where nothing can spawn.
// Everything else — MCP tools arriving as their servers connect among them — is offered.
const left = this.o.tools.filter((t) => (t.name === "task" && !this.o.spawn) || !offeredFor(t.requires ? [t.requires] : undefined, could)).map((t) => t.name)
const told = toldAt(now)
// A model switch keeps the conversation, and with it the date already said.
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
}
/** A system prompt as it would be made now, kept nowhere (what /context and tests look at
* before a session has made its own). */
compose(mode: Mode = this.mode, plan: Plan | undefined = this.plan): string {
return this.o.system(mode, this.model, plan, this.o.changing?.() ?? NO_CHANGING)
}
/** The system prompt this session sends (made now if it has none yet). */
systemPrompt(): string {
return this.prefix().system
}
/** The tools a request offers: the session's list, the same on every request — the wrap-up's
* included, and a tool withdrawn after a final refusal too (it answers that refusal). */
private toolSpecs() {
const left = new Set(this.prefix().left)
return this.o.tools.filter((t) => !left.has(t.name)).map(toSpec)
}
/** The note for the next user message (§10 rule 3), "" when nothing changed; what it tells is
* 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 ?? "" }), this.o.team?.note?.() ?? [])
if (note) {
kept.told = told
this.saveKept()
}
return note
}
/** Something the model learnt from its own tool's result (a plan it wrote, the mode its
* plan_submit set, a memory or file it changed): told, so no note says it again. */
private learnt(fields: Partial<Told>) {
if (!this.kept) return
Object.assign(this.kept.told, structuredClone(fields))
this.saveKept()
}
/** The conversation was cut back (/undo, a rewind): the notes after the cut may be gone, so what
* the model knows is the prompt again, and the next user message says the rest. */
rewound() {
if (!this.kept || !this.keptBase) return
this.kept.told = structuredClone(this.keptBase)
this.o.team?.forget?.()
this.saveKept()
}
/** A request's usage, kept with the session (cached input tokens included, §10 rule 6). */
private recordUsage(usage: Usage, kind: "step" | "compaction") {
this.lastRequest = { usage, kind }
if (this.o.store && this.sessionId)
try {
this.o.store.request(this.sessionId, { model: this.model.ref, kind, ...usage })
} catch {}
} }
/** A session's results: kept with it in the store, or in memory without one. A stub may point /** A session's results: kept with it in the store, or in memory without one. A stub may point
@@ -164,31 +343,34 @@ export class Engine {
} }
/** Replace the plan (null clears it): kept with the session, and said on the bus so every /** Replace the plan (null clears it): kept with the session, and said on the bus so every
* front end's card follows. */ * front end's card follows. `by`: the model's own tool, whose result shows it the plan; a
setPlan(plan: Plan | null) { * change by the person reaches the model as a note on the next user message. */
setPlan(plan: Plan | null, by: "person" | "model" = "person") {
this.plan = plan ?? undefined this.plan = plan ?? undefined
if (this.o.store && this.sessionId) this.o.store.setMeta(this.sessionId, PLAN_META, plan) if (this.o.store && this.sessionId) this.o.store.setMeta(this.sessionId, PLAN_META, plan)
if (by === "model") this.learnt({ plan: this.plan ? render(this.plan) : "" })
this.o.bus.emit({ type: "plan", plan: this.plan }) this.o.bus.emit({ type: "plan", plan: this.plan })
} }
get effort(): Effort | null { get effort(): Level {
return this._effort return this._effort
} }
/** Said on the bus when it changes: the web UI's chat shows the device's model and effort, /** Said on the bus when it changes: the web UI's chat shows the device's model and effort,
* whoever changed them. */ * whoever changed them. */
set effort(v: Effort | null) { set effort(v: Level) {
if (v === this._effort) return if (v === this._effort) return
this._effort = v this._effort = v
this.o.bus.emit({ type: "model", ref: this.model.ref, effort: v ?? "off", connection: this.model.connectionName }) this.o.bus.emit({ type: "model", ref: this.model.ref, effort: v, connection: this.model.connectionName })
} }
/** Switch model mid-session. The conversation carries over; the effort resets to the new model's. */ /** Switch model mid-session. The conversation carries over; the effort resets to the new model's.
setModel(model: ResolvedModel, client: Client, effort: Effort | null) { * The prompt is made again for it at the next request (prefix(): it was made for another). */
setModel(model: ResolvedModel, client: Client, effort: Level) {
const changed = model.ref !== this.model.ref || effort !== this._effort const changed = model.ref !== this.model.ref || effort !== this._effort
this.model = model this.model = model
this.client = client this.client = client
this._effort = effort this._effort = effort
if (changed) this.o.bus.emit({ type: "model", ref: model.ref, effort: effort ?? "off", connection: model.connectionName }) if (changed) this.o.bus.emit({ type: "model", ref: model.ref, effort, connection: model.connectionName })
} }
/** Start over: a fresh conversation (and session row). Approvals given "for the session" end. */ /** Start over: a fresh conversation (and session row). Approvals given "for the session" end. */
@@ -201,8 +383,12 @@ export class Engine {
this.withdrawn.clear() this.withdrawn.clear()
this.results = this.resultsFor(sessionId) this.results = this.resultsFor(sessionId)
// A new session's plan is whatever the store holds for it: none for a fresh one, the old one // A new session's plan is whatever the store holds for it: none for a fresh one, the old one
// on a resume. // on a resume; its prefix likewise.
this.plan = this.storedPlan() this.plan = this.storedPlan()
this.kept = undefined
this.keptBase = undefined
this.lastRequest = undefined
this.kept = this.storedPrefix()
this.o.bus.emit({ type: "plan", plan: this.plan }) this.o.bus.emit({ type: "plan", plan: this.plan })
this.o.toolCtx.readFiles.clear() this.o.toolCtx.readFiles.clear()
this.o.toolCtx.fileStamps.clear() this.o.toolCtx.fileStamps.clear()
@@ -227,17 +413,17 @@ export class Engine {
} }
} }
/** The summary, asked for as the next turn of this same conversation (harness context.md §7):
* the same system prompt and tools, every message as it is, then tasks/compact.md with an empty
* transcript and previous summary. Nothing of it is shown or stored as a turn; a tool call in the reply is not run. */
private async summarise(signal: AbortSignal): Promise<string> { private async summarise(signal: AbortSignal): Promise<string> {
const previous = this.messages[0]?.role === "user" && this.messages[0].parts[0]?.type === "text" && this.messages[0].parts[0].text.startsWith("The earlier part of this conversation was compacted") // No previous summary in the ask: an earlier one is already the conversation's first message.
? this.messages[0].parts[0].text const ask: Message = { role: "user", parts: [{ type: "text", text: compactAsk() }] }
: ""
const prompt = fill(promptText("tasks/compact.md"), {
previous_summary: previous ? `## Summary of even earlier turns\n\n${previous}` : "",
transcript: transcript(this.messages, 60_000),
})
let summary = "" let summary = ""
for await (const ev of this.client.stream({ system: "", messages: [{ role: "user", parts: [{ type: "text", text: prompt }] }], tools: [], effort: null, signal })) for await (const ev of this.client.stream({ system: this.systemPrompt(), messages: mergeUsers([...this.messages, ask]), tools: this.toolSpecs(), effort: this.effort, signal })) {
if (ev.type === "finish") summary = ev.message.parts.filter((p) => p.type === "text").map((p) => (p as { text: string }).text).join("").trim() if (ev.type === "finish") summary = summaryOf(ev.message.parts)
else if (ev.type === "usage") this.recordUsage(ev.usage, "compaction")
}
if (!summary) throw new Error("the model returned an empty summary") if (!summary) throw new Error("the model returned an empty summary")
// The harness's own table of the reads still in the conversation (context.md §7), after the // The harness's own table of the reads still in the conversation (context.md §7), after the
// summary; the conversation is then the summary alone, so no stub can point at a result before it. // summary; the conversation is then the summary alone, so no stub can point at a result before it.
@@ -247,14 +433,20 @@ export class Engine {
this.messages = summaryTurns(summary) this.messages = summaryTurns(summary)
this.usage = undefined this.usage = undefined
this.usageAt = 0 this.usageAt = 0
// Everything is read again now anyway: the prompt is made again at the next request, and the
// next user message says the date again (what said it is in the summary, if anywhere).
this.kept = undefined
this.keptBase = undefined
this.saveKept()
// Said on the bus: the web UI marks the point in a shared session, as the TUI does. // Said on the bus: the web UI marks the point in a shared session, as the TUI does.
this.o.bus.emit({ type: "compacted", summary }) this.o.bus.emit({ type: "compacted", summary })
return summary return summary
} }
/** Tokens in use now: the last reported usage plus an estimate of what was added since. The /** Tokens in use now: the last reported usage plus an estimate of what was added since. A final
* reply's thinking is left out where the dialect never sends it back (openai-chat): it is in the * reply's thinking is left out: it is in its output, but the next request starts a turn and
* last reply's output but will not be in the next request. */ * sends no earlier turn's reasoning (context.md §8). A reply that called tools keeps it: its turn
* goes on, and its reasoning goes back. */
contextUsed(): number | undefined { contextUsed(): number | undefined {
if (!this.usage) return undefined if (!this.usage) return undefined
return this.usage.input + this.usage.output - this.droppedThinking() + estimateValue(this.messages.slice(this.usageAt)) return this.usage.input + this.usage.output - this.droppedThinking() + estimateValue(this.messages.slice(this.usageAt))
@@ -263,7 +455,7 @@ export class Engine {
private droppedThinking(): number { private droppedThinking(): number {
if (!this.usage) return 0 if (!this.usage) return 0
const reply = this.messages[this.usageAt - 1] const reply = this.messages[this.usageAt - 1]
if (reply?.role !== "assistant") return 0 if (reply?.role !== "assistant" || reply.parts.some((p) => p.type === "tool_call")) return 0
const estimate = reply.parts.reduce((n, p) => n + (p.type === "reasoning" ? estimateTokens(p.text) : 0), 0) const estimate = reply.parts.reduce((n, p) => n + (p.type === "reasoning" ? estimateTokens(p.text) : 0), 0)
return Math.min(this.usage.output, this.usage.reasoning ?? estimate) return Math.min(this.usage.output, this.usage.reasoning ?? estimate)
} }
@@ -293,14 +485,18 @@ export class Engine {
await this.summarise(signal) await this.summarise(signal)
// The task is never left to the summary: the prompt that started this turn comes back word // The task is never left to the summary: the prompt that started this turn comes back word
// for word (found on a real run, where a small model's summary lost the task entirely). // for word (found on a real run, where a small model's summary lost the task entirely).
const original = this.turnPrompt?.role === "user" ? this.turnPrompt.parts : [] // Its note is not: what it said may be out of date, and the new prompt's first user message
// gets a note of its own.
const original = this.turnPrompt?.role === "user" ? this.turnPrompt.parts.filter((p) => p.type !== "text" || !isNote(p.text)) : []
const steers = this.turnSteers.map((s) => ({ type: "text" as const, text: `(Sent while you were working:) ${s}` })) const steers = this.turnSteers.map((s) => ({ type: "text" as const, text: `(Sent while you were working:) ${s}` }))
const note = this.takeNote()
this.push({ this.push({
role: "user", role: "user",
parts: [ parts: [
...original, ...original,
...steers, ...steers,
{ type: "text", text: "(The conversation was compacted to fit the context window; the summary above records what was done so far. This was the request — continue it from where the summary leaves off.)" }, { type: "text", text: "(The conversation was compacted to fit the context window; the summary above records what was done so far. This was the request — continue it from where the summary leaves off.)" },
...(note ? [{ type: "text" as const, text: note }] : []),
], ],
}) })
this.o.bus.emit({ type: "notice", message: "compacted — carrying on from the summary, with your request kept word for word" }) this.o.bus.emit({ type: "notice", message: "compacted — carrying on from the summary, with your request kept word for word" })
@@ -332,23 +528,6 @@ export class Engine {
return undefined return undefined
} }
/** Tools a model can use at all: view_image only with vision, plan_submit only in plan mode
* (`requires: "plan"`; plan_write and plan_update are offered in every mode). */
private offered(name: string): boolean {
const t = this.o.tools.find((x) => x.name === name)
if (name === "task" && !this.o.spawn) return false
if (!t?.requires) return true
if (t.requires === "vision") return this.model.spec.vision === true
if (t.requires === "project") return this.o.toolCtx.projectDir !== undefined
if (t.requires === "display") return this.o.toolCtx.show !== undefined
if (t.requires === "knowledge") {
const L = this.o.toolCtx.library
const have = L?.lib.bases().filter((b) => b.documents > 0).map((b) => b.name) ?? []
return have.some((b) => !L!.bases || L!.bases.includes(b))
}
return this.mode === "plan"
}
cancel() { cancel() {
this.abort?.abort() this.abort?.abort()
} }
@@ -413,7 +592,7 @@ export class Engine {
private deliverInbox(): boolean { private deliverInbox(): boolean {
const notes = this.notes.splice(0) const notes = this.notes.splice(0)
if (notes.length) { if (notes.length) {
this.push({ role: "user", parts: [{ type: "text", text: notes.join("\n\n") }] }) this.push({ role: "user", parts: [{ type: "text", text: notes.join("\n\n") }], turn: false })
// Said as what happened, not as its mark: the line after it. // Said as what happened, not as its mark: the line after it.
this.o.bus.emit({ type: "notice", message: notes.map((n) => n.split("\n").find((l, i) => i > 0 && l.trim()) ?? n.split("\n")[0]!).join("; ") }) this.o.bus.emit({ type: "notice", message: notes.map((n) => n.split("\n").find((l, i) => i > 0 && l.trim()) ?? n.split("\n")[0]!).join("; ") })
} }
@@ -421,7 +600,7 @@ export class Engine {
const texts = this.inbox.splice(0) const texts = this.inbox.splice(0)
const ids = this.inboxIds.splice(0).filter((i): i is string => typeof i === "string") const ids = this.inboxIds.splice(0).filter((i): i is string => typeof i === "string")
this.turnSteers.push(...texts) this.turnSteers.push(...texts)
this.push({ role: "user", parts: [{ type: "text", text: `${this.o.steerMark ?? STEER_MARK}\n${texts.join("\n\n")}` }] }) this.push({ role: "user", parts: [{ type: "text", text: `${this.o.steerMark ?? STEER_MARK}\n${texts.join("\n\n")}` }], turn: false })
this.o.bus.emit({ type: "steered", texts, ...(ids.length ? { ids } : {}) }) this.o.bus.emit({ type: "steered", texts, ...(ids.length ? { ids } : {}) })
this.o.bus.emit({ type: "inbox", texts: [], mode: this.busyInput }) this.o.bus.emit({ type: "inbox", texts: [], mode: this.busyInput })
return true return true
@@ -449,12 +628,26 @@ export class Engine {
const { bus } = this.o const { bus } = this.o
this.abort = new AbortController() this.abort = new AbortController()
const signal = this.abort.signal const signal = this.abort.signal
this.push({ role: "user", parts: [{ type: "text", text }, ...extra.map((t) => (typeof t === "string" ? { type: "text" as const, text: t } : t))], ...(turnId ? { turnId } : {}), ...(machine ? { machine } : {}) }) // 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({
role: "user",
parts: [{ type: "text", text }, ...extra.map((t) => (typeof t === "string" ? { type: "text" as const, text: t } : t)), ...(note ? [{ type: "text" as const, text: note }] : [])],
...(turnId ? { turnId } : {}),
...(machine ? { machine } : {}),
})
this.turnPrompt = this.messages[this.messages.length - 1] this.turnPrompt = this.messages[this.messages.length - 1]
this.turnSteers = [] this.turnSteers = []
this.autoCompactions = 0 this.autoCompactions = 0
const toolMap = new Map(this.o.tools.map((t) => [t.name, t])) const toolMap = new Map(this.o.tools.map((t) => [t.name, t]))
const allSpecs = this.o.tools.map(toSpec)
const maxSteps = this.o.maxSteps ?? LOOP.steps.default const maxSteps = this.o.maxSteps ?? LOOP.steps.default
const started = performance.now() const started = performance.now()
this.turnWaitedMs = 0 this.turnWaitedMs = 0
@@ -486,10 +679,13 @@ export class Engine {
// What was said before a stop: kept, so /continue picks up from there. // What was said before a stop: kept, so /continue picks up from there.
let partial = "" let partial = ""
try { try {
// The same system prompt and tool list on every request of the session (§10).
const stream = this.client.stream({ const stream = this.client.stream({
system: this.o.system(this.mode, this.model, this.plan), 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),
tools: wrapUp ? [] : allSpecs.filter((s) => !this.withdrawn.has(s.name) && this.offered(s.name)), // 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(),
effort: this.effort, effort: this.effort,
signal, signal,
}) })
@@ -533,8 +729,16 @@ export class Engine {
bus.emit({ type: "done", reason: "error" }) bus.emit({ type: "done", reason: "error" })
return "error" return "error"
} }
// The wrap-up's answer is its text before any call: a call there is not run, and none is kept
// without a result to go with it.
if (wrapUp && assistant.parts.some((p) => p.type === "tool_call")) {
const first = assistant.parts.findIndex((p) => p.type === "tool_call")
assistant = { ...assistant, parts: assistant.parts.slice(0, first) }
bus.emit({ type: "notice", message: "the wrap-up reply called a tool; it was not run" })
}
this.push(assistant) this.push(assistant)
if (usage) { if (usage) {
this.recordUsage(usage, "step")
this.turnCompletion += usage.output this.turnCompletion += usage.output
// Counted once the reply is in the history, so the reply itself is not estimated again. // Counted once the reply is in the history, so the reply itself is not estimated again.
this.usage = usage this.usage = usage
@@ -561,7 +765,7 @@ export class Engine {
? `the reply was cut off at the output limit${limit}; asking it to carry on` ? `the reply was cut off at the output limit${limit}; asking it to carry on`
: `the model used the whole output limit${limit} thinking; asking it to act`, : `the model used the whole output limit${limit} thinking; asking it to act`,
}) })
this.push({ role: "user", parts: [{ type: "text", text: !cut ? NO_ANSWER : said ? CUT_TEXT : CUT_THINKING }] }) this.push({ role: "user", parts: [{ type: "text", text: !cut ? NO_ANSWER : said ? CUT_TEXT : CUT_THINKING }], turn: false })
continue continue
} }
bus.emit({ bus.emit({
@@ -608,11 +812,13 @@ export class Engine {
question: this.o.asker.question ? (req, id) => this.waiting(this.o.asker.question!(req, id)) : undefined, question: this.o.asker.question ? (req, id) => this.waiting(this.o.asker.question!(req, id)) : undefined,
plan: { plan: {
get: () => this.plan, get: () => this.plan,
set: (p) => this.setPlan(p), set: (p) => this.setPlan(p, "model"),
ask: this.o.asker.plan ? (req, id) => this.waiting(this.o.asker.plan!(req, id)) : undefined, ask: this.o.asker.plan ? (req, id) => this.waiting(this.o.asker.plan!(req, id)) : undefined,
}, },
setMode: (m) => { setMode: (m) => {
this.mode = m this.mode = m
// Its own plan_submit's result tells the model the mode.
this.learnt({ mode: m })
bus.emit({ type: "mode", mode: m }) bus.emit({ type: "mode", mode: m })
}, },
spawn: this.o.spawn, spawn: this.o.spawn,
@@ -647,6 +853,16 @@ export class Engine {
) )
} }
this.bgNow = undefined this.bgNow = undefined
// A memory or instruction file changed by this step's tools is the model's own doing, which
// their results showed it: no note says it again. Changed between turns, it is the person's.
if (runnable.some((r) => r.writes) && this.o.changing && this.kept) {
const now = this.now()
// Files by their path only: the person's own instructions and an instance's (named by a
// heading) are not something a tool of the model writes.
const told = this.kept.told.files
const files = Object.fromEntries([...Object.entries(told).filter(([k]) => !isAbsolute(k)), ...Object.entries(now.files).filter(([k]) => isAbsolute(k))])
this.learnt({ memory: now.memory, files, ...(runnable.some((r) => r.tool.name === "settings") ? { personality: now.personality } : {}) })
}
const images: ImagePart[] = [] const images: ImagePart[] = []
for (const call of calls) { for (const call of calls) {
const r = results.get(call.id) ?? { output: "Not run: the reply was cancelled.", isError: true } const r = results.get(call.id) ?? { output: "Not run: the reply was cancelled.", isError: true }
@@ -656,7 +872,7 @@ export class Engine {
} }
// No dialect takes an image inside a tool result the same way; a user turn right after the // No dialect takes an image inside a tool result the same way; a user turn right after the
// results works everywhere. // results works everywhere.
if (images.length) this.push({ role: "user", parts: [{ type: "text", text: "The image(s) you asked to view:" }, ...images] }) if (images.length) this.push({ role: "user", parts: [{ type: "text", text: "The image(s) you asked to view:" }, ...images], turn: false })
if (signal.aborted) { if (signal.aborted) {
bus.emit({ type: "done", reason: "cancelled" }) bus.emit({ type: "done", reason: "cancelled" })
return "cancelled" return "cancelled"
@@ -702,14 +918,26 @@ export class Engine {
const resolved = resolveCall(call.name, raw) const resolved = resolveCall(call.name, raw)
tool = toolMap.get(resolved.name) ?? tool tool = toolMap.get(resolved.name) ?? tool
raw = resolved.raw raw = resolved.raw
const team = this.o.team
// Offered for the whole session; while what it needs is not met, the spec's sentence (§10 rule 4).
const no = unavailable(tool.name, tool.requires ? [tool.requires] : undefined, this.met())
if (no) return { result: { output: no, isError: true } }
// Withdrawn after a final refusal: still listed, and the refusal is the answer.
const refused = this.withdrawn.get(tool.name)
if (refused !== undefined) return { result: { output: refused, isError: true }, denied: true }
// What the call is for goes to the user when they are asked; the tool never sees an added `purpose`. // What the call is for goes to the user when they are asked; the tool never sees an added `purpose`.
const { args: own, purpose } = splitPurpose(tool, raw) const { args: own, purpose } = splitPurpose(tool, raw)
const parsed = tool.schema.safeParse(own) const parsed = tool.schema.safeParse(own)
if (!parsed.success) { if (!parsed.success) {
const spoken = tool.invalid?.(own, { ...this.o.toolCtx, signal: new AbortController().signal })
if (spoken) return { result: { output: spoken, isError: true } }
const why = parsed.error.issues.map((i) => `${i.path.join(".") || "(arguments)"}: ${i.message}`).join("; ") const why = parsed.error.issues.map((i) => `${i.path.join(".") || "(arguments)"}: ${i.message}`).join("; ")
return { result: { output: `Invalid arguments for ${tool.name}: ${why}`, isError: true } } return { result: { output: `Invalid arguments for ${tool.name}: ${why}`, isError: true } }
} }
let args = parsed.data let args = parsed.data
// A team's answers come before the permission check — nothing is asked (team.md §6).
const byTeam = team?.refuse(tool, args)
if (byTeam) return { result: { output: byTeam, isError: true } }
const toolCtx: ToolContext = { ...this.o.toolCtx, signal: new AbortController().signal } const toolCtx: ToolContext = { ...this.o.toolCtx, signal: new AbortController().signal }
let request = tool.permission(args, toolCtx) let request = tool.permission(args, toolCtx)
let edited: string | undefined let edited: string | undefined
@@ -746,15 +974,10 @@ export class Engine {
if (reply.kind === "deny" && withdraw) { if (reply.kind === "deny" && withdraw) {
// Every tool behind the same permission goes: edit and write share "edit". // Every tool behind the same permission goes: edit and write share "edit".
const siblings = this.o.tools.filter((t) => t.access === tool.access) const siblings = this.o.tools.filter((t) => t.access === tool.access)
for (const t of siblings) this.withdrawn.add(t.name)
const gone = siblings.map((t) => t.name).join(", ") const gone = siblings.map((t) => t.name).join(", ")
return { const output = `Refused: ${reply.feedback ?? "nobody can approve this"} It will be refused every time in this session: every further call to ${gone} answers this same refusal. Do not look for a way around it. Finish with what you can do without it, and say exactly what you would have done — the change, the command — so the user can do it.`
result: { for (const t of siblings) this.withdrawn.set(t.name, output)
output: `Refused: ${reply.feedback ?? "nobody can approve this"} It will be refused every time in this session, so ${gone} ${gone.includes(",") ? "are" : "is"} no longer offered. Do not look for a way around it. Finish with what you can do without it, and say exactly what you would have done — the change, the command — so the user can do it.`, return { result: { output, isError: true }, denied: true }
isError: true,
},
denied: true,
}
} }
if (reply.kind === "deny") { if (reply.kind === "deny") {
const output = reply.feedback const output = reply.feedback
@@ -790,19 +1013,3 @@ export class Engine {
return { tool, args, paths: request.command === undefined ? (request.paths ?? []) : [], writes: request.class !== "read", edited } return { tool, args, paths: request.command === undefined ? (request.paths ?? []) : [], writes: request.class !== "read", edited }
} }
} }
/** The conversation as plain text for the summariser, newest kept when it is too long. */
export function transcript(messages: Message[], maxChars: number): string {
const lines: string[] = []
for (const m of messages) {
if (m.role === "tool") lines.push(`[${m.name} result${m.isError ? ", error" : ""}]\n${m.content.slice(0, 2000)}`)
else
for (const p of m.parts) {
if (p.type === "text") lines.push(`${m.role === "user" ? "User" : "Assistant"}: ${p.text}`)
else if (p.type === "tool_call") lines.push(`[assistant called ${p.name} ${p.args.slice(0, 400)}]`)
}
}
const all = lines.join("\n\n")
return all.length <= maxChars ? all : "[…earlier turns cut…]\n\n" + all.slice(-maxChars)
}
+157
View File
@@ -0,0 +1,157 @@
// The prefix (harness spec 3.1.0, context.md §10): what the model has already read stays exactly as
// it was, so its server can reuse it. The system prompt and the tool list are made once per session
// and resent byte for byte; what changes during the session goes as a note on the next user
// message. This file holds the rules and the texts, without the engine: what the model was last
// told, what a note says about the difference, which tools are offered, what a call answers when
// its requirement is not met, and what compaction asks for. The engine keeps the state
// (session/engine.ts); the conformance area `prefix` drives these functions directly.
import type { Mode } from "../config/schema.ts"
import { LOOP, TOOL_SPECS } from "../harness.ts"
import { fillPrompt } from "../prompt/fill.ts"
import { fill, promptText } from "../prompt/assemble.ts"
/** The note's texts (loop.json `context.note`). */
export const NOTE = LOOP.context.note
/** What a note says in place of something that is now empty: no plan, no memory, a file removed. */
export const NOTHING = NOTE.empty
/** The session_meta key the prefix is kept under (the prompt, the tools left out, what was told). */
export const PREFIX_META = "prefix"
/** What the system prompt holds that can change during a session: the app reads it as it is now,
* builds the prompt from it, and the engine compares it at every user message. Each is "" (or
* empty) where there is none. */
export interface Changing {
/** The memory block as the prompt shows it. */
memory: string
/** The personality as the prompt shows it. */
personality: string
/** Instruction texts by what names them: an instruction file by its path; the person's own
* instructions and an instance's by their heading. */
files: Record<string, string>
/** MCP servers' instructions, by server. */
mcp: Record<string, string>
/** What the app needs to make the prompt from these same values; never compared or kept. */
source?: unknown
}
export const NO_CHANGING: Changing = { memory: "", personality: "", files: {}, mcp: {} }
/** What the model was last told of each: by the system prompt when it was made, by a note since, or
* by its own tool's result (a plan it wrote, a mode its plan_submit set). `date` is absent until a
* note has said it. */
export interface Told extends Changing {
date?: string
mode: Mode
/** The plan as rendered (plan.ts `render`), or "". */
plan: string
}
/** What is true now, against which a Told is compared. */
export interface Now extends Changing {
date: string
mode: Mode
plan: string
}
/** The prefix a session keeps (session_meta `prefix`): the prompt it sends, the tools left out of
* its list for the whole session (every other tool is offered, MCP tools as their servers
* connect), the model it was made for, and what the model has been told. */
export interface Kept {
system: string
left: string[]
model: string
told: Told
}
/** Told as of a prompt made now: everything as it is, except the date, which no prompt holds. */
export function toldAt(now: Now): Told {
const { date: _date, source: _source, ...rest } = now
return structuredClone(rest)
}
/** 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, 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)
if (told.date !== now.date) say(NOTE.date, { date: now.date })
if (told.mode !== now.mode) say(NOTE.mode, { mode: now.mode, mode_text: modeText(now.mode) })
if (told.plan !== now.plan) say(NOTE.plan, { plan: or(now.plan) })
if (told.memory !== now.memory) say(NOTE.memory, { memory: or(now.memory) })
for (const path of union(told.files, now.files))
if (told.files[path] !== now.files[path]) say(NOTE.instructions, { path, text: or(now.files[path] ?? "") })
if (told.personality !== now.personality) say(NOTE.personality, { personality: or(now.personality) })
// 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 } }
}
/** The block a note is: its lines between the tags, a blank line between two. */
export function noteBlock(lines: string[]): string {
return `${NOTE.open}\n${lines.join("\n\n")}\n${NOTE.close}`
}
/** Whether a text part of a user message is a note (not shown as the person's words). */
export function isNote(text: string): boolean {
return text.startsWith(NOTE.open) && text.trimEnd().endsWith(NOTE.close)
}
/** One MCP server's instructions as the prompt and a note show them. */
export function mcpBlock(name: string, text: string): string {
return `<server name="${name}">\n${text}\n</server>`
}
function union(a: Record<string, string>, b: Record<string, string>): string[] {
return [...new Set([...Object.keys(a), ...Object.keys(b)])]
}
/** Today's date where this runs, as the note says it (YYYY-MM-DD). */
export function today(at = new Date()): string {
const p = (n: number) => String(n).padStart(2, "0")
return `${at.getFullYear()}-${p(at.getMonth() + 1)}-${p(at.getDate())}`
}
/** 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" | "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
* call while one is not met now answers `unavailable`. */
export function offeredFor(requires: readonly Requirement[] | undefined, could: ReadonlySet<Requirement>): boolean {
return (requires ?? []).every((r) => could.has(r))
}
/** What a call answers when a requirement of its tool is not met now: the spec's text, word for
* word, or undefined when it is met (or the tool has no such text). */
export function unavailable(tool: string, requires: readonly Requirement[] | undefined, met: ReadonlySet<Requirement>): string | undefined {
if ((requires ?? []).every((r) => met.has(r))) return undefined
return TOOL_SPECS[tool]?.unavailable
}
/** §7: what the summary is asked with, as one more user message after the conversation —
* tasks/compact.md with `{{transcript}}` and `{{previous_summary}}` empty, since the model has the
* conversation, an earlier summary included, itself. */
export function compactAsk(): string {
return fill(promptText("tasks/compact.md"), { previous_summary: "", transcript: "" })
.replace(/\n{3,}/g, "\n\n")
.trim()
}
/** The summary in a reply to the compaction request: its text before any tool call (a call in it
* is not run), "" when there is none. */
export function summaryOf(parts: { type: string; text?: string }[]): string {
const first = parts.findIndex((p) => p.type === "tool_call")
return (first < 0 ? parts : parts.slice(0, first))
.filter((p) => p.type === "text")
.map((p) => p.text ?? "")
.join("")
.trim()
}
+30 -2
View File
@@ -2,7 +2,8 @@ import { Database } from "bun:sqlite"
import { mkdirSync } from "node:fs" import { mkdirSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import { paths } from "../config/paths.ts" import { paths } from "../config/paths.ts"
import type { Message } from "../provider/types.ts" import type { Message, Usage } from "../provider/types.ts"
import { isNote } from "./prefix.ts"
// Sessions and their messages, in ~/.local/share/lembas/sessions.db. Message text is also // Sessions and their messages, in ~/.local/share/lembas/sessions.db. Message text is also
// indexed with FTS5 so past sessions can be searched (session_search). // indexed with FTS5 so past sessions can be searched (session_search).
@@ -53,6 +54,11 @@ CREATE TABLE IF NOT EXISTS results (
json TEXT NOT NULL, text TEXT NOT NULL, json TEXT NOT NULL, text TEXT NOT NULL,
PRIMARY KEY (session_id, id) PRIMARY KEY (session_id, id)
); );
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
created INTEGER NOT NULL, model TEXT NOT NULL, kind TEXT NOT NULL,
input INTEGER NOT NULL, output INTEGER NOT NULL, reasoning INTEGER, cached INTEGER, estimated INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS session_meta ( CREATE TABLE IF NOT EXISTS session_meta (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (session_id, key) PRIMARY KEY (session_id, key)
@@ -70,7 +76,16 @@ export const CWD_META = "cwd"
export function plainText(m: Message): string { export function plainText(m: Message): string {
if (m.role === "tool") return m.content if (m.role === "tool") return m.content
return m.parts.map((p) => (p.type === "text" || p.type === "reasoning" ? p.text : p.type === "tool_call" ? `${p.name} ${p.args}` : "")).join("\n") // A session note is the harness's, not the person's words: not searched.
return m.parts.map((p) => (p.type === "text" ? (m.role === "user" && isNote(p.text) ? "" : p.text) : p.type === "reasoning" ? p.text : p.type === "tool_call" ? `${p.name} ${p.args}` : "")).join("\n")
}
/** One request's usage as the store keeps it (harness context.md §10 rule 6): what it was for — a
* step of a turn, or a compaction's summary — and the cached input tokens where the server said. */
export interface RequestRow extends Usage {
model: string
kind: "step" | "compaction"
created?: number
} }
export interface Row { export interface Row {
@@ -225,6 +240,19 @@ export class Store {
} }
} }
/** Record one request's usage. */
request(sessionId: string, r: RequestRow) {
this.db
.query("INSERT INTO requests (session_id, created, model, kind, input, output, reasoning, cached, estimated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run(sessionId, Date.now(), r.model, r.kind, r.input, r.output, r.reasoning ?? null, r.cached ?? null, r.estimated ? 1 : 0)
}
/** A session's requests, oldest first. */
requests(sessionId: string): RequestRow[] {
const rows = this.db.query("SELECT created, model, kind, input, output, reasoning, cached, estimated FROM requests WHERE session_id = ? ORDER BY id").all(sessionId) as (Omit<RequestRow, "estimated" | "reasoning" | "cached"> & { estimated: number; reasoning: number | null; cached: number | null })[]
return rows.map(({ estimated, reasoning, cached, ...r }) => ({ ...r, ...(reasoning !== null ? { reasoning } : {}), ...(cached !== null ? { cached } : {}), ...(estimated ? { estimated: true } : {}) }))
}
/** Something a session keeps besides its messages, as JSON. */ /** Something a session keeps besides its messages, as JSON. */
meta<T>(sessionId: string, key: string): T | undefined { meta<T>(sessionId: string, key: string): T | undefined {
const r = this.db.query("SELECT value FROM session_meta WHERE session_id = ? AND key = ?").get(sessionId, key) as { value: string } | null const r = this.db.query("SELECT value FROM session_meta WHERE session_id = ? AND key = ?").get(sessionId, key) as { value: string } | null
+3
View File
@@ -96,6 +96,7 @@ export class Turns {
this.snaps.restore(turn.before, files) this.snaps.restore(turn.before, files)
} }
const messages = this.engine.messages.splice(turn.at) const messages = this.engine.messages.splice(turn.at)
this.engine.rewound()
if (this.store && this.engine.sessionId && turn.lastRow >= turn.firstRow) this.store.setKind(this.engine.sessionId, turn.firstRow, turn.lastRow, "reverted") if (this.store && this.engine.sessionId && turn.lastRow >= turn.firstRow) this.store.setKind(this.engine.sessionId, turn.firstRow, turn.lastRow, "reverted")
this.undone.push({ turn, current, messages }) this.undone.push({ turn, current, messages })
return { prompt: turn.prompt, files } return { prompt: turn.prompt, files }
@@ -129,6 +130,7 @@ export class Turns {
const first = dropped[0]! const first = dropped[0]!
const last = dropped[dropped.length - 1]! const last = dropped[dropped.length - 1]!
this.engine.messages.splice(first.at) this.engine.messages.splice(first.at)
this.engine.rewound()
if (this.store && sessionId && last.lastRow >= first.firstRow) this.store.setKind(sessionId, first.firstRow, last.lastRow, "reverted") if (this.store && sessionId && last.lastRow >= first.firstRow) this.store.setKind(sessionId, first.firstRow, last.lastRow, "reverted")
this.undone = [] this.undone = []
return dropped.map((t) => t.turnId) return dropped.map((t) => t.turnId)
@@ -142,6 +144,7 @@ export class Turns {
this.store.setKind(sessionId, row, Number.MAX_SAFE_INTEGER, "reverted") this.store.setKind(sessionId, row, Number.MAX_SAFE_INTEGER, "reverted")
const at = this.engine.messages.findIndex((m) => m.role === "user" && m.turnId === turnId) const at = this.engine.messages.findIndex((m) => m.role === "user" && m.turnId === turnId)
if (at >= 0) this.engine.messages.splice(at) if (at >= 0) this.engine.messages.splice(at)
this.engine.rewound()
this.done = [] this.done = []
this.undone = [] this.undone = []
return ids return ids
+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 { function find(ctx: ToolContext, id: string): Job {
const { registry, owner } = jobsOf(ctx) const { registry, owner } = jobsOf(ctx)
const job = registry.get(owner().sessionId, id.trim()) const job = registry.get(owner().sessionId, id.trim())
if (!job) throw new ToolError(`No background job ${id}. job_list shows this session's jobs.`) // A team member is not a job to the model: the team tools take it (team.md §4).
if (!job || job.team) throw new ToolError(`No background job ${id}. job_list shows this session's jobs.`)
return job return job
} }
@@ -42,7 +43,7 @@ export const jobListTool = defineTool({
permission: () => ({ permission: "job_list", class: "read", patterns: ["*"] }), permission: () => ({ permission: "job_list", class: "read", patterns: ["*"] }),
async run(_a, ctx) { async run(_a, ctx) {
const { registry, owner } = jobsOf(ctx) const { registry, owner } = jobsOf(ctx)
const jobs = registry.list(owner().sessionId) const jobs = registry.list(owner().sessionId).filter((j) => !j.team)
if (!jobs.length) return { output: "No background jobs.", title: "0 jobs" } if (!jobs.length) return { output: "No background jobs.", title: "0 jobs" }
const now = Date.now() const now = Date.now()
const lines = jobs.map((j) => { const lines = jobs.map((j) => {
@@ -101,6 +102,9 @@ export const jobSendTool = defineTool({
job.subagent.send(a.text) job.subagent.send(a.text)
return { output: `Sent to ${job.id} (${whatWords(job)}); it is given to it at its next step.`, title: `${job.id} · message sent` } return { output: `Sent to ${job.id} (${whatWords(job)}); it is given to it at its next step.`, title: `${job.id} · message sent` }
} }
// Starting a finished helper again counts toward a team's size as task does (team.md §3).
const no = ctx.team?.runtime.team.taskRefusal()
if (no) throw new ToolError(no)
registry.restartSubagent(job) registry.restartSubagent(job)
void job.subagent.restart(a.text) void job.subagent.restart(a.text)
return { output: `${job.id} (${whatWords(job)}) is running again with your message as its new prompt, its context intact. Its report comes to you when it ends.`, title: `${job.id} · started again` } return { output: `${job.id} (${whatWords(job)}) is running again with your message as its new prompt, its context intact. Its report comes to you when it ends.`, title: `${job.id} · started again` }
@@ -114,6 +118,12 @@ export const reportTool = defineTool({
schema: z.object({ text: z.string().min(1) }), schema: z.object({ text: z.string().min(1) }),
permission: () => ({ permission: "report", class: "interact", patterns: ["*"] }), permission: () => ({ permission: "report", class: "interact", patterns: ["*"] }),
async run(a, ctx) { async run(a, ctx) {
// A team member's report goes through the room, to its manager (team.md §4).
if (ctx.team && ctx.team.as !== "lead") {
const sent = ctx.team.runtime.team.message(ctx.team.as, a.text)
ctx.team.runtime.notify()
if (sent) return { output: `${sent} An answer, if one comes, arrives as a message to you; carry on meanwhile.`, title: `message to ${ctx.team.runtime.team.get(ctx.team.as)?.manager ?? "your manager"}` }
}
const { registry, owner } = jobsOf(ctx) const { registry, owner } = jobsOf(ctx)
const job = owner().job const job = owner().job
if (!job) throw new ToolError("report is for a subagent: you are the main agent, so write to the user directly.") if (!job) throw new ToolError("report is for a subagent: you are the main agent, so write to the user directly.")
+2
View File
@@ -32,6 +32,8 @@ export function resolveCall(name: string, raw: unknown): { name: string; raw: un
const plan = fromTodos(obj.todos) const plan = fromTodos(obj.todos)
args = isPlan(plan) ? { title: plan.title, phases: plan.phases.map((p) => ({ title: p.title, status: p.status, tasks: p.tasks.map((t) => ({ text: t.text, status: t.status })) })) } : { title: "Work", phases: [] } args = isPlan(plan) ? { title: plan.title, phases: plan.phases.map((p) => ({ title: p.title, status: p.status, tasks: p.tasks.map((t) => ({ text: t.text, status: t.status })) })) } : { title: "Work", phases: [] }
} }
// team_send's `to` as one name: read as a list of one (team.md §5).
if (to === "team_send" && obj && typeof obj.to === "string") args = { ...obj, to: [obj.to] }
const renamed = ARGUMENTS_RENAMED[to] const renamed = ARGUMENTS_RENAMED[to]
if (renamed && args && typeof args === "object" && !Array.isArray(args)) { if (renamed && args && typeof args === "object" && !Array.isArray(args)) {
const a = { ...(args as Record<string, unknown>) } const a = { ...(args as Record<string, unknown>) }
+3 -2
View File
@@ -1,5 +1,6 @@
// plan_submit: the plan written with plan_write, presented for approval — plan mode only, where // plan_submit: the plan written with plan_write, presented for approval — in plan mode, where it is
// it is the approval. The plan itself is the session's (src/plan/plan.ts); a copy goes under the // the approval. Offered in every mode (spec 3.1.0, `requires: plan`): outside plan mode the engine
// answers the spec's `unavailable` text and nothing runs. The plan itself is the session's (src/plan/plan.ts); a copy goes under the
// project's plans directory for the record, since that is the one place plan mode may write. // project's plans directory for the record, since that is the one place plan mode may write.
import { mkdirSync, writeFileSync } from "node:fs" import { mkdirSync, writeFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
+6
View File
@@ -11,6 +11,7 @@ import { planSubmitTool } from "./plan_submit.ts"
import { planUpdateTool } from "./plan_update.ts" import { planUpdateTool } from "./plan_update.ts"
import { planWriteTool } from "./plan_write.ts" import { planWriteTool } from "./plan_write.ts"
import { taskTool } from "./task.ts" import { taskTool } from "./task.ts"
import { teamAddTool, teamReadTool, teamSendTool, teamStopTool } from "./team_tools.ts"
import { decisionsTool, tasksTool } from "./project_tools.ts" import { decisionsTool, tasksTool } from "./project_tools.ts"
import { webFetchTool, webSearchTool } from "./web.ts" import { webFetchTool, webSearchTool } from "./web.ts"
import { memoryTool } from "./memory.ts" import { memoryTool } from "./memory.ts"
@@ -40,6 +41,11 @@ export const BUILTIN_TOOLS: Tool[] = [
webSearchTool, webSearchTool,
webFetchTool, webFetchTool,
taskTool, taskTool,
// Spec 3.2.0: a team (team.md), offered where the model serves two requests at once.
teamAddTool,
teamSendTool,
teamReadTool,
teamStopTool,
planWriteTool, planWriteTool,
planUpdateTool, planUpdateTool,
tasksTool, tasksTool,
+1 -1
View File
@@ -82,7 +82,7 @@ export const grepTool = defineTool({
if (a.literal) args.push("-F") if (a.literal) args.push("-F")
if (a.include) args.push("--glob", a.include) if (a.include) args.push("--glob", a.include)
args.push("-e", a.pattern, target) args.push("-e", a.pattern, target)
const r = await run(args, ctx.cwd, ctx.signal) const r = await run(args, ctx.root, ctx.signal)
if (r.code > 1) throw new ToolError(r.err.trim() || `rg exited ${r.code}`) if (r.code > 1) throw new ToolError(r.err.trim() || `rg exited ${r.code}`)
for (const l of r.out.split("\n")) if (l) lines.push(l) for (const l of r.out.split("\n")) if (l) lines.push(l)
} else { } else {
+3 -1
View File
@@ -7,6 +7,7 @@ import type { Message } from "../provider/types.ts"
import type { Row, SessionRow, Store } from "../session/store.ts" import type { Row, SessionRow, Store } from "../session/store.ts"
import { defineTool, ToolError, type ToolContext } from "./tool.ts" import { defineTool, ToolError, type ToolContext } from "./tool.ts"
import { described } from "../harness.ts" import { described } from "../harness.ts"
import { isNote } from "../session/prefix.ts"
/** A user's words made safe for FTS5 MATCH: quoted phrases kept, operators' punctuation dropped, /** A user's words made safe for FTS5 MATCH: quoted phrases kept, operators' punctuation dropped,
* terms with dots or hyphens quoted (FTS5 would read them as syntax). */ * terms with dots or hyphens quoted (FTS5 would read them as syntax). */
@@ -35,7 +36,8 @@ export function readable(json: string): { role: string; text: string } {
const m = JSON.parse(json) as Message const m = JSON.parse(json) as Message
if (m.role === "tool") return { role: "tool", text: `[${m.name} result, ${m.content.length} chars]` } if (m.role === "tool") return { role: "tool", text: `[${m.name} result, ${m.content.length} chars]` }
const text = m.parts const text = m.parts
.map((p) => (p.type === "text" ? p.text : p.type === "tool_call" ? `[called ${p.name}]` : p.type === "image" ? "[image]" : "")) // A session note is the harness's, not what was said.
.map((p) => (p.type === "text" ? (isNote(p.text) ? "" : p.text) : p.type === "tool_call" ? `[called ${p.name}]` : p.type === "image" ? "[image]" : ""))
.filter(Boolean) .filter(Boolean)
.join("\n") .join("\n")
return { role: m.role, text } return { role: m.role, text }
+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.`) if (size > SHOW_MAX_BYTES) throw new ToolError(`${showPath(abs, ctx)} is ${Math.round(size / 1024)} KB; at most 1 MB can be shown. Give the user the path instead.`)
const text = await readText(abs) const text = await readText(abs)
if (text.includes("\u0000")) throw new ToolError(`${showPath(abs, ctx)} is not a text file; it cannot be shown. Give the user the path instead.`) if (text.includes("\u0000")) throw new ToolError(`${showPath(abs, ctx)} is not a text file; it cannot be shown. Give the user the path instead.`)
const rel = relative(ctx.cwd, abs) || basename(abs) const rel = relative(ctx.root, abs) || basename(abs)
const reply = await ctx.show({ path: rel, ...showKind(abs), ...(a.line ? { line: a.line } : {}), text }, ctx.callId) const reply = await ctx.show({ path: rel, ...showKind(abs), ...(a.line ? { line: a.line } : {}), text }, ctx.callId)
if (!reply.shown) return { output: `Not shown: ${reply.reason}`, isError: true } if (!reply.shown) return { output: `Not shown: ${reply.reason}`, isError: true }
return { output: `Shown to the user: ${showPath(abs, ctx)}${a.line ? ` at line ${a.line}` : ""}.`, title: showPath(abs, ctx) } return { output: `Shown to the user: ${showPath(abs, ctx)}${a.line ? ` at line ${a.line}` : ""}.`, title: showPath(abs, ctx) }
+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 }
},
})
+21 -7
View File
@@ -9,7 +9,8 @@ import { fillParameters, PURPOSE_DEF, TOOL_SPECS } from "../harness.ts"
export interface ToolContext { export interface ToolContext {
/** Absolute project root. */ /** Absolute project root. */
root: string root: string
/** Absolute working directory for relative paths and shells. */ /** Where the session was started (absolute): it ends the instruction chain and is told to the
* model relative to the root. Relative paths and the shell are the root's (context.md §11). */
cwd: string cwd: string
signal: AbortSignal signal: AbortSignal
/** Absolute paths read in this session — `edit`/`write` of an existing file requires one. */ /** Absolute paths read in this session — `edit`/`write` of an existing file requires one. */
@@ -35,6 +36,9 @@ export interface ToolContext {
* (the TUI's ctrl+b): bash hands it to the registry as it would at its timeout. Set by the * (the TUI's ctrl+b): bash hands it to the registry as it would at its timeout. Set by the
* engine for the step's calls. */ * engine for the step's calls. */
backgroundNow?: AbortSignal backgroundNow?: AbortSignal
/** The team this agent is on (spec 3.2.0, team/runtime.ts) and its name there (`lead`, or a
* member's). Absent where no team is offered. */
team?: { runtime: import("../team/runtime.ts").TeamRuntime; as: string }
/** The id of the call being run (set by the engine per call). */ /** The id of the call being run (set by the engine per call). */
callId?: string callId?: string
/** The project directory (.agent), when the project has one. */ /** The project directory (.agent), when the project has one. */
@@ -90,12 +94,16 @@ export interface SpawnOptions {
description?: string description?: string
model?: string model?: string
background?: boolean background?: boolean
/** Spec 3.2.0: a team member (team.md §4) — run as one, not as a task helper. */
member?: import("../team/team.ts").TeamAgent
} }
/** What the task tool gets back from spawn: the job's id and the report when it ends. */ /** What the task tool gets back from spawn: the job's id and the report when it ends. */
export interface SubagentHandleInfo { export interface SubagentHandleInfo {
id: string id: string
done: Promise<import("./task.ts").SubagentResult> done: Promise<import("./task.ts").SubagentResult>
/** A team member's engine, for the team's runtime to drive. */
member?: import("../team/runtime.ts").MemberHandle
} }
export interface ToolResult { export interface ToolResult {
@@ -118,10 +126,12 @@ export interface Tool<S extends z.ZodType = z.ZodType> {
access: string access: string
/** Runs alone, never alongside other calls of the same step (it waits on the user). */ /** Runs alone, never alongside other calls of the same step (it waits on the user). */
exclusive?: boolean exclusive?: boolean
/** Offered only when this holds: a model with vision, plan mode, a project directory, knowledge /** What the tool needs: a model with vision, plan mode, a project directory, knowledge bases with
* bases with documents, or a display (a client over the link that shows files: the spec's * documents, or a display (a client over the link that shows files: the spec's `requires:
* `requires: display`). */ * display`). The tool list is the session's (harness context.md §10): one the session could
requires?: "vision" | "plan" | "project" | "knowledge" | "display" * 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" | "team"
description: string description: string
schema: S schema: S
/** The JSON Schema sent to the model, when it does not come from `schema` (MCP tools). */ /** The JSON Schema sent to the model, when it does not come from `schema` (MCP tools). */
@@ -133,6 +143,9 @@ export interface Tool<S extends z.ZodType = z.ZodType> {
* `purpose` argument added to every tool (the default), a field of the tool's own (bash's * `purpose` argument added to every tool (the default), a field of the tool's own (bash's
* `description`), or false for a tool that never asks. */ * `description`), or false for a tool that never asks. */
purpose?: string | false purpose?: string | false
/** What a call answers when its arguments do not parse, where the spec has its own words for it
* (team_add's name); undefined: the generic answer. */
invalid?(raw: unknown, ctx: ToolContext): string | undefined
permission(args: z.infer<S>, ctx: ToolContext): PermissionRequest permission(args: z.infer<S>, ctx: ToolContext): PermissionRequest
/** What the call would change, as a unified diff, shown when the user is asked. Writes nothing. */ /** What the call would change, as a unified diff, shown when the user is asked. Writes nothing. */
preview?(args: z.infer<S>, ctx: ToolContext): Promise<string> preview?(args: z.infer<S>, ctx: ToolContext): Promise<string>
@@ -187,12 +200,13 @@ export async function readText(abs: string): Promise<string> {
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(await Bun.file(abs).bytes()) return new TextDecoder("utf-8", { ignoreBOM: true }).decode(await Bun.file(abs).bytes())
} }
/** A tool's path argument, absolute. Models send both absolute and relative paths. */ /** A tool's path argument, absolute. Models send both absolute and relative paths; a relative one
* is read against the project root, wherever the session was started (harness context.md §11). */
export function absPath(p: string, ctx: ToolContext): string { export function absPath(p: string, ctx: ToolContext): string {
// A model writes ~/x as a shell would; without this it becomes a directory named "~". // A model writes ~/x as a shell would; without this it becomes a directory named "~".
if (p === "~" || p.startsWith("~/")) return resolve(expandHome(p)) if (p === "~" || p.startsWith("~/")) return resolve(expandHome(p))
if (isAbsolute(p)) return resolve(p) if (isAbsolute(p)) return resolve(p)
const rel = resolve(ctx.cwd, p) const rel = resolve(ctx.root, p)
// Small models often drop the leading "/" of an absolute path ("home/me/proj/a.py"). When the // Small models often drop the leading "/" of an absolute path ("home/me/proj/a.py"). When the
// relative reading does not exist and the absolute one does, inside the project, take that. // relative reading does not exist and the absolute one does, inside the project, take that.
if (!existsSync(rel)) { if (!existsSync(rel)) {
+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 { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
import { stricterMode, type App as Runtime } from "../app.ts" import { stricterMode, type App as Runtime } from "../app.ts"
import { isLocal } from "../config/paths.ts" import { isLocal } from "../config/paths.ts"
import type { Effort, Mode } from "../config/schema.ts" import type { Mode } from "../config/schema.ts"
import { levelLabel, type Level } from "../provider/effort.ts"
import { teamLabel } from "../session/commands.ts"
import { COMMANDS, findCommand, type CommandCtx } from "./commands.ts" import { COMMANDS, findCommand, type CommandCtx } from "./commands.ts"
import { Dialog, type DialogSpec } from "./components/dialog.tsx" import { Dialog, type DialogSpec } from "./components/dialog.tsx"
import { Messages } from "./components/messages.tsx" import { Messages } from "./components/messages.tsx"
@@ -58,7 +60,9 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
const refresh = () => setRev((n) => n + 1) const refresh = () => setRev((n) => n + 1)
const mode = () => (rev(), app.engine.mode) const mode = () => (rev(), app.engine.mode)
const model = () => (rev(), app.engine.model.ref) const model = () => (rev(), app.engine.model.ref)
const effort = () => (rev(), app.engine.effort ?? "") const effort = () => (rev(), levelLabel(app.engine.model, app.engine.effort))
// "team 3/4 · 2 working" while the lead has members (team.md, the TUI's status).
const teamWord = () => (rev(), teamLabel(app.team.team))
const busy = () => view.state.phase !== "idle" const busy = () => view.state.phase !== "idle"
const branch = () => (rev(), app.project.gitRoot ? currentBranch(app.project.gitRoot) : undefined) const branch = () => (rev(), app.project.gitRoot ? currentBranch(app.project.gitRoot) : undefined)
// A word in the status bar that goes by itself (a copy): it says something happened without // A word in the status bar that goes by itself (a copy): it says something happened without
@@ -73,7 +77,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
onCleanup(() => clearTimeout(flashTimer)) onCleanup(() => clearTimeout(flashTimer))
// A mode changed inside a turn (a plan approved) must reach the status bar; so must a branch the // A mode changed inside a turn (a plan approved) must reach the status bar; so must a branch the
// model switched with git. // model switched with git.
app.bus.on((e) => (e.type === "mode" || e.type === "done") && refresh()) app.bus.on((e) => (e.type === "mode" || e.type === "done" || e.type === "team") && refresh())
app.bus.on((e) => e.type === "flash" && flash(e.text, 6000)) app.bus.on((e) => e.type === "flash" && flash(e.text, 6000))
// A setting changed by the agent, /settings or a command: show it. Theme and icons are drawn here. // A setting changed by the agent, /settings or a command: show it. Theme and icons are drawn here.
app.bus.on((e) => { app.bus.on((e) => {
@@ -223,8 +227,8 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
setMode(m: Mode) { setMode(m: Mode) {
app.settings.set("mode", m) app.settings.set("mode", m)
}, },
setEffort(e: Effort | null) { setEffort(e: Level) {
app.settings.set("effort", e ?? "off") app.settings.set("effort", e)
}, },
setTheme(name: string) { setTheme(name: string) {
try { try {
@@ -246,7 +250,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
confirmStop("reload", () => { confirmStop("reload", () => {
// Back on this session, with what it was using; everything else comes from the files again. // Back on this session, with what it was using; everything else comes from the files again.
const e = app.engine const e = app.engine
const args = [...(isLocal() ? ["-l"] : []), ...(e.sessionId ? ["-s", e.sessionId] : []), "-m", e.model.ref, "--mode", e.mode, "-e", e.effort ?? "off"] const args = [...(isLocal() ? ["-l"] : []), ...(e.sessionId ? ["-s", e.sessionId] : []), "-m", e.model.ref, "--mode", e.mode, "-e", e.effort]
void app void app
.close() .close()
.catch(() => {}) .catch(() => {})
@@ -402,7 +406,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
const id = view.shellStart(command) const id = view.shellStart(command)
const t0 = performance.now() const t0 = performance.now()
bashTool bashTool
.run({ command }, { ...app.engine.o.toolCtx, signal: new AbortController().signal, bashTimeline: false }) .run({ command, cwd: app.engine.o.toolCtx.cwd }, { ...app.engine.o.toolCtx, signal: new AbortController().signal, bashTimeline: false })
.then((r) => { .then((r) => {
view.shellEnd(id, r, performance.now() - t0) view.shellEnd(id, r, performance.now() - t0)
app.engine.pendingContext.push(`<shell command="${command.replaceAll('"', "'")}">\n${r.output}\n</shell>`) app.engine.pendingContext.push(`<shell command="${command.replaceAll('"', "'")}">\n${r.output}\n</shell>`)
@@ -641,7 +645,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
</Show> </Show>
<Queued /> <Queued />
<Prompt focused={() => !dialog() && !jobPanel() && !view.state.pending && !view.state.question && !view.state.plan} busy={busy} commands={palette} files={files} onSubmit={submit} ref={(r) => (input = r)} queue={queueAccess} onWheel={(dir, lines) => scroll?.scrollBy(dir * lines)} onBackground={backgroundNow} /> <Prompt focused={() => !dialog() && !jobPanel() && !view.state.pending && !view.state.question && !view.state.plan} busy={busy} commands={palette} files={files} onSubmit={submit} ref={(r) => (input = r)} queue={queueAccess} onWheel={(dir, lines) => scroll?.scrollBy(dir * lines)} onBackground={backgroundNow} />
<StatusBar mode={mode} model={model} effort={effort} branch={branch()} voice={voiceStatus} flash={flashText} remote={() => (rev(), share.label())} spend={spend} /> <StatusBar mode={mode} model={model} effort={effort} branch={branch()} voice={voiceStatus} flash={flashText} remote={() => (rev(), share.label())} spend={spend} team={teamWord} />
</box> </box>
<Show when={dialog()}> <Show when={dialog()}>
<Dialog spec={dialog()!} close={() => setDialog(undefined)} /> <Dialog spec={dialog()!} close={() => setDialog(undefined)} />
+80 -16
View File
@@ -3,15 +3,15 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs" import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import { paths } from "../config/paths.ts" import { paths } from "../config/paths.ts"
import { asMode, EFFORTS, MODES, type Effort, type Mode } from "../config/schema.ts" import { asMode, MODES, type Mode } from "../config/schema.ts"
import { effortsFor } from "../provider/effort.ts" import { levelsFor, type Level } from "../provider/effort.ts"
import { setTrust } from "../project/root.ts" import { setTrust } from "../project/root.ts"
import { commit, commitPlan, draftMessage } from "../git/commit.ts" import { commit, commitPlan, draftMessage } from "../git/commit.ts"
import { doRelease, planRelease } from "../git/release.ts" import { doRelease, planRelease } from "../git/release.ts"
import { instructionFiles } from "../prompt/assemble.ts" import { instructionFiles } from "../prompt/assemble.ts"
import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts" import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts"
import { contextBreakdown, renderBreakdown } from "../session/context.ts" import { contextBreakdown, renderBreakdown } from "../session/context.ts"
import { changelogPrompt, initPrompt, reviewPrompt } from "../session/commands.ts" import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/commands.ts"
import { git } from "../git/run.ts" import { git } from "../git/run.ts"
import { branches, deleteBranch, switchBranch } from "../git/branch.ts" import { branches, deleteBranch, switchBranch } from "../git/branch.ts"
import type { Ctx } from "./context.ts" import type { Ctx } from "./context.ts"
@@ -47,7 +47,7 @@ export interface CommandCtx extends Ctx {
sendAs?(prompt: string, shown: string, opts?: { model?: string; mode?: string; attachFrom?: string }): void sendAs?(prompt: string, shown: string, opts?: { model?: string; mode?: string; attachFrom?: string }): void
openDialog(spec: DialogSpec): void openDialog(spec: DialogSpec): void
setMode(m: Mode): void setMode(m: Mode): void
setEffort(e: Effort | null): void setEffort(e: Level): void
setTheme(name: string): void setTheme(name: string): void
setIcons(set: import("./theme.ts").IconSet): void setIcons(set: import("./theme.ts").IconSet): void
/** A short word in the status bar that goes by itself. */ /** A short word in the status bar that goes by itself. */
@@ -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. 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[] = [ export const COMMANDS: Command[] = [
{ {
name: "agents", name: "agents",
@@ -171,7 +192,7 @@ export const COMMANDS: Command[] = [
options: refs.map((r) => { options: refs.map((r) => {
const known = c.app.loaded.refs.find((x) => x.ref === r)! const known = c.app.loaded.refs.find((x) => x.ref === r)!
const spec = c.app.loaded.connections[known.connection]!.models[known.id]! const spec = c.app.loaded.connections[known.connection]!.models[known.id]!
const bits = [spec.context ? `${Math.round(spec.context / 1024)}k ctx` : "", spec.vision ? "vision" : "", spec.efforts ? "effort" : ""].filter(Boolean).join(" · ") const bits = [spec.context ? `${Math.round(spec.context / 1024)}k ctx` : "", spec.vision ? "vision" : "", levelsFor(c.app.engine.model.ref === r ? c.app.engine.model : { ...c.app.engine.model, ref: r, spec }).control === "switch" ? "thinking switch" : spec.efforts?.length ? "effort" : ""].filter(Boolean).join(" · ")
return { label: r, value: r, description: bits, current: r === c.app.engine.model.ref } return { label: r, value: r, description: bits, current: r === c.app.engine.model.ref }
}), }),
onSelect: pick, onSelect: pick,
@@ -180,23 +201,65 @@ export const COMMANDS: Command[] = [
}, },
{ {
name: "effort", name: "effort",
args: `[${EFFORTS.join("|")}|off]`, args: "[level|on|off]",
description: "reasoning effort for this model", description: "how hard the model thinks: its levels, a Thinking switch, or nothing to choose",
run(c, arg) { run(c, arg) {
const allowed = effortsFor(c.app.engine.model) // The control follows the model's levels (harness models/schema.json `levels`): one level —
// nothing to choose; [off, X] — a Thinking switch; more — a list.
const m = c.app.engine.model
const { levels, control } = levelsFor(m)
const on = levels.find((l) => l !== "off")!
const set = (v: string) => { const set = (v: string) => {
if (v === "off") return c.setEffort(null) if (control === "switch" && (v === "on" || v === "thinking")) v = on
if (!allowed.includes(v as Effort)) return c.view.info(`${c.app.engine.model.ref} takes ${allowed.join(", ")} or off`, "warn") if (!(levels as string[]).includes(v)) return c.view.info(control === "switch" ? `${m.ref} has a Thinking switch: /effort on or /effort off` : `${m.ref} offers ${levels.join(", ")}`, "warn")
c.setEffort(v as Effort) c.setEffort(v as Level)
c.view.info(control === "switch" ? `thinking ${v === "off" ? "off" : `on (${v})`}` : `effort: ${v}`)
} }
// The 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) if (arg) return set(arg)
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({ c.openDialog({
title: `Effort — ${c.app.engine.model.ref}`, title: `Effort — ${m.ref}`,
options: [...allowed, "off"].map((e) => ({ label: e, value: e, current: (c.app.engine.effort ?? "off") === e })), options: [...levels.map((e) => ({ label: e === "off" ? "off (no thinking)" : e, value: e, current: c.app.engine.effort === e })), ...agentsRow],
onSelect: set, 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()
},
},
{ {
name: "mode", name: "mode",
args: `[${MODES.join("|")}]`, args: `[${MODES.join("|")}]`,
@@ -627,7 +690,8 @@ export const COMMANDS: Command[] = [
description: "where the context window goes", description: "where the context window goes",
run(c) { run(c) {
const e = c.app.engine const e = c.app.engine
const system = e.o.system(e.mode, e.model) // The prompt the session sends (made now if no request has gone yet).
const system = e.systemPrompt()
const git = /<git>\n([\s\S]*?)\n<\/git>/.exec(system)?.[1] const git = /<git>\n([\s\S]*?)\n<\/git>/.exec(system)?.[1]
c.view.info(renderBreakdown(contextBreakdown(e, system, instructionFiles(e.o.toolCtx.cwd, c.app.project.root, c.app.loaded.instructions), git, e.o.tools))) c.view.info(renderBreakdown(contextBreakdown(e, system, instructionFiles(e.o.toolCtx.cwd, c.app.project.root, c.app.loaded.instructions), git, e.o.tools)))
}, },
@@ -933,7 +997,7 @@ export const COMMANDS: Command[] = [
e.def.kind === "model" e.def.kind === "model"
? c.app.modelRefs() ? c.app.modelRefs()
: e.def.kind === "effort" : e.def.kind === "effort"
? [...effortsFor(c.app.engine.model), "off"] ? [...levelsFor(c.app.engine.model).levels]
: e.def.kind === "boolean" : e.def.kind === "boolean"
? ["true", "false"] ? ["true", "false"]
: key === "theme" : key === "theme"
+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 * for a moment, a flash (a copy) in their place. The jobs word sits on the right, which is never
* truncated: at 80 columns a long project name cuts the left short, and a running job is the one * truncated: at 80 columns a long project name cuts the left short, and a running job is the one
* thing that must stay visible. */ * thing that must stay visible. */
export function StatusBar(p: { mode: () => string; model: () => string; effort: () => string; branch?: string; voice?: () => string; flash?: () => string | undefined; remote?: () => string; spend?: () => string }) { export function StatusBar(p: { mode: () => string; model: () => string; effort: () => string; branch?: string; voice?: () => string; flash?: () => string | undefined; remote?: () => string; spend?: () => string; team?: () => string }) {
const { theme, view, app } = useTui() const { theme, view, app } = useTui()
const c = () => theme().colors const c = () => theme().colors
const modeColor = () => ({ manual: c().statusText, edit: c().statusGood, auto: c().statusCritical, plan: c().info })[p.mode() as keyof typeof MODE_LABEL] ?? c().statusText const modeColor = () => ({ manual: c().statusText, edit: c().statusGood, auto: c().statusCritical, plan: c().info })[p.mode() as keyof typeof MODE_LABEL] ?? c().statusText
@@ -50,6 +50,7 @@ export function StatusBar(p: { mode: () => string; model: () => string; effort:
when={p.flash?.()} when={p.flash?.()}
fallback={ fallback={
<text wrapMode="none"> <text wrapMode="none">
<span style={{ fg: c().info }}>{p.team?.() ? `${p.team()} · ` : ""}</span>
<span style={{ fg: jobsWord(view.state.jobs).endsWith("!") ? c().statusWarn : c().statusText }}>{jobsWord(view.state.jobs) ? `${jobsWord(view.state.jobs)} · ` : ""}</span> <span style={{ fg: jobsWord(view.state.jobs).endsWith("!") ? c().statusWarn : c().statusText }}>{jobsWord(view.state.jobs) ? `${jobsWord(view.state.jobs)} · ` : ""}</span>
<span style={{ fg: c().statusDim }}>{view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""}</span> <span style={{ fg: c().statusDim }}>{view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""}</span>
<span style={{ fg: c().statusText }}>{p.spend?.() ? `${p.spend()} · ` : ""}</span> <span style={{ fg: c().statusText }}>{p.spend?.() ? `${p.spend()} · ` : ""}</span>
+4 -1
View File
@@ -16,6 +16,7 @@ import type { JobInfo } from "../jobs/registry.ts"
import { splitPrototypes, titleFor, type PrototypeKind } from "../prototype/fence.ts" import { splitPrototypes, titleFor, type PrototypeKind } from "../prototype/fence.ts"
import { savePrototype } from "../prototype/store.ts" import { savePrototype } from "../prototype/store.ts"
import { CHARS_PER_TOKEN } from "../provider/tokens.ts" import { CHARS_PER_TOKEN } from "../provider/tokens.ts"
import { isNote } from "../session/prefix.ts"
export type Item = export type Item =
/** `mid`: sent while the agent worked, and given to it at a step boundary. `machine`: the harness /** `mid`: sent while the agent worked, and given to it at a step boundary. `machine`: the harness
@@ -121,7 +122,9 @@ export interface ViewState {
jobs: TrackedJob[] jobs: TrackedJob[]
} }
const ATTACHMENT = /^<(file|directory|shell|image) / // What the model was given beside the words typed — attachments, and a session note
// (session/prefix.ts) — is not shown as typed.
const ATTACHMENT = { test: (t: string) => /^<(file|directory|shell|image) /.test(t) || isNote(t) }
/** Running first, then by start — the registry's order. */ /** Running first, then by start — the registry's order. */
const byRunning = (a: JobInfo, b: JobInfo) => Number(b.state === "running") - Number(a.state === "running") || a.started - b.started const byRunning = (a: JobInfo, b: JobInfo) => Number(b.state === "running") - Number(a.state === "running") || a.started - b.started
let seq = 0 let seq = 0
+15 -7
View File
@@ -360,14 +360,21 @@ test("the context reaches the prompt: the instance's instructions, its personali
// names nothing to the model — nothing unfilled reaches it either. // names nothing to the model — nothing unfilled reaches it either.
expect(system()).not.toContain("Manuals") expect(system()).not.toContain("Manuals")
expect(system()).not.toContain("{{") expect(system()).not.toContain("{{")
// A later prompt replaces the context: personalization back to the device's, nothing from the instance. // A later prompt replaces the context: personalization back to the device's, nothing from the
// instance. The system prompt stays as it was made (harness context.md §10); the change goes as a
// note on that prompt's message.
const first = system()
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "again" }], _meta: { lembas: { turnId: "T2", system_prompt: "", personalization: null, prototypes: { offered: false, sources: "" } } } }) await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "again" }], _meta: { lembas: { turnId: "T2", system_prompt: "", personalization: null, prototypes: { offered: false, sources: "" } } } })
expect(system()).not.toContain("Instructions from example") expect(system()).toBe(first)
expect(system()).toContain("local words") const said = () => fake!.requests.at(-1)!.messages.at(-1).content as string
expect(system()).not.toContain("prototype-chat") expect(said()).toStartWith("again\n<session-note>\n")
// A prompt that says only its turn id changes nothing. expect(said()).toContain("the person's own instructions changed; it now reads:\nlocal words")
expect(said()).toContain("Instructions from example changed; it now reads:\n(none)")
expect(said()).toContain("The person chose a different personality:\nBe brief.")
// A prompt that says only its turn id changes nothing, and so says nothing.
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "more" }], _meta: { lembas: { turnId: "T3" } } }) await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "more" }], _meta: { lembas: { turnId: "T3" } } })
expect(system()).toContain("local words") expect(system()).toBe(first)
expect(said()).toBe("more")
}) })
test("the plan is sent after a session is opened from the store, and cleared with plan: null", async () => { test("the plan is sent after a session is opened from the store, and cleared with plan: null", async () => {
@@ -546,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") 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) 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. // `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 }) 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 () => { 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" })] }]) fake = fakeProvider([{ chunks: [delta({ content: "one" })] }, { chunks: [delta({ content: "two" })] }])
mkdirSync(paths.config, { recursive: true }) 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") writeFileSync(join(paths.config, "config.yaml"), "model: bonsai\n")
const root = project() const root = project()
const { c } = await client({ roots: [root], maxMode: "auto", approvalTimeoutMs: 50, requireTrust: true }, undefined, "example") 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" }) expect(changed).toEqual({ model: "bonsai", effort: "off" })
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "again" }] }) await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "again" }] })
expect(fake.requests.at(-1).model).toBe("bonsai") 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(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 () => { 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 () => {
+4 -4
View File
@@ -25,7 +25,7 @@ function app(limits: string, script: Parameters<typeof fakeProvider>[0], ask: ()
const tools = (r: any) => (r.tools ?? []).map((t: any) => t.function.name) const tools = (r: any) => (r.tools ?? []).map((t: any) => t.function.name)
test("past the output budget the tools are withdrawn, the model is told, and it answers", async () => { test("past the output budget the model is told the tools are withdrawn, and it answers; the list stays the session's", async () => {
const a = app("output_bytes: 1000", [ const a = app("output_bytes: 1000", [
{ chunks: [toolCall(0, "r1", "read", '{"path":"big.txt"}')] }, { chunks: [toolCall(0, "r1", "read", '{"path":"big.txt"}')] },
{ chunks: [delta({ content: "It is a file of x." }, "stop")] }, { chunks: [delta({ content: "It is a file of x." }, "stop")] },
@@ -34,7 +34,7 @@ test("past the output budget the tools are withdrawn, the model is told, and it
a.bus.on((e: Event) => e.type === "notice" && notices.push(e.message)) a.bus.on((e: Event) => e.type === "notice" && notices.push(e.message))
expect(await a.engine.prompt("what is in big.txt?")).toBe("budget") expect(await a.engine.prompt("what is in big.txt?")).toBe("budget")
expect(tools(fake!.requests[0])).toContain("read") expect(tools(fake!.requests[0])).toContain("read")
expect(tools(fake!.requests[1])).toEqual([]) expect(tools(fake!.requests[1])).toEqual(tools(fake!.requests[0]))
expect(JSON.stringify(fake!.requests[1].messages.at(-1))).toContain("The tool output budget for this prompt has been reached") expect(JSON.stringify(fake!.requests[1].messages.at(-1))).toContain("The tool output budget for this prompt has been reached")
expect(notices.join("\n")).toContain("with too much tool output to read") expect(notices.join("\n")).toContain("with too much tool output to read")
}) })
@@ -44,8 +44,8 @@ test("the token budget counts what the model wrote this prompt", async () => {
{ chunks: [toolCall(0, "r1", "read", '{"path":"big.txt"}'), usage(10, 80)] }, { chunks: [toolCall(0, "r1", "read", '{"path":"big.txt"}'), usage(10, 80)] },
{ chunks: [delta({ content: "done" }, "stop")] }, { chunks: [delta({ content: "done" }, "stop")] },
]) ])
await a.engine.prompt("go") expect(await a.engine.prompt("go")).toBe("budget")
expect(tools(fake!.requests[1])).toEqual([]) expect(JSON.stringify(fake!.requests[1].messages.at(-1))).toContain("The output budget for this prompt has been reached")
}) })
test("time spent waiting for an approval is not spent from the wall-clock budget", async () => { test("time spent waiting for an approval is not spent from the wall-clock budget", async () => {
+145 -2
View File
@@ -7,13 +7,14 @@ import { splitCommand, words } from "../src/permission/bash.ts"
import { prefix } from "../src/permission/arity.ts" import { prefix } from "../src/permission/arity.ts"
import { split } from "../src/library/chunks.ts" import { split } from "../src/library/chunks.ts"
import { ThinkSplitter } from "../src/provider/think.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 { replace } from "../src/tool/replace.ts"
import { applyUnified, parseUnified } from "../src/tool/unidiff.ts" import { applyUnified, parseUnified } from "../src/tool/unidiff.ts"
import { derive, joinBom, parse } from "../src/tool/patch.ts" import { derive, joinBom, parse } from "../src/tool/patch.ts"
import type { Mode, PermissionConfig } from "../src/config/schema.ts" import type { Mode, PermissionConfig } from "../src/config/schema.ts"
import { apply, fromTodos, normalise, type Plan } from "../src/plan/plan.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 { parseInfo, titleFor } from "../src/prototype/fence.ts"
import { resolveBase } from "../src/library/store.ts" import { resolveBase } from "../src/library/store.ts"
import { NarrowError, narrowParameters, type Narrow } from "../src/harness.ts" import { NarrowError, narrowParameters, type Narrow } from "../src/harness.ts"
@@ -23,6 +24,12 @@ import { CONTEXT, hunks, lineCount, memoryResults, presentIds, ResultContext, sh
import { readBody } from "../src/tool/read.ts" import { readBody } from "../src/tool/read.ts"
import { applyOps, type Op, type Target } from "../src/memory/store.ts" import { applyOps, type Op, type Target } from "../src/memory/store.ts"
import { jobNote, type NoteCase } from "../src/jobs/note.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 { 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> type Case = Record<string, unknown>
@@ -65,6 +72,14 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
return out return out
}, },
effort: (c) => ({ refused: effortRefused(c.message as string), advertised: advertisedEfforts(c.message as string) }), 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) => { edit: (c) => {
try { try {
return { content: replace(c.content as string, c.old as string, c.new as string, c.all === true) } return { content: replace(c.content as string, c.old as string, c.new as string, c.all === true) }
@@ -150,6 +165,134 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
return text return text
}) })
}, },
// The prefix (context.md §10): one session's requests, step by step — which prompt each sends (a
// new number each time one is made), which tools it offers, and the note on a user message.
prefix: (c) => {
const start = c.start as Partial<Now> & { could?: Requirement[]; met?: Requirement[] }
const could = new Set<Requirement>(start.could ?? [])
let met = new Set<Requirement>(start.met ?? could)
const now: Now = { date: start.date!, mode: start.mode ?? "manual", plan: start.plan ?? "", memory: start.memory ?? "", personality: start.personality ?? "", files: start.files ?? {}, mcp: start.mcp ?? {} }
const modeText = (m: Mode) => fillPrompt(promptText(`modes/${m}.md`), { plan_dir: (c.plan_dir as string | undefined) ?? ".agent/plans" })
const requires = (t: string) => TOOL_SPECS[t]?.requires as Requirement[] | undefined
let made: { system: number; tools: string[]; told: Told } | undefined
let prompts = 0
let carried: string | undefined
const make = () => {
if (made) return made
made = { system: ++prompts, tools: (c.tools as string[]).filter((t) => offeredFor(requires(t), could)), told: { ...toldAt(now), ...(carried ? { date: carried } : {}) } }
carried = undefined
return made
}
const fields = ["date", "mode", "plan", "memory", "personality", "files", "mcp"] as const
return (c.steps as Record<string, any>[]).map((st) => {
if (st.op === "set") {
for (const f of fields) if (f in st) (now as unknown as Record<string, unknown>)[f] = structuredClone(st[f])
if (st.met) met = new Set(st.met)
// The model's own tool changed it: its result told the model.
if (st.by === "model" && made) for (const f of fields) if (f in st && f !== "date") (made.told as unknown as Record<string, unknown>)[f] = structuredClone(st[f])
return null
}
if (st.op === "call") {
const here = new Set(met)
if (now.mode === "plan") here.add("plan")
else here.delete("plan")
return unavailable(st.tool, requires(st.tool), here) ?? null
}
if (st.op === "model") {
carried = made?.told.date
made = undefined
return null
}
const k = make()
if (st.op === "user") {
const { note, told } = noteFor(k.told, now, modeText)
if (note) k.told = told
return { system: k.system, tools: k.tools, note: note || null }
}
if (st.op === "tool" || st.op === "wrapup") return { system: k.system, tools: k.tools, note: null }
if (st.op === "compact") {
made = undefined
return { system: k.system, tools: k.tools, ask: compactAsk() }
}
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), library_scope: (c) => resolveBase(c.bases as { id: string; name: string }[], c.base as string | null),
toolcall_index: (c) => { toolcall_index: (c) => {
// The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`. // The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`.
+10 -2
View File
@@ -66,7 +66,15 @@ describe("context", () => {
a.bus.on((e: Event) => e.type === "notice" && notices.push(e.message)) a.bus.on((e: Event) => e.type === "notice" && notices.push(e.message))
await a.engine.prompt("go") await a.engine.prompt("go")
expect(notices.some((n) => n.includes("compacting the conversation"))).toBe(true) expect(notices.some((n) => n.includes("compacting the conversation"))).toBe(true)
expect(fake!.requests[1].messages[0].content).toContain("## Transcript") // The summary is the next turn of the same conversation (context.md §7, spec 3.1.0): the same
// system prompt and tools, every message as it was, then the compaction text — no transcript.
const [first, compaction] = [fake!.requests[0], fake!.requests[1]]
expect(compaction.messages[0]).toEqual(first.messages[0])
expect(compaction.tools).toEqual(first.tools)
expect(compaction.messages.slice(0, first.messages.length)).toEqual(first.messages)
expect(compaction.messages.at(-2).role).toBe("tool")
expect(compaction.messages.at(-1).content).toStartWith("Summarise the conversation")
expect(compaction.messages.at(-1).content).not.toContain("yyyy")
const last = fake!.requests[2].messages const last = fake!.requests[2].messages
expect(JSON.stringify(last[1])).toContain("A long task.") expect(JSON.stringify(last[1])).toContain("A long task.")
// the original request comes back verbatim, with the note // the original request comes back verbatim, with the note
@@ -77,7 +85,7 @@ describe("context", () => {
test("/context: parts of the system prompt, tools, the conversation, against the window", async () => { test("/context: parts of the system prompt, tools, the conversation, against the window", async () => {
const a = app(`connections:\n f:\n type: webui\n url: URL\n models: { f/m: { context: 32768 } }\n`, [{ chunks: [toolCall(0, "c1", "read", '{"path":"big.txt"}'), usage(1500, 20)] }, { chunks: [delta({ content: "ok" }), usage(3100, 5)] }]) const a = app(`connections:\n f:\n type: webui\n url: URL\n models: { f/m: { context: 32768 } }\n`, [{ chunks: [toolCall(0, "c1", "read", '{"path":"big.txt"}'), usage(1500, 20)] }, { chunks: [delta({ content: "ok" }), usage(3100, 5)] }])
await a.engine.prompt("read it") await a.engine.prompt("read it")
const system = a.engine.o.system(a.engine.mode, a.engine.model) const system = a.engine.compose(a.engine.mode)
const text = renderBreakdown(contextBreakdown(a.engine, system, [], undefined, a.engine.o.tools)) const text = renderBreakdown(contextBreakdown(a.engine, system, [], undefined, a.engine.o.tools))
expect(text).toContain("window 32.8k") expect(text).toContain("window 32.8k")
expect(text).toContain("system prompt") expect(text).toContain("system prompt")
+7 -5
View File
@@ -93,18 +93,20 @@ describe("history", () => {
}) })
describe("the context meter", () => { 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([ const dir = setup([
{ chunks: [delta({ reasoning_content: "x".repeat(4000) }), toolCall(0, "l1", "list", "{}"), usage(1000, 1100)] }, { 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 app = createApp({ cwd: dir, mode: "edit", asker: noAsk, store: false })
const used: number[] = [] const used: number[] = []
app.bus.on((e: Event) => e.type === "usage" && e.used !== undefined && used.push(e.used)) app.bus.on((e: Event) => e.type === "usage" && e.used !== undefined && used.push(e.used))
await app.engine.prompt("go") await app.engine.prompt("go")
// 1000 in + 1100 out, of which ~1000 thinking that is not sent again: ~1100, not 2100. // The step that called a tool: its ~1000 tokens of thinking go back with the next step.
expect(used[0]).toBeLessThan(1200) expect(used[0]).toBeGreaterThan(2000)
expect(used[0]).toBeGreaterThan(1000) // 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)
}) })
}) })
+21 -13
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs" import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
import { join } from "node:path" import { basename, join } from "node:path"
import { createApp } from "../src/app.ts" import { createApp } from "../src/app.ts"
import type { AskReply, Event } from "../src/bus/index.ts" import type { AskReply, Event } from "../src/bus/index.ts"
import { paths } from "../src/config/paths.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[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$/) 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. // 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") 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 // 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). // 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", "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") 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"]) 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 const sid = store.sessions(1)[0]!.id
@@ -109,16 +110,20 @@ describe("end to end", () => {
expect(Bun.YAML.parse(saved)).toEqual({ mode: "manual", permission: { bash: { "echo *": "allow" } } }) expect(Bun.YAML.parse(saved)).toEqual({ mode: "manual", permission: { bash: { "echo *": "allow" } } })
}) })
test("step ceiling withdraws tools and asks for an answer", async () => { test("step ceiling: the same tools are listed, the model is asked for an answer, and a call in it is not run", async () => {
const dir = setup([ const dir = setup([
{ chunks: [toolCall(0, "c1", "list", "{}")] }, { chunks: [toolCall(0, "c1", "list", "{}")] },
{ chunks: [delta({ content: "summary" })] }, { chunks: [delta({ content: "summary" }), toolCall(0, "c2", "list", "{}")] },
]) ])
writeFileSync(join(paths.config, "config.yaml"), "model: fake/m\nlimits: { steps: 1 }\n") writeFileSync(join(paths.config, "config.yaml"), "model: fake/m\nlimits: { steps: 1 }\n")
const app = createApp({ cwd: dir, mode: "edit", asker: noAsk, store: false }) const app = createApp({ cwd: dir, mode: "edit", asker: noAsk, store: false })
expect(await app.engine.prompt("go")).toBe("steps") expect(await app.engine.prompt("go")).toBe("steps")
expect(fake!.requests[1].tools).toBeUndefined() // The tool list is the session's, the wrap-up's request included (harness context.md §10).
expect(fake!.requests[1].tools).toEqual(fake!.requests[0].tools)
expect(fake!.requests[1].messages.at(-1).content).toContain("step limit") expect(fake!.requests[1].messages.at(-1).content).toContain("step limit")
expect(fake!.requests).toHaveLength(2)
const last = app.engine.messages.at(-1)!
expect(last).toEqual({ role: "assistant", parts: [{ type: "text", text: "summary" }] })
}) })
test("the CLI: lembas run", async () => { test("the CLI: lembas run", async () => {
@@ -137,9 +142,11 @@ describe("end to end", () => {
}) })
describe("nobody to approve", () => { describe("nobody to approve", () => {
test("a final denial withdraws every tool under that permission, and the prompt says so up front", async () => { test("a final denial withdraws every tool under that permission: still listed, each further call answers the refusal", async () => {
const asked: string[] = []
const dir = setup([ const dir = setup([
{ chunks: [toolCall(0, "c1", "read", '{"path":"hello.txt"}'), toolCall(1, "c2", "edit", JSON.stringify({ path: "hello.txt", old: "world", new: "x" }))] }, { chunks: [toolCall(0, "c1", "read", '{"path":"hello.txt"}'), toolCall(1, "c2", "edit", JSON.stringify({ path: "hello.txt", old: "world", new: "x" }))] },
{ chunks: [toolCall(0, "c3", "write", JSON.stringify({ path: "hello.txt", content: "x" }))] },
{ chunks: [delta({ content: "I would change world to x." })] }, { chunks: [delta({ content: "I would change world to x." })] },
]) ])
const app = createApp({ const app = createApp({
@@ -147,16 +154,17 @@ describe("nobody to approve", () => {
mode: "manual", mode: "manual",
store: false, store: false,
unattended: true, unattended: true,
asker: { ask: async ({ tool }) => ({ kind: "deny", final: true, feedback: `nobody is present to approve ${tool}.` }) }, asker: { ask: async ({ tool }) => (asked.push(tool), { kind: "deny", final: true, feedback: `nobody is present to approve ${tool}.` }) },
}) })
await app.engine.prompt("go") await app.engine.prompt("go")
expect(fake!.requests[0].messages[0].content).toContain("nobody can approve anything") expect(fake!.requests[0].messages[0].content).toContain("nobody can approve anything")
expect(fake!.requests[0].messages[0].content).toContain("any file change") expect(fake!.requests[0].messages[0].content).toContain("any file change")
const names = fake!.requests[1].tools.map((t: any) => t.function.name) expect(fake!.requests[1].tools).toEqual(fake!.requests[0].tools)
expect(names).not.toContain("edit") const refusal = fake!.requests[1].messages.at(-1).content as string
expect(names).not.toContain("write") expect(refusal).toContain("every further call to write, edit, multiedit, apply_patch answers this same refusal")
expect(names).toContain("read") // The write is not put to anybody: its answer is that same refusal.
expect(fake!.requests[1].messages.at(-1).content).toContain("no longer offered") expect(asked).toEqual(["edit"])
expect(fake!.requests[2].messages.at(-1).content).toBe(refusal.replace(/^\[#0002\]/, "[#0003]"))
}) })
test("a 5xx before any output is retried once, then given up", async () => { test("a 5xx before any output is retried once, then given up", async () => {
+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 /** `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, * 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". */ * 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 requests: any[] = []
const calls: { path: string; headers: Record<string, string> }[] = [] const calls: { path: string; headers: Record<string, string> }[] = []
let i = 0 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) }) if (to) return fetch(to.url.replace(/\/v1$/, "") + url.pathname + url.search, { method: "POST", headers: req.headers, body: JSON.stringify(body) })
requests.push(body) requests.push(body)
calls.push({ path: url.pathname + url.search, headers: Object.fromEntries(req.headers.entries()) }) 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 (!r) return new Response("script exhausted", { status: 500 })
if ("status" in r) return new Response(r.body, { status: r.status }) if ("status" in r) return new Response(r.body, { status: r.status })
// gapMs: a pause between chunks, to look at the screen mid-stream. // gapMs: a pause between chunks, to look at the screen mid-stream.
+15 -3
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. // plan_write's former name rather than a tool.
expect(TOOL_SPECS.todo).toBeUndefined() expect(TOOL_SPECS.todo).toBeUndefined()
expect(TOOL_SPECS.plan_write!.aliases.cli).toEqual(["todo"]) 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()) 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", () => { 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 }> } 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"]) { for (const n of ["concise", "pragmatic", "optimistic", "funny", "formal", "socratic"]) {
@@ -206,7 +218,7 @@ test("every {{variable}} in a description is declared, and none reaches a model"
const plan = normalise({ title: "T", phases: [{ title: "P", tasks: ["a"] }] }) as Plan const plan = normalise({ title: "T", phases: [{ title: "P", tasks: ["a"] }] }) as Plan
for (const mode of MODES) { for (const mode of MODES) {
for (const p of [undefined, plan]) { for (const p of [undefined, plan]) {
const system = app.engine.o.system(mode, app.engine.model, p) const system = app.engine.compose(mode, p)
expect(system).not.toContain("{{") expect(system).not.toContain("{{")
expect(system).toContain("Use the instructions below and the tools you have to help with software engineering and with running the project.") expect(system).toContain("Use the instructions below and the tools you have to help with software engineering and with running the project.")
expect(system).toContain("- Your output is shown in a terminal and rendered as GitHub-flavoured markdown in a monospace font. Be concise") expect(system).toContain("- Your output is shown in a terminal and rendered as GitHub-flavoured markdown in a monospace font. Be concise")
@@ -221,7 +233,7 @@ test("every {{variable}} in a description is declared, and none reaches a model"
} }
} }
app.setContext({ instructions: "Be brief.", prototypes: { offered: true, sources: "" } }, "example") app.setContext({ instructions: "Be brief.", prototypes: { offered: true, sources: "" } }, "example")
const withContext = app.engine.o.system("manual", app.engine.model, plan) const withContext = app.engine.compose("manual", plan)
expect(withContext).not.toContain("{{") expect(withContext).not.toContain("{{")
expect(withContext).toContain("## Instructions from example\n\nBe brief.") expect(withContext).toContain("## Instructions from example\n\nBe brief.")
expect(withContext).toContain("prototype-page") expect(withContext).toContain("prototype-page")
+3 -1
View File
@@ -59,6 +59,8 @@ describe("images", () => {
const atts = att("see @shot.png", app.engine.o.toolCtx, true) const atts = att("see @shot.png", app.engine.o.toolCtx, true)
await app.engine.prompt("see @shot.png", atts.flatMap((a) => (a.image ? [a.text, a.image] : [a.text]))) await app.engine.prompt("see @shot.png", atts.flatMap((a) => (a.image ? [a.text, a.image] : [a.text])))
const content = fake!.requests[0].messages.at(-1).content const content = fake!.requests[0].messages.at(-1).content
expect(content.map((p: any) => p.type)).toEqual(["text", "text", "image_url"]) // The session's first user message also carries the date, as a note after everything else.
expect(content.map((p: any) => p.type)).toEqual(["text", "text", "image_url", "text"])
expect(content.at(-1).text).toStartWith("<session-note>\nToday is ")
}) })
}) })
+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")) mkdirSync(join(root, "a"))
writeFileSync(join(paths.config, "config.yaml"), "") 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: [] }) 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`.")
}) })
+3 -3
View File
@@ -82,8 +82,8 @@ test("memory is the server's; notes, knowledge and skills stay this machine's",
// The entries for the prompt: read from the resource as the session started. // The entries for the prompt: read from the resource as the session started.
for (let i = 0; i < 50 && !lembas.calls.some((c) => c.uri); i++) await Bun.sleep(10) for (let i = 0; i < 50 && !lembas.calls.some((c) => c.uri); i++) await Bun.sleep(10)
expect(lembas.calls.find((c) => c.uri)).toEqual({ auth: "Bearer lmb_lib", uri: "lembas://memory" }) expect(lembas.calls.find((c) => c.uri)).toEqual({ auth: "Bearer lmb_lib", uri: "lembas://memory" })
for (let i = 0; i < 50 && !app.engine.o.system("manual", app.engine.model).includes("Prefers tabs."); i++) await Bun.sleep(10) for (let i = 0; i < 50 && !app.engine.compose("manual").includes("Prefers tabs."); i++) await Bun.sleep(10)
expect(app.engine.o.system("manual", app.engine.model)).toContain("MEMORY (the account's)\nPrefers tabs.") expect(app.engine.compose("manual")).toContain("MEMORY (the account's)\nPrefers tabs.")
await app.close() await app.close()
}) })
@@ -106,7 +106,7 @@ test("a session the client opened sends its id on every memory call and on the r
for (let i = 0; i < 50 && !lembas.calls.some((c) => c.uri && c.meta); i++) await Bun.sleep(10) for (let i = 0; i < 50 && !lembas.calls.some((c) => c.uri && c.meta); i++) await Bun.sleep(10)
expect(lembas.calls.find((c) => c.uri && c.meta)).toEqual({ auth: "Bearer lmb_lib", uri: "lembas://memory", meta: { scope: app.engine.sessionId } }) expect(lembas.calls.find((c) => c.uri && c.meta)).toEqual({ auth: "Bearer lmb_lib", uri: "lembas://memory", meta: { scope: app.engine.sessionId } })
// Knowledge is this machine's: nothing the server holds is named to the model. // Knowledge is this machine's: nothing the server holds is named to the model.
expect(app.engine.o.system("manual", app.engine.model)).not.toContain("Knowledge searches here cover only") expect(app.engine.compose("manual")).not.toContain("Knowledge searches here cover only")
}) })
test("without a login it says so and keeps this machine's memory", () => { test("without a login it says so and keeps this machine's memory", () => {
+3 -2
View File
@@ -155,10 +155,11 @@ test("the tools: knowledge_search is offered only with something to search; a pr
lib.putDocument("private", { title: "Diary", source: "d", text: "The pump primes in my dreams." }) lib.putDocument("private", { title: "Diary", source: "d", text: "The pump primes in my dreams." })
await app.engine.prompt("anything") await app.engine.prompt("anything")
expect(offered()).not.toContain("knowledge_search") expect(offered()).not.toContain("knowledge_search")
// A document in the project's base: offered, and only that base is searched. // A document in the project's base: offered from the next session — the tool list is the
// session's, decided as it starts (harness context.md §10) — and only that base is searched.
const pump = lib.putDocument("manuals", { title: "Pump", source: "p", text: "The pump primes in thirty seconds." }) const pump = lib.putDocument("manuals", { title: "Pump", source: "p", text: "The pump primes in thirty seconds." })
fake.requests.length = 0 fake.requests.length = 0
app.engine.messages = [] app.newSession()
await app.engine.prompt("how long does the pump take to prime?") await app.engine.prompt("how long does the pump take to prime?")
expect(offered()).toContain("knowledge_search") expect(offered()).toContain("knowledge_search")
const results = app.engine.messages.filter((m) => m.role === "tool").map((m) => (m.role === "tool" ? m.content : "")) const results = app.engine.messages.filter((m) => m.role === "tool").map((m) => (m.role === "tool" ? m.content : ""))
+18 -7
View File
@@ -7,6 +7,7 @@ import type { Asker, AskReply } from "../src/bus/index.ts"
import { paths } from "../src/config/paths.ts" import { paths } from "../src/config/paths.ts"
import type { PlanReply, PlanRequest } from "../src/tool/plan_submit.ts" import type { PlanReply, PlanRequest } from "../src/tool/plan_submit.ts"
import { delta, fakeProvider, toolCall, type Fake } from "./fake-provider.ts" import { delta, fakeProvider, toolCall, type Fake } from "./fake-provider.ts"
import { TOOL_SPECS } from "../src/harness.ts"
let fake: Fake | undefined let fake: Fake | undefined
afterEach(() => fake?.stop()) afterEach(() => fake?.stop())
@@ -29,7 +30,7 @@ const exitPlan = () => toolCall(0, "x1", "plan_submit", JSON.stringify({ path: "
const names = (r: any) => r.tools.map((t: any) => t.function.name) const names = (r: any) => r.tools.map((t: any) => t.function.name)
describe("plan mode", () => { describe("plan mode", () => {
test("plan_write, plan_submit, approve → edit mode in the same turn, and plan_submit is no longer offered", async () => { test("plan_write, plan_submit, approve → edit mode in the same turn; the prompt and tools stay as they were", async () => {
const seen: PlanRequest[] = [] const seen: PlanRequest[] = []
const a = app([{ chunks: [writePlan()] }, { chunks: [exitPlan()] }, { chunks: [delta({ content: "Implementing." })] }], async (r) => { const a = app([{ chunks: [writePlan()] }, { chunks: [exitPlan()] }, { chunks: [delta({ content: "Implementing." })] }], async (r) => {
seen.push(r) seen.push(r)
@@ -48,8 +49,11 @@ describe("plan mode", () => {
expect(names(fake!.requests[0])).toContain("plan_submit") expect(names(fake!.requests[0])).toContain("plan_submit")
expect(names(fake!.requests[0])).toContain("plan_write") expect(names(fake!.requests[0])).toContain("plan_write")
expect(fake!.requests[0].messages[0].content).toContain("Permission mode: plan") expect(fake!.requests[0].messages[0].content).toContain("Permission mode: plan")
expect(fake!.requests[2].messages[0].content).toContain("Permission mode: edit") // Spec 3.1.0 (context.md §10): the system prompt and the tool list are the session's, byte for
expect(names(fake!.requests[2])).not.toContain("plan_submit") // byte; the approval's own result tells the model the mode, so no note says it again.
expect(fake!.requests[2].messages[0]).toEqual(fake!.requests[0].messages[0])
expect(fake!.requests[2].tools).toEqual(fake!.requests[0].tools)
expect(names(fake!.requests[2])).toContain("plan_submit")
// plan_write and plan_update are offered in every mode: the plan is kept current while implementing. // plan_write and plan_update are offered in every mode: the plan is kept current while implementing.
expect(names(fake!.requests[2])).toContain("plan_update") expect(names(fake!.requests[2])).toContain("plan_update")
expect(fake!.requests[2].messages.at(-1).content).toContain("The user approved the plan") expect(fake!.requests[2].messages.at(-1).content).toContain("The user approved the plan")
@@ -69,13 +73,20 @@ describe("plan mode", () => {
expect(b.engine.mode).toBe("plan") expect(b.engine.mode).toBe("plan")
}) })
test("plan_submit with no plan written is an error naming plan_write; edit mode does not offer it", async () => { test("plan_submit with no plan written is an error naming plan_write; outside plan mode it is offered and answers unavailable", async () => {
const a = app([{ chunks: [exitPlan()] }, { chunks: [delta({ content: "ok" })] }], async () => ({ kind: "approve", mode: "edit" })) const a = app([{ chunks: [exitPlan()] }, { chunks: [delta({ content: "ok" })] }, { chunks: [exitPlan()] }, { chunks: [delta({ content: "carrying on" })] }], async () => ({ kind: "approve", mode: "edit" }))
await a.engine.prompt("go") await a.engine.prompt("go")
expect(fake!.requests[1].messages.at(-1).content).toContain("Write it with plan_write") expect(fake!.requests[1].messages.at(-1).content).toContain("Write it with plan_write")
expect(a.engine.mode).toBe("plan") expect(a.engine.mode).toBe("plan")
a.engine.mode = "edit" a.engine.mode = "edit"
await a.engine.prompt("again").catch(() => {}) await a.engine.prompt("again")
expect(names(fake!.requests.at(-1))).not.toContain("plan_submit") const [again, after] = fake!.requests.slice(-2)
expect(names(again)).toContain("plan_submit")
expect(again.tools).toEqual(fake!.requests[0].tools)
expect(again.messages[0]).toEqual(fake!.requests[0].messages[0])
// The person's mode change comes as a note on their next message, the mode's own text with it.
expect(again.messages.at(-1).content).toStartWith("again\n<session-note>\nThe permission mode is now edit:\nPermission mode: edit.")
expect(after.messages.at(-1).content).toBe(`[#0002]\n${TOOL_SPECS.plan_submit!.unavailable}`)
expect(a.engine.mode).toBe("edit")
}) })
}) })
+234
View File
@@ -0,0 +1,234 @@
// The prefix (harness spec 3.1.0, context.md §10): the system prompt and the tool list are made once
// per session and resent byte for byte; what changes during it goes as a note on the next user
// message; compaction asks for its summary as the next turn of the same conversation; every
// request's cached input tokens are kept. These drive the real engine against a scripted server.
import { afterEach, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, 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 { TOOL_SPECS } from "../src/harness.ts"
import { Store } from "../src/session/store.ts"
import { isNote, NOTE, noteFor, PREFIX_META, summaryOf, toldAt, type Kept } from "../src/session/prefix.ts"
import { delta, fakeProvider, toolCall, usage, type Fake } from "./fake-provider.ts"
let fake: Fake | undefined
afterEach(() => fake?.stop())
const once = { ask: async (): Promise<AskReply> => ({ kind: "once" }) }
function setup(script: Parameters<typeof fakeProvider>[0], extra = "") {
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: { context: 32768 }\n fake/n: { context: 32768 }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), `model: fake/m\ntitles: prompt\n${extra}`)
const dir = mkdtempSync(join(tmpdir(), "lembas-prefix-"))
Bun.spawnSync(["git", "init", "-q", dir])
writeFileSync(join(dir, "AGENTS.md"), "# Demo\nBuild with make.\n")
return dir
}
const reply = (text: string) => ({ chunks: [delta({ content: text }, "stop")] })
const system = (i: number) => fake!.requests.at(i)!.messages[0].content as string
const lastUser = (i: number) => {
const m = fake!.requests.at(i)!.messages
return [...m].reverse().find((x: { role: string }) => x.role === "user").content as string
}
const names = (i: number) => (fake!.requests.at(i)!.tools as { function: { name: string } }[]).map((t) => t.function.name)
test("no clock in the prompt: two requests across a date change send the same system bytes, and the date goes as a note", async () => {
const dir = setup([reply("one"), reply("two"), reply("three")])
const app = createApp({ cwd: dir, asker: once, store: false, snapshots: false })
let day = "2026-11-04"
app.engine.o.today = () => day
await app.engine.prompt("first")
await app.engine.prompt("second")
day = "2026-11-05"
await app.engine.prompt("third")
expect(system(0)).not.toContain("Today")
expect(system(0)).not.toContain("2026-11-0")
expect(system(1)).toBe(system(0))
expect(system(2)).toBe(system(0))
expect(fake!.requests[2].tools).toEqual(fake!.requests[0].tools)
// 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.
expect(fake!.requests[2].messages[1].content).toBe(lastUser(0))
})
test("a tool step carries no note; the note waits for the next user message", async () => {
const dir = setup([{ chunks: [toolCall(0, "c1", "read", '{"path":"AGENTS.md"}')] }, reply("read it"), reply("ok")])
const app = createApp({ cwd: dir, asker: once, store: false, snapshots: false })
let day = "2026-11-04"
app.engine.o.today = () => day
app.bus.on((e) => {
// The clock passes midnight while the tool runs.
if (e.type === "tool_start") day = "2026-11-05"
})
await app.engine.prompt("look")
expect(fake!.requests[1].messages.at(-1).role).toBe("tool")
expect(system(1)).toBe(system(0))
await app.engine.prompt("and now")
expect(lastUser(2)).toBe(`and now\n${NOTE.open}\nToday is 2026-11-05.\n${NOTE.close}`)
})
test("the person's changes come as one note in the spec's order; the model's own do not", async () => {
const dir = setup([
{ chunks: [toolCall(0, "w1", "plan_write", JSON.stringify({ title: "Fix", phases: [{ title: "P", tasks: ["a"] }] }))] },
{ chunks: [toolCall(0, "e1", "edit", JSON.stringify({ path: "AGENTS.md", old: "Build with make.", new: "Build with make; test with make test." }))] },
reply("done"),
reply("ok"),
reply("fine"),
])
const app = createApp({ cwd: dir, asker: once, mode: "edit", store: false, snapshots: false })
app.engine.o.today = () => "2026-11-05"
// The model reads AGENTS.md before it may edit it: read-before-edit is the tool's rule.
app.engine.o.toolCtx.readFiles.add(join(dir, "AGENTS.md"))
await app.engine.prompt("plan and fix")
// Its own plan and its own edit: their results told it.
await app.engine.prompt("thanks")
expect(lastUser(3)).toBe("thanks")
// Between turns the person changes the mode, the plan, an instruction file, and the personality.
app.engine.mode = "manual"
app.engine.setPlan(null)
writeFileSync(join(dir, "AGENTS.md"), "# Demo\nBuild with ninja.\n")
app.settings.set("personality", "concise", "session")
await app.engine.prompt("again")
const note = lastUser(4).slice("again\n".length)
expect(isNote(note)).toBe(true)
const order = ["The permission mode is now manual:\nPermission mode: manual.", "The person changed the plan; it is now:\n(none)", `${join(dir, "AGENTS.md")} changed; it now reads:\n# Demo\nBuild with ninja.`, "The person chose a different personality:\nBe brief."]
for (const line of order) expect(note).toContain(line)
expect(order.map((l) => note.indexOf(l))).toEqual([...order.map((l) => note.indexOf(l))].sort((a, b) => a - b))
expect(system(4)).toBe(system(0))
})
test("plan_submit is in the list in every mode, and answers its unavailable text outside plan mode", async () => {
const dir = setup([{ chunks: [toolCall(0, "x1", "plan_submit", "{}")] }, reply("carrying on")])
const app = createApp({ cwd: dir, asker: once, mode: "manual", store: false, snapshots: false })
await app.engine.prompt("submit it")
expect(names(0)).toContain("plan_submit")
expect(fake!.requests[1].messages.at(-1).content).toBe(`[#0001]\n${TOOL_SPECS.plan_submit!.unavailable}`)
// A terminal session can never show a file: show is not in its list at all.
expect(names(0)).not.toContain("show")
})
test("compaction: the same system and tools, every message, then the compaction text; a call in the reply is not run, and the request is no turn", async () => {
const dir = setup([
reply("The answer is 42."),
{ chunks: [delta({ content: "## What we are doing\nFinding the answer." }), toolCall(0, "c1", "bash", '{"command":"rm -rf x","description":"x"}'), usage(900, 40)] },
reply("ok"),
])
const store = new Store(":memory:")
const app = createApp({ cwd: dir, asker: { ask: async () => { throw new Error("nothing may ask") } }, store, snapshots: false })
await app.engine.prompt("what is the answer")
const sid = app.engine.sessionId!
const summary = await app.engine.compact()
const [first, compaction] = [fake!.requests[0], fake!.requests[1]]
expect(compaction.messages[0]).toEqual(first.messages[0])
expect(compaction.tools).toEqual(first.tools)
expect(compaction.messages.slice(1, -1).map((m: { content: string }) => m.content)).toEqual([first.messages[1].content, "The answer is 42."])
expect(compaction.messages.at(-1).content).toStartWith("Summarise the conversation")
expect(compaction.messages.at(-1).content).not.toContain("User: what is the answer")
// The text before the call is the summary; the call was not run.
expect(summary).toStartWith("## What we are doing\nFinding the answer.")
expect(store.messages(sid).map((m) => (m.role === "tool" ? "tool" : m.parts.map((p) => (p.type === "text" ? p.text : p.type)).join("|")))).not.toContainEqual(expect.stringContaining("Summarise"))
expect(store.messages(sid).some((m) => m.role === "tool")).toBe(false)
// Its usage is kept, as the compaction's.
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")
// 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 () => {
const dir = setup([reply("one"), reply("two")])
const store = new Store(":memory:")
const a = createApp({ cwd: dir, asker: once, store, snapshots: false })
await a.engine.prompt("first")
const sid = a.engine.sessionId!
const kept = store.meta<Kept>(sid, PREFIX_META)!
expect(kept.system).toBe(system(0))
await a.close()
writeFileSync(join(dir, "AGENTS.md"), "# Demo\nBuild with ninja.\n")
const b = createApp({ cwd: dir, asker: once, store, snapshots: false, resuming: sid })
b.resume(sid)
await b.engine.prompt("second")
expect(system(1)).toBe(system(0))
expect(system(1)).toContain("Build with make.")
expect(lastUser(1)).toContain(`${join(dir, "AGENTS.md")} changed; it now reads:\n# Demo\nBuild with ninja.`)
// Shown as typed: the note is not the person's words.
expect(b.engine.messages.filter((m) => m.role === "user").length).toBe(2)
await b.close()
})
test("a model switch makes the prompt again for the new model; the date already said holds", async () => {
const dir = setup([reply("one"), reply("two")])
const app = createApp({ cwd: dir, asker: once, store: false, snapshots: false })
await app.engine.prompt("first")
app.switchModel("fake/n")
await app.engine.prompt("second")
expect(system(0)).toContain("Model: fake/m")
expect(system(1)).toContain("Model: fake/n")
expect(lastUser(1)).toBe("second")
})
test("cached input tokens are kept with every request's usage and shown by /context", async () => {
const dir = setup([
{ chunks: [delta({ content: "one" }), { choices: [], usage: { prompt_tokens: 1000, completion_tokens: 5, prompt_tokens_details: { cached_tokens: 0 } } }] },
// llama.cpp says it in its timings when the usage does not.
{ chunks: [delta({ content: "two" }), usage(1100, 5), { choices: [], timings: { cache_n: 1000, prompt_n: 100, predicted_n: 5 } }] },
])
const store = new Store(":memory:")
const app = createApp({ cwd: dir, asker: once, store, snapshots: false })
await app.engine.prompt("first")
await app.engine.prompt("second")
expect(store.requests(app.engine.sessionId!).map((r) => [r.kind, r.input, r.cached])).toEqual([
["step", 1000, 0],
["step", 1100, 1000],
])
const { contextBreakdown, renderBreakdown } = await import("../src/session/context.ts")
expect(renderBreakdown(contextBreakdown(app.engine, app.engine.systemPrompt(), [], undefined, app.engine.o.tools))).toContain("last request: 1.1k in, 1.0k of it cached (91%)")
await app.close()
})
test("noteFor: nothing changed says nothing; an MCP server that connected later is said by its own instructions", () => {
const now = { date: "2026-11-05", mode: "manual" as const, plan: "", memory: "", personality: "", files: {}, mcp: { docs: "Search the docs." } }
const told = { ...toldAt(now), date: "2026-11-05" }
expect(noteFor(told, now, () => "").note).toBe("")
const later = { ...now, mcp: { ...now.mcp, tickets: "Name the ticket." } }
expect(noteFor(told, later, () => "").note).toBe(`${NOTE.open}\nInstructions from an MCP server that connected since the session began:\n<server name="tickets">\nName the ticket.\n</server>\n${NOTE.close}`)
expect(summaryOf([{ type: "reasoning", text: "x" }, { type: "text", text: " S " }, { type: "tool_call" }, { type: "text", text: "after" }])).toBe("S")
})
test("the first request waits for the configured MCP servers (up to loop.json's mcp_wait_ms): their tools and instructions are in the first list and prompt", async () => {
const BUN = process.execPath
const SERVER = join(import.meta.dir, "fixtures", "mcp", "server.ts")
const dir = setup([reply("one")], `mcp:\n fix:\n command: ["${BUN}", "${SERVER}"]\n`)
const app = createApp({ cwd: dir, asker: once, store: false, snapshots: false })
// Not waiting for app.mcpReady here: the engine does, before it makes the prompt.
await app.engine.prompt("first")
expect(names(0)).toContain("mcp__fix__echo")
expect(system(0)).toContain("<mcp_instructions>")
await app.close()
writeFileSync(join(paths.config, "config.yaml"), "")
})
test("an empty value in a note reads loop.json's context.note.empty", () => {
const now = { date: "2026-11-05", mode: "manual" as const, plan: "", memory: "", personality: "", files: {}, mcp: {} }
const told = { ...toldAt({ ...now, memory: "Prefers tabs." }), date: "2026-11-05" }
expect(noteFor(told, now, () => "").note).toBe(`${NOTE.open}\nThe person changed what is remembered; it is now:\n${NOTE.empty}\n${NOTE.close}`)
})
test("compaction's ask has no previous summary and no transcript heading", async () => {
const { compactAsk } = await import("../src/session/prefix.ts")
const ask = compactAsk()
expect(ask).not.toContain("## Transcript")
expect(ask).not.toContain("Summary of even earlier turns")
expect(ask).not.toMatch(/\n{3,}/)
expect(ask).toEndWith("stay under 600 words.")
})
+11 -4
View File
@@ -139,11 +139,18 @@ describe("openai-chat streaming", () => {
expect(fake.requests[0].chat_template_kwargs).toEqual({ reasoning_effort: "high" }) expect(fake.requests[0].chat_template_kwargs).toEqual({ reasoning_effort: "high" })
}) })
test("effort outside the vocabulary is not sent", async () => { 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" })] }]) fake = fakeProvider([{ chunks: [delta({ content: "ok" })] }, { chunks: [delta({ content: "ok" })] }, { chunks: [delta({ content: "ok" })] }])
await collect(new OpenAIChatClient(model(fake.url, { efforts: ["low"] })), "high") await collect(new OpenAIChatClient(model(fake.url, { efforts: ["low"] })), "high")
expect(fake.requests[0].reasoning_effort).toBeUndefined() expect(fake.requests[0].reasoning_effort).toBe("low")
expect(fake.requests[0].chat_template_kwargs).toBeUndefined() // 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 () => { 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(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.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") 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")
})
})
+5 -2
View File
@@ -63,7 +63,8 @@ describe("subagents", () => {
const child = fake!.requests[1] const child = fake!.requests[1]
expect(child.messages[0].content).toContain('You are the "explore" subagent') expect(child.messages[0].content).toContain('You are the "explore" subagent')
expect(child.messages[0].content).not.toContain("Permission mode: plan") expect(child.messages[0].content).not.toContain("Permission mode: plan")
expect(child.messages.at(-1).content).toBe("Find why average is wrong in calc.py") // Its prompt has no date either: its first message says it (harness context.md §10).
expect(child.messages.at(-1).content).toMatch(/^Find why average is wrong in calc\.py\n<session-note>\nToday is \d{4}-\d{2}-\d{2}\.\n<\/session-note>$/)
// its own job tools for commands it starts, and report for a word to the main agent // its own job tools for commands it starts, and report for a word to the main agent
expect(names(child)).toEqual(["read", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "web_search", "web_fetch", "report"]) expect(names(child)).toEqual(["read", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "web_search", "web_fetch", "report"])
// the preamble is the spec's text; a subagent is told nothing of plan_write or ask_user // the preamble is the spec's text; a subagent is told nothing of plan_write or ask_user
@@ -132,7 +133,9 @@ describe("worktrees", () => {
await a.engine.prompt("fix it in a worktree") await a.engine.prompt("fix it in a worktree")
const child = fake!.requests[1].messages[0].content as string const child = fake!.requests[1].messages[0].content as string
expect(child).toContain("You are working in a separate checkout") 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") expect(readFileSync(join(root, "calc.py"), "utf8")).toContain("len(v) - 1")
const result = fake!.requests[4].messages.at(-1).content as string const result = fake!.requests[4].messages.at(-1).content as string
const branch = /branch (lembas\/general-fix-average-[a-z0-9]+)/.exec(result)?.[1] 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
@@ -47,5 +47,5 @@ test("an untrusted project's task board is neither in the prompt nor writable",
setTrust(root, "readonly") setTrust(root, "readonly")
const app = createApp({ cwd: root, store: false, snapshots: false, asker: { ask: async () => ({ kind: "once" }) } }) const app = createApp({ cwd: root, store: false, snapshots: false, asker: { ask: async () => ({ kind: "once" }) } })
expect(app.engine.o.toolCtx.projectDir).toBeUndefined() expect(app.engine.o.toolCtx.projectDir).toBeUndefined()
expect(app.engine.o.system("plan", app.engine.model)).not.toContain("IGNORE PREVIOUS INSTRUCTIONS") expect(app.engine.compose("plan")).not.toContain("IGNORE PREVIOUS INSTRUCTIONS")
}) })
+8 -2
View File
@@ -104,7 +104,11 @@ test("/compact replaces the conversation with a summary", async () => {
await t.input.typeText("/compact") await t.input.typeText("/compact")
t.input.pressEnter() t.input.pressEnter()
expect(await t.until("compacted")).toBe(true) expect(await t.until("compacted")).toBe(true)
expect(t.fake.requests[1].messages[0].content).toContain("## Transcript") // Asked as the next turn of the same conversation: the same system prompt, the messages, then the
// compaction text (harness context.md §7).
expect(t.fake.requests[1].messages[0]).toEqual(t.fake.requests[0].messages[0])
expect(t.fake.requests[1].messages.at(-2).content).toBe("The answer is 42.")
expect(t.fake.requests[1].messages.at(-1).content).toStartWith("Summarise the conversation")
expect(t.app.engine.messages[0]).toMatchObject({ role: "user" }) expect(t.app.engine.messages[0]).toMatchObject({ role: "user" })
expect(JSON.stringify(t.app.engine.messages[0])).toContain("It is 42.") expect(JSON.stringify(t.app.engine.messages[0])).toContain("It is 42.")
}) })
@@ -429,8 +433,10 @@ test("/review sends the session's diff with the review brief in plan mode, then
t.input.pressEnter() t.input.pressEnter()
expect(await t.until("No findings.")).toBe(true) expect(await t.until("No findings.")).toBe(true)
const req = t.fake.requests.at(-1) const req = t.fake.requests.at(-1)
expect(req.messages[0].content).toContain("Permission mode: plan") // The session's prompt as it was made; plan mode comes as a note on the review's message.
expect(req.messages[0]).toEqual(t.fake.requests[0].messages[0])
const user = req.messages.at(-1).content as string const user = req.messages.at(-1).content as string
expect(user).toContain("<session-note>\nThe permission mode is now plan:\nPermission mode: plan.")
expect(user).toContain("Findings first, ordered by severity") expect(user).toContain("Findings first, ordered by severity")
expect(user).toContain("+hello there") expect(user).toContain("+hello there")
expect(t.frame()).toContain("┃ /review") expect(t.frame()).toContain("┃ /review")
+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 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) const missing = assembleSystem(base)
expect(missing).toContain(`This project has no AGENTS.md yet`) 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(missing).toContain("which branch the work goes on")
expect(assembleSystem({ ...base, isGit: false })).toContain("not a git repository yet") expect(assembleSystem({ ...base, isGit: false })).toContain("not a git repository yet")
expect(assembleSystem({ ...base, subagent: { name: "explore", instructions: "look" } })).not.toContain("AGENTS.md yet") expect(assembleSystem({ ...base, subagent: { name: "explore", instructions: "look" } })).not.toContain("AGENTS.md yet")