From e349d829a1a693e88d7ad37f2c11c687da2680f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Tue, 10 Nov 2026 12:11:56 +0000 Subject: [PATCH] Harness spec 3.5.0: scheduled work on a linked computer Accept scheduled work (remote.scheduled, off by default) said as limits.scheduled; a scheduled turn is an agent session marked `scheduled` in the schedule's mode (Edit when none chosen, Plan refused, lowered to remote.max_mode) with its Min/Max; the answer reports status ok, silent, failed or cancelled and the final reply. Approvals wait the schedule's wait and go to the person through the server, then count as denied. _lembas/monitor runs a watch command under the mode's rules (one that would ask is refused) and answers a hash and an excerpt. Schedule routes of /api/v2 accept the device's token. team_add under a sub-manager names it (added_under). Conformance areas scheduled, silent, monitor; team cases changed. Co-Authored-By: Claude Opus 5.5 --- harness/README.md | 36 +++++- harness/VERSION | 2 +- harness/acp.md | 176 ++++++++++++++++++++++++++++- harness/conformance/monitor.json | 123 ++++++++++++++++++++ harness/conformance/scheduled.json | 145 ++++++++++++++++++++++++ harness/conformance/silent.json | 101 +++++++++++++++++ harness/conformance/team.json | 32 +++--- harness/loop.json | 45 ++++++++ 8 files changed, 639 insertions(+), 21 deletions(-) create mode 100644 harness/conformance/monitor.json create mode 100644 harness/conformance/scheduled.json create mode 100644 harness/conformance/silent.json diff --git a/harness/README.md b/harness/README.md index 28a2bc6..f4079e5 100644 --- a/harness/README.md +++ b/harness/README.md @@ -22,7 +22,7 @@ page *Harness-parity* in LLeMbas CLI's wiki. | `permission/defaults.json` | The rules every session starts from | | `permission/modes.json` | The four modes — manual, edit, auto, plan — as what each does with each risk class | | `models/schema.json` | Model metadata: context, output limit, efforts and the thinking switch, vision, tools, notes, capacity (`parallel`); `levels` — the effort levels a chat offers and what each sends | -| `loop.json` | How one prompt runs: the step backstop, budgets, wrap-up, nudges, parallel calls, capacity; `jobs` — background work, its marks and limits; `subagents` — the model refusals, what names a model, the setting's label; `team` — a team's size, names, marks, refusals and texts; `progress` — the phases' texts; `context` — result ids, stubs, the output cap, the compaction table, the environment lines, the session notes | +| `loop.json` | How one prompt runs: the step backstop, budgets, wrap-up, nudges, parallel calls, capacity; `jobs` — background work, its marks and limits; `subagents` — the model refusals, what names a model, the setting's label; `team` — a team's size, names, marks, refusals and texts; `progress` — the phases' texts; `context` — result ids, stubs, the output cap, the compaction table, the environment lines, the session notes; `scheduled` (3.5.0) — scheduled work on a linked computer: the setting, the modes, the approval wait, the sentinel, the watch command's limits and the refusals | | `filesystem.md` | The file system a session works in: paths, node kinds, the four access levels, errors, listings, search and indexing by tokens, stacked instruction files, skills, memory | | `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 | @@ -71,6 +71,40 @@ page *Harness-parity* in LLeMbas CLI's wiki. approval in plan mode and answers its `unavailable` text outside it. Their permission key is `plan` (`permission/defaults.json`), which a rule written for the former `todo` key still reaches. +## 3.5.0 + +Written ahead of both implementations; the server vendors it as written, both carry it in the same +round (`acp.md` *Scheduled work on a linked computer*, `loop.json` `scheduled`). + +- **Scheduled turns on a linked computer.** A schedule may run each time as an agent turn on a + linked device, in a folder of it. The device must allow it: **Accept scheduled work** + (`remote.scheduled`, off by default; `lembas service scheduled on|off`), said to the server as + `limits.scheduled` and judged again at every request — off, every scheduled request is refused + with one fixed text. The server opens an agent session marked `scheduled` (schedule, run, mode, + approval wait) with the schedule's Min/Max as an agent chat's, and prompts it with the run's own + text. **The mode** is the schedule's — Manual, Edit or Auto, **Edit when none was chosen**, so never + wider than Edit unless the person picked Auto; Plan refused — lowered to `remote.max_mode`. The + answer reports the run: `status` `ok`, `silent` (the `[SILENT]` sentinel on the first or last + line, the server's rule), `failed` or `cancelled`, and the final reply's `text`. A device that is + away is waited for like a busy model, then the run is missed; a refusal fails the run. +- **Approvals in a scheduled turn** go to the person through the server (a phone notification with + the card), carry `scheduled` on `session/request_permission`, and wait the schedule's wait + (default 30 minutes) instead of `remote.approval_timeout`; then they count as denied and the turn + goes on. `ask_user` and `plan_submit` have nobody to answer; `show` is not offered. +- **Monitor mode, the device's half**: `_lembas/monitor` runs a schedule's watch command in its + folder, with a timeout, under the mode's permission rules — **a command the mode would ask about + is refused** — and answers the output's hash (`sha256:` over the exit status and the bytes) and a + short excerpt; the server runs the model only when the hash changed, and records `unchanged` + otherwise. +- **Schedules over `/api/v2` with the device's token** (scope `link`): the CLI's `/schedule` lists, + creates from words (compile), pauses, resumes, runs now and reads history. A run may be + `unchanged`, may wait for the `device`; a schedule may say `runs_on` and `watch`. +- **`team_add` under a sub-manager** says where the report goes: `team.texts.added_under` and + `added_waiting_under` ("Its report goes to when it finishes"). +- Capability `scheduled`. Conformance areas **`scheduled`** (the mode, the wait, the refusals), + **`silent`** (the sentinel and the stripped text) and **`monitor`** (the hash, and what a watch + command may run); `team` cases changed for `added_under`. + ## 3.4.0 Written ahead of both implementations; the server vendors it as written, both carry it in the same diff --git a/harness/VERSION b/harness/VERSION index 1809198..1545d96 100644 --- a/harness/VERSION +++ b/harness/VERSION @@ -1 +1 @@ -3.4.0 +3.5.0 diff --git a/harness/acp.md b/harness/acp.md index 4b21dc1..2cc07e1 100644 --- a/harness/acp.md +++ b/harness/acp.md @@ -68,6 +68,7 @@ change to the protocol is a change here first, then in both projects. | `progress` | spec 3.4.0 (`progress.md`): the `phase` event; `progress` in `_lembas/session/status` | | `agents` | spec 3.4.0: the session's Subagents setting `{ min, max }` is read from `_meta.lembas.agents` on `session/new`, `session/prompt` and `_lembas/configure` (*Subagents in an agent chat*, below) | | `tree` | spec 3.4.0 (`subagents.md` §5–§13): the `team` event; `_lembas/team`, `_lembas/team/transcript`, `_lembas/team/findings`, `_lembas/team/stop`; `subagent` on `session/request_permission` | +| `scheduled` | spec 3.5.0 (*Scheduled work on a linked computer*, below): `scheduled` in `_meta.lembas` of `session/new` and `session/prompt` read, and the run's outcome in the prompt's answer; `scheduled` on `session/request_permission`; `_lembas/monitor`; `limits.scheduled` in `_lembas/hello` and `initialize`. Listed by every build that has it, whatever the setting says: whether this device *accepts* scheduled work is `limits.scheduled` | ## Standard ACP, as used here @@ -90,14 +91,15 @@ Result: "_meta": { "lembas": { "protocol": 3, "harness_spec": "3.0.0", - "limits": { "roots": ["~/work"], "max_mode": "edit" }, + "limits": { "roots": ["~/work"], "max_mode": "edit", "scheduled": false }, "connection": "example", "capabilities": ["directories", "configure", "…"] } } } ``` -`limits` is `null` on `serve --stdio`. `connection` is the device's name for the instance — the +`limits` is `null` on `serve --stdio`. `limits.scheduled` (spec 3.5.0, capability `scheduled`): whether +this device accepts scheduled work now (`remote.scheduled`, *Scheduled work on a linked computer*). `connection` is the device's name for the instance — the login's connection — or `null`; the `model` event and `announce` say directly whether a model is the instance's (`instance`). @@ -251,6 +253,10 @@ person may also answer at the terminal (a shared session), so the client does no can say which — a background subagent may ask while the session itself is idle. `subagent` (capability `tree`, spec 3.4.0): `{ id, name, position }` when the call is a tree subagent's that nobody in the tree could allow (`subagents.md` §7) — the card names it; the answers are the same as for the main agent's own call. +`scheduled` (capability `scheduled`, spec 3.5.0): `{ schedule_id, run_id, wait_s }` when the call is +a scheduled turn's — nobody is at the chat, so the client puts the card to the person where they are +(LLeMbas: a notification on their phone carrying the card, answerable there and in every client), +and the device waits `wait_s` for it, not `remote.approval_timeout` (*Scheduled work*, below). Answer: ACP's `{ outcome: { outcome: "selected", optionId } | { outcome: "cancelled" } }`, with optional `_meta.lembas.command` (the corrected command line, `once` only) or `_meta.lembas.feedback` (why it was denied). No answer within `remote.approval_timeout` is a denial. @@ -306,6 +312,134 @@ is on the device, and gets it once the device links again. Over `/api/v2` the ch the tree as it changes and the endpoints to read a subagent's transcript and findings, the same for a chat's own subagents and for an agent chat's — so Web, Desktop and Android draw both alike. +## Scheduled work on a linked computer (spec 3.5.0) + +A schedule (the server's: its rule, its runs, its history and notifications) may run **on a linked +computer**: each run is an agent turn on that device, in a folder of it, instead of a chat on the +server. The server decides *when*; the device decides *whether*, and *how far* — every rule an agent +chat has still holds, and the ones below are added. Numbers and texts: `loop.json` `scheduled`. +Conformance areas `scheduled`, `silent`, `monitor`. + +### The device's setting: Accept scheduled work + +- **`remote.scheduled`** in the device's global config, **off by default**; shown as **Accept + scheduled work** (`loop.json` `scheduled.setting`). Set from the command line with + `lembas service scheduled on|off` (also `lembas remote scheduled on|off`; `lembas remote list + --json` says `scheduled`), and by LLeMbas Desktop through the same command. It is global only: a + project's config cannot turn it on. +- It narrows remote work and widens nothing: a scheduled turn also needs `remote.enabled`, a folder + inside `remote.roots`, a trusted project where `remote.require_trust`, and a mode within + `remote.max_mode`, exactly as an agent chat does. +- The device says it in `limits.scheduled` (hello and `initialize`). Turning it on or off changes + the limits, so the device dials again once nothing works (as for any limit) and the client hears + it. The client may offer the device in a schedule's form only while `limits.scheduled` is true, + and says why not otherwise. The device judges it again at every `session/new`, `session/prompt` + and `_lembas/monitor` that is scheduled, whatever it said earlier. +- **Off** (or remote work off): every scheduled request is refused with `-32602`, the message + `loop.json` `scheduled.texts.off` word for word, and `data: { reason: "scheduled_off" }`. Not + retried: it is the person's setting. A folder the device does not accept is `-32602` with the + usual reason and `data: { reason: "folder" }`. + +### Starting a scheduled turn + +The client opens an agent session as for an agent chat (`session/new`: `cwd`, `lembas_model`, +`effort`, `_meta.lembas` with `agents` — the schedule's **Min/Max**, as an agent chat's — and the +`Context`), marked scheduled: + +```json +"_meta": { "mode": "edit", "lembas_model": "qwen", "lembas": { + "agents": { "min": 1, "max": 4 }, + "scheduled": { "schedule_id": "sched1", "run_id": "run7", "title": "Waybread check", + "mode": "edit", "approval_wait_s": 1800 } } } +``` + +and runs the turn with `session/prompt`, its `_meta.lembas` carrying the same `scheduled` object +(and a `turnId`, the run's — so a prompt sent again after a dropped link gets the run's answer +instead of a second run). A schedule that continues one conversation (`continue`) prompts the same +session again on its next run; a fresh run opens a new one. The prompt's text is the server's own +for a scheduled run — the instruction, the run's time, and for a schedule that notifies only when +it matters the `[SILENT]` hint — composed exactly as for a run on the server; the device adds nothing. + +**The mode** (`scheduled.mode`; `_meta.mode` is ignored for a scheduled session): `manual`, `edit` +or `auto`, and **`edit` when absent** — so a run is never wider than Edit unless the person picked +Auto on that schedule. `plan` is refused (`scheduled.texts.plan`, `data: { reason: "plan" }`): +nobody is there to approve a plan. Then lowered to `remote.max_mode`, as every remote session is — +Auto runs only where both the schedule and the device allow it (`conformance/scheduled.json`). The +mode holds for the turn; neither `_lembas/configure` nor a command in the prompt widens it. + +In a scheduled turn: + +- **Approvals** go to the person through the client (`session/request_permission` with + `_meta.lembas.scheduled: { schedule_id, run_id, wait_s }`), and the device waits + `approval_wait_s` — the schedule's wait, 30 minutes by default, clamped to + `scheduled.approval_wait_seconds` — instead of `remote.approval_timeout`. Unanswered by then, the + call is denied with `scheduled.texts.denied` as the feedback the model reads (`{{minutes}}` the + wait in whole minutes), the card is settled (`_lembas/permission/settled`), and the turn goes on — + the model works around the refusal or fails. Not `patient`. +- **`ask_user` and `plan_submit`** have nobody to answer: the device answers them as it does with + no client (`ask_user` tells the model nobody can be asked; `plan_submit` saves the plan and says + to stop). `show` is not offered: nobody is looking. +- Subagents as in an agent chat (`agents`, the tree, findings); a subagent's call only the person + can allow is an approval like the main agent's, with `scheduled` beside `subagent`. + +### How the turn ends and reports + +`session/prompt`'s answer carries the run's outcome in `_meta.lembas.scheduled`: + +```json +{ "stopReason": "end_turn", "_meta": { "lembas": { "turnId": "run7", + "scheduled": { "run_id": "run7", "status": "ok", "text": "All the waybread is wrapped.", + "error": null } } } } +``` + +- **`text`**: the turn's final reply — the text of the last assistant message the turn wrote, as + written (`[SILENT]` included). Model output: untrusted wherever it is shown. +- **`status`**: `cancelled` — the turn was cancelled (`session/cancel`: the person stopped it, or the + client's inactivity limit); `failed` — the turn ended on an error (`error` its text), or with no + reply text at all (`scheduled.texts.no_reply`); `silent` — the reply says the sentinel + `[SILENT]` on its first or last non-empty line (any case, a trailing full stop forgiven; + `conformance/silent.json`, the server's own rule for its runs); otherwise `ok`. A turn that hit the + step backstop or a budget and still wrote a reply is `ok`; `stopReason` says why it stopped. +- The client records the run from this (its history, its notification by the schedule's policy — + `silent` is never notified, a failure is told as a failure), and keeps the session as the run's + agent chat, whose events it received as for any agent chat. What the sentinel stripped looks like + is `silent.json`'s `stripped`. + +### When the device is away, or refuses + +- **Away** (not linked when the run is due, or the link drops before `session/new` is answered): the + client treats the device like a busy model — the run is `waiting` (`waiting_for: "device"`) and + tried again on the same steps a run waiting for a free model is, until the run's window closes; + then it is **missed**. A turn already running when the link drops is followed again by + `_lembas/session/status` and the turn id, as for any agent chat. +- **Refused** (`-32602` from a scheduled `session/new`, `session/prompt` or `_lembas/monitor`): the + run **fails** with the device's message, said in the run's history and in the failure + notification; `data.reason` (`scheduled_off`, `folder`, `plan`, `permission`) lets the client say + what to change. Not retried. + +### Monitor mode: a command watched on the device + +A schedule may **watch** something and run the model only when it changed. A URL is fetched by the +server and needs nothing from the device; a **command's output** is watched on the device with +`_lembas/monitor` (below): the client asks the device to run the watch command, compares the +answer's `hash` with the one the previous check stored, and when it is the same records the run as +`unchanged` — no model, nobody notified. Changed (or the first check), the model runs as the +schedule says — on the server, or as a scheduled turn on the device — told what changed through the +`excerpt`. A check that fails (timed out, refused) is a failed run. + +The command runs under the **same permission rules as the schedule's mode**, with one difference: +**a command the mode would ask about is refused**, because a watch has nobody to ask. The device +judges the command line as `bash` would be judged in a session in that folder (the hardline, the +default rules, the global rules and — in a trusted project — the project's), and runs it only on +`allow`; `ask` is refused with `scheduled.texts.monitor_asks`, `deny` with +`scheduled.texts.monitor_denied`, both `-32602` with `data: { reason: "permission" }` +(`conformance/monitor.json`). + +**The hash** (`monitor.json` `hash` cases): `sha256:` and the lowercase hex SHA-256 of the exit +status as decimal digits, a newline, and then the output's bytes exactly as read — standard output +and standard error as one stream, as the `bash` tool reads them. Nothing is normalised: a watch +command that prints a time changes every time, and should not. + ## `_lembas/*` methods Direction is who sends it. "→ result" marks a request; the others are notifications. @@ -313,7 +447,9 @@ Direction is who sends it. "→ result" marks a request; the others are notifica ### `_lembas/hello` — agent → client Sent once when the link opens, before `initialize`: -`{ protocol, version, harness_spec, device: { host, platform }, limits: { roots, max_mode, require_trust } }`. +`{ protocol, version, harness_spec, device: { host, platform }, limits: { roots, max_mode, require_trust, scheduled } }` +(`scheduled` since spec 3.5.0: whether the device accepts scheduled work; a change of it is a +change of the limits, below). A device links whether its remote work is on or not, and with no roots at all. Off, or with no roots, it says `limits.roots: []` here and in `initialize`, and refuses every `session/new`, every @@ -350,6 +486,22 @@ client hears the new limits. | `phase` | `progress` — the progress object (`progress.md` §1), or `null` when nothing runs; `agent?` — the subagent's id when it is a subagent's, absent for the main agent's (capability `progress`). Sent when the phase changes, and for a change of its data at most four times a second. The existing `progress` event (the prompt's tokens read) is unchanged | | `team` | `tree` — the session's subagent tree (`subagents.md` §12, the data) — whenever it changes, at most four times a second (capability `tree`). A client draws it and fetches nothing else to do so | +### `_lembas/monitor` — client → agent → result (capability `scheduled`) + +The watch command of a schedule in monitor mode (*Monitor mode*, above). Params +`{ cwd, command, mode?, timeout_s?, schedule_id?, run_id? }` — `cwd` absolute, judged as +`session/new`'s; `mode` the schedule's (`manual`, `edit` or `auto`; `edit` when absent; `plan` +refused), lowered to `remote.max_mode`; `timeout_s` defaulting to and clamped by +`loop.json` `scheduled.monitor.timeout_seconds`. Refused like any scheduled request when the device +does not accept scheduled work. The command runs as `bash -c` in `cwd`, in its own process group, +with no input; at the timeout the group is stopped. + +Result `{ hash, exit, timed_out, excerpt, truncated, bytes, duration_ms }`: `hash` as above (`null` +when it timed out), `exit` the exit status (`null` on a signal or a timeout), `excerpt` the start of +the output decoded as UTF-8 (invalid bytes replaced), at most `scheduled.monitor.excerpt_chars` +characters, `truncated` whether there was more, `bytes` the output's length. The device keeps at +most `scheduled.monitor.output_max_bytes` of output and hashes all of it as it streams. + ### `_lembas/directories` — client → agent → result Params `{ path?: string }`. Without a path: `{ path: "", places: Dir[] }`, the remote roots and every @@ -682,6 +834,24 @@ Besides `/v1/models`, the chat completions, `/api/v1/instance` and `/mcp`: older `library` scope counts as `memory`. Local memory files are not synced up; logged out, there is no memory. +- **Schedules** (spec 3.5.0): the schedule routes of `/api/v2` — `GET`/`POST /api/v2/schedules`, + `GET`/`PATCH`/`DELETE /api/v2/schedules/{id}`, `POST …/{id}/pause`, `/resume`, `/run`, + `GET …/{id}/runs`, `GET …/{id}/runs/{run}`, `POST /api/v2/schedules/compile` and `/preview` — + **accept the device's token** (scope `link`) as well as an app's (`app`), for the same person and + under the same `schedule.use` permission; nothing else of `/api/v2` does. The shapes are the + server's (its contract fixtures); what the CLI's `/schedule` relies on: a schedule's `id`, + `title`, `instruction`, `description`, `enabled`, `next_run`, `last_run` (`status`, `timing`, + `due_at`, `finished_at`, `preview`, `error.text`), `running`; a run's `id`, `status`, `due_at`, + `started_at`, `finished_at`, `model.name`, `preview`, `error`; compile's `ok`, `reason`, `title`, + `instruction`, `rule`, `description`, `next_runs`, `timezone`. A run's `status` may also be + **`unchanged`** (monitor mode: the watch saw nothing new; no model ran), and `waiting_for` may be + **`device`**. A schedule that runs on a linked computer has + **`runs_on: { device, device_name, cwd, mode, approval_wait_minutes }`** (else `null`); one that + watches has **`watch: { kind: "url", url } | { kind: "command", command, timeout_s }`** (else + `null`). Creating one with the device's token, `runs_on.device: "this"` names the calling device. + A server without these answers 401 or 403 to the device's token, and the CLI says the server is + older. + ## Notes - `_lembas/files/search`'s `root` is the session's working directory, which `@path` mentions diff --git a/harness/conformance/monitor.json b/harness/conformance/monitor.json new file mode 100644 index 0000000..dca9a5d --- /dev/null +++ b/harness/conformance/monitor.json @@ -0,0 +1,123 @@ +{ + "about": "Spec 3.5.0 (acp.md *Monitor mode*, `_lembas/monitor`): the watch command on a device. `hash`: `exit` and `output` (the bytes as UTF-8) → the answer's `hash`. `judge`: a command line in the schedule's mode, with the default rules (and any extra global rules given), the project root /project and nothing named on disk, as conformance/permission.json judges it → `run` (the mode allows it without asking), or `{refused}` with `asks` (the mode would ask: scheduled.texts.monitor_asks) or `denied` (scheduled.texts.monitor_denied). The device's own; the server keeps only the hash.", + "cases": [ + { + "about": "Exit 0 and a line of output.", + "kind": "hash", + "exit": 0, + "output": "ok\n", + "expect": "sha256:9dbd5131695af2d8636ea64178344ffcb5d682333c2ac8342153b5f8a999037a" + }, + { + "about": "The same output with another exit status is a change.", + "kind": "hash", + "exit": 1, + "output": "ok\n", + "expect": "sha256:262115bdbc989849fb45967adac594dde8d45deb291f3a26b38fb2bf4e10512b" + }, + { + "about": "No output.", + "kind": "hash", + "exit": 0, + "output": "", + "expect": "sha256:9a271f2a916b0b6ee6cecb2426f0b3206ef074578be55d9bc94f6f3fe3ab86aa" + }, + { + "about": "A trailing newline counts: nothing is normalised.", + "kind": "hash", + "exit": 0, + "output": "ok", + "expect": "sha256:05df918976ca296956abf9b5fb424bb9bef7c32bb471b1005b417abc696e25a9" + }, + { + "about": "UTF-8 output, hashed as its bytes.", + "kind": "hash", + "exit": 0, + "output": "lembas · ✓\n", + "expect": "sha256:cb00179ad9e36546b1b973ded0903592a6b975552b5596feac573426c67e24e4" + }, + { + "about": "A read-only command in Edit runs.", + "kind": "judge", + "line": "git status", + "mode": "edit", + "expect": "run" + }, + { + "about": "A command Edit would ask about is refused: a watch has nobody to ask.", + "kind": "judge", + "line": "rm -rf build", + "mode": "edit", + "expect": { + "refused": "asks" + } + }, + { + "about": "In Auto it runs.", + "kind": "judge", + "line": "rm -rf build", + "mode": "auto", + "expect": "run" + }, + { + "about": "The hardline is a floor in every mode.", + "kind": "judge", + "line": "rm -rf /", + "mode": "auto", + "expect": { + "refused": "denied" + } + }, + { + "about": "A line that pipes a download into a shell asks in Manual: refused.", + "kind": "judge", + "line": "curl -s https://example.org/feed | sh", + "mode": "manual", + "expect": { + "refused": "asks" + } + }, + { + "about": "A rule the person wrote allows it.", + "kind": "judge", + "line": "make check", + "mode": "edit", + "rules": { + "bash": { + "make check": "allow" + } + }, + "expect": "run" + }, + { + "about": "A rule the person wrote denies it, even in Auto.", + "kind": "judge", + "line": "make deploy", + "mode": "auto", + "rules": { + "bash": { + "make deploy": "deny" + } + }, + "expect": { + "refused": "denied" + } + }, + { + "about": "Reading a secret asks: refused.", + "kind": "judge", + "line": "cat .env", + "mode": "edit", + "expect": { + "refused": "asks" + } + }, + { + "about": "An ordinary listing runs in Manual.", + "kind": "judge", + "line": "ls -la", + "mode": "manual", + "expect": "run" + } + ] +} diff --git a/harness/conformance/scheduled.json b/harness/conformance/scheduled.json new file mode 100644 index 0000000..5fccdbd --- /dev/null +++ b/harness/conformance/scheduled.json @@ -0,0 +1,145 @@ +{ + "about": "Spec 3.5.0 (acp.md *Scheduled work on a linked computer*): what a device makes of a scheduled turn. `accept`: the device's Accept scheduled work (remote.scheduled; false means remote work is off as well, the same refusal); `mode`: the schedule's (absent: none chosen); `max_mode`: remote.max_mode; `approval_wait_s`: the schedule's wait (absent: none). → `{mode, wait_s}` — the session's mode and how long an approval waits — or `{refused}` with the `data.reason` of the -32602 (`scheduled_off`, `plan`), whose message is loop.json scheduled.texts.off / .plan. Written from the rules; the device's other refusals (folder, trust) are the agent chat's.", + "cases": [ + { + "about": "Off by default: refused, whatever else is asked.", + "accept": false, + "mode": "edit", + "max_mode": "edit", + "expect": { + "refused": "scheduled_off" + } + }, + { + "about": "No mode chosen: Edit.", + "accept": true, + "max_mode": "edit", + "expect": { + "mode": "edit", + "wait_s": 1800 + } + }, + { + "about": "No mode chosen, the device allows Auto: still Edit — Auto only when the schedule says so.", + "accept": true, + "max_mode": "auto", + "expect": { + "mode": "edit", + "wait_s": 1800 + } + }, + { + "about": "Auto chosen and allowed.", + "accept": true, + "mode": "auto", + "max_mode": "auto", + "expect": { + "mode": "auto", + "wait_s": 1800 + } + }, + { + "about": "Auto chosen, the device allows Edit at most: lowered.", + "accept": true, + "mode": "auto", + "max_mode": "edit", + "expect": { + "mode": "edit", + "wait_s": 1800 + } + }, + { + "about": "Edit chosen, the device allows Manual at most: lowered.", + "accept": true, + "mode": "edit", + "max_mode": "manual", + "expect": { + "mode": "manual", + "wait_s": 1800 + } + }, + { + "about": "Manual chosen.", + "accept": true, + "mode": "manual", + "max_mode": "auto", + "expect": { + "mode": "manual", + "wait_s": 1800 + } + }, + { + "about": "Plan is refused: nobody is there to approve a plan.", + "accept": true, + "mode": "plan", + "max_mode": "auto", + "expect": { + "refused": "plan" + } + }, + { + "about": "An unknown mode is read as the default, never as anything wider.", + "accept": true, + "mode": "unrestricted", + "max_mode": "auto", + "expect": { + "mode": "edit", + "wait_s": 1800 + } + }, + { + "about": "The device allows Plan at most: every scheduled turn is lowered to it.", + "accept": true, + "mode": "edit", + "max_mode": "plan", + "expect": { + "mode": "plan", + "wait_s": 1800 + } + }, + { + "about": "The wait, in seconds, as the schedule says.", + "accept": true, + "mode": "edit", + "max_mode": "edit", + "approval_wait_s": 600, + "expect": { + "mode": "edit", + "wait_s": 600 + } + }, + { + "about": "Below the floor: one minute.", + "accept": true, + "mode": "edit", + "max_mode": "edit", + "approval_wait_s": 5, + "expect": { + "mode": "edit", + "wait_s": 60 + } + }, + { + "about": "Above the ceiling: a day.", + "accept": true, + "mode": "edit", + "max_mode": "edit", + "approval_wait_s": 999999, + "expect": { + "mode": "edit", + "wait_s": 86400 + } + }, + { + "about": "A fraction is cut to whole seconds.", + "accept": true, + "mode": "edit", + "max_mode": "edit", + "approval_wait_s": 90.7, + "expect": { + "mode": "edit", + "wait_s": 90 + } + } + ] +} diff --git a/harness/conformance/silent.json b/harness/conformance/silent.json new file mode 100644 index 0000000..0289b3d --- /dev/null +++ b/harness/conformance/silent.json @@ -0,0 +1,101 @@ +{ + "about": "Spec 3.5.0: whether a scheduled run's reply is `silent` — loop.json scheduled.sentinel on its first or last non-empty line, compared after trimming, any case, every trailing full stop forgiven — and the reply with every sentinel line taken out (`stripped`, the text a preview shows), trimmed. The server's rule for its own runs, and the device's for a scheduled turn.", + "cases": [ + { + "about": "The sentinel alone.", + "text": "[SILENT]", + "expect": { + "silent": true, + "stripped": "" + } + }, + { + "about": "Lower case, with a full stop.", + "text": "[silent].", + "expect": { + "silent": true, + "stripped": "" + } + }, + { + "about": "On the last line, after a sentence.", + "text": "Nothing new since yesterday.\n[SILENT]", + "expect": { + "silent": true, + "stripped": "Nothing new since yesterday." + } + }, + { + "about": "On the first line, before a sentence.", + "text": "[SILENT]\nChecked the feed: nothing.", + "expect": { + "silent": true, + "stripped": "Checked the feed: nothing." + } + }, + { + "about": "In the middle: not silent (only the first or last line counts), and the line still goes from the preview.", + "text": "Two new posts.\n[SILENT]\nBoth about waybread.", + "expect": { + "silent": false, + "stripped": "Two new posts.\nBoth about waybread." + } + }, + { + "about": "Inside a sentence: not silent.", + "text": "I would say [SILENT] but there is news: the build broke.", + "expect": { + "silent": false, + "stripped": "I would say [SILENT] but there is news: the build broke." + } + }, + { + "about": "Blank lines around it are not lines.", + "text": "\n\n [SILENT] \n\n", + "expect": { + "silent": true, + "stripped": "" + } + }, + { + "about": "A space before the full stop.", + "text": "All quiet.\n[SILENT] .", + "expect": { + "silent": true, + "stripped": "All quiet." + } + }, + { + "about": "Without brackets: not the sentinel.", + "text": "SILENT", + "expect": { + "silent": false, + "stripped": "SILENT" + } + }, + { + "about": "Empty: not silent.", + "text": "", + "expect": { + "silent": false, + "stripped": "" + } + }, + { + "about": "An ordinary reply.", + "text": "The backup finished at 03:12; 4.2 GB.", + "expect": { + "silent": false, + "stripped": "The backup finished at 03:12; 4.2 GB." + } + }, + { + "about": "Two full stops are forgiven too (every trailing one is).", + "text": "[SILENT]..", + "expect": { + "silent": true, + "stripped": "" + } + } + ] +} diff --git a/harness/conformance/team.json b/harness/conformance/team.json index ec53ead..a5fe381 100644 --- a/harness/conformance/team.json +++ b/harness/conformance/team.json @@ -1,5 +1,5 @@ { - "about": "Teams (team.md, spec 3.2.0; the main agent and its tree, subagents.md §5–§13, spec 3.4.0 — a member's team_add is now the lead's with `parent`, and `offered` lists 3.4.0's tools): one lead session's team, step by step, written from a reference model of the rules. `start`: the size setting `min` and `max`, `subagents` (enabled for the session; default true) and the chat model's `capacity` (models/schema.json: `parallel`, `single_session`; {} — no limit). The lead is `lead`; its first prompt is running as the case begins, and every agent is on the chat's model, and it is the only model there is (spec 3.3.0: other models are subagents.json's). Steps and what each answers: `offered` (`by` an agent, default lead) — `{tools, setting}`: the team tools listed for that agent, sorted, and whether the setting is shown; `call` (`by`, `tool`, `args`) — `{result, delivered}`: the call's answer word for word (null where an ordinary tool would run: the case looks only at whether it is refused), and what was delivered to whom by this step, `{name: [message, …]}`; `team_read` answers `{room, delivered}` instead, `room` being the messages it shows as `{n, from, to}` (the tree and the text format are loop.json team.read's, checked by each project's own tests); `task` runs a subagent that counts toward the size (and holds a slot) until `task_done` (`by`) — answers null; `finish` (`by`, `reply`, `reason`: stop by default, or steps, budget, `error — `) — the agent's turn ends: a member's reply is its report — `{delivered}`; `set` (`min`, `max`, `capacity`: the person's setting or the model's metadata changed) — null; `person_stop` (`name`) — the person stopped that member in the UI — null; `user` — the person's message to the lead — `{note}`: the note on it (context.md §10), null when there is none; `state` — the team in tree order as `{name, manager, state}`: working, waiting (for a slot), finished, idle (the lead between turns). Delivering to a finished member or the idle lead wakes it (it is then working, or waiting when its model is full). Spec 3.4.0 adds: `start.mode` — the main agent's mode (default edit); `call` to team_whoami, team_finish, team_findings, team_request, team_decide, team_assign, team_replace, answered word for word; `context` (`by`, `used`, `window`) — its engine's context use — null; `context_full` (`by`) — its context reached team.context.pause_at: `{delivered}`; `started` (`by`) — `{text}`: what its latest turn was started with (a brief, or the messages that woke it); `tree` — `{size, open_slots, agents: [{id, name, parent, state, mode, request}], ended: [{id, name, state}]}`; `main_mode` (`mode`) — the main agent's mode changed — null. LLeMbas: services/team.py; CLI: team/.", + "about": "Teams (team.md, spec 3.2.0; the main agent and its tree, subagents.md §5–§13, spec 3.4.0 — a member's team_add is now the lead's with `parent`, and `offered` lists 3.4.0's tools): one lead session's team, step by step, written from a reference model of the rules. `start`: the size setting `min` and `max`, `subagents` (enabled for the session; default true) and the chat model's `capacity` (models/schema.json: `parallel`, `single_session`; {} — no limit). The lead is `lead`; its first prompt is running as the case begins, and every agent is on the chat's model, and it is the only model there is (spec 3.3.0: other models are subagents.json's). Spec 3.5.0: a subagent added under a sub-manager is said to report to it (`added_under`). Steps and what each answers: `offered` (`by` an agent, default lead) — `{tools, setting}`: the team tools listed for that agent, sorted, and whether the setting is shown; `call` (`by`, `tool`, `args`) — `{result, delivered}`: the call's answer word for word (null where an ordinary tool would run: the case looks only at whether it is refused), and what was delivered to whom by this step, `{name: [message, …]}`; `team_read` answers `{room, delivered}` instead, `room` being the messages it shows as `{n, from, to}` (the tree and the text format are loop.json team.read's, checked by each project's own tests); `task` runs a subagent that counts toward the size (and holds a slot) until `task_done` (`by`) — answers null; `finish` (`by`, `reply`, `reason`: stop by default, or steps, budget, `error — `) — the agent's turn ends: a member's reply is its report — `{delivered}`; `set` (`min`, `max`, `capacity`: the person's setting or the model's metadata changed) — null; `person_stop` (`name`) — the person stopped that member in the UI — null; `user` — the person's message to the lead — `{note}`: the note on it (context.md §10), null when there is none; `state` — the team in tree order as `{name, manager, state}`: working, waiting (for a slot), finished, idle (the lead between turns). Delivering to a finished member or the idle lead wakes it (it is then working, or waiting when its model is full). Spec 3.4.0 adds: `start.mode` — the main agent's mode (default edit); `call` to team_whoami, team_finish, team_findings, team_request, team_decide, team_assign, team_replace, answered word for word; `context` (`by`, `used`, `window`) — its engine's context use — null; `context_full` (`by`) — its context reached team.context.pause_at: `{delivered}`; `started` (`by`) — `{text}`: what its latest turn was started with (a brief, or the messages that woke it); `tree` — `{size, open_slots, agents: [{id, name, parent, state, mode, request}], ended: [{id, name, state}]}`; `main_mode` (`mode`) — the main agent's mode changed — null. LLeMbas: services/team.py; CLI: team/.", "cases": [ { "about": "Offered on a model that serves two requests at once: the lead has all four tools and the setting is shown.", @@ -717,7 +717,7 @@ "delivered": {} }, { - "result": "Added db (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 4 agents.", + "result": "Added db (owns a part) under backend: it is working. Its report goes to backend when it finishes. The team is 3 of 4 agents.", "delivered": {} }, { @@ -1637,7 +1637,7 @@ "delivered": {} }, { - "result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 6 agents.", + "result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 6 agents.", "delivered": {} }, { @@ -1878,7 +1878,7 @@ "delivered": {} }, { - "result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 6 agents.", + "result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 6 agents.", "delivered": {} }, { @@ -2155,11 +2155,11 @@ "delivered": {} }, { - "result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 8 agents.", + "result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 8 agents.", "delivered": {} }, { - "result": "Added db (owns a part): it is working. Its report comes to you when it finishes. The team is 4 of 8 agents.", + "result": "Added db (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 4 of 8 agents.", "delivered": {} }, { @@ -2464,7 +2464,7 @@ "delivered": {} }, { - "result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 5 agents.", + "result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 5 agents.", "delivered": {} }, { @@ -2836,15 +2836,15 @@ "delivered": {} }, { - "result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 8 agents.", + "result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 8 agents.", "delivered": {} }, { - "result": "Added auth (owns a part): it is working. Its report comes to you when it finishes. The team is 4 of 8 agents.", + "result": "Added auth (owns a part) under routes: it is working. Its report goes to routes when it finishes. The team is 4 of 8 agents.", "delivered": {} }, { - "result": "Added db (owns a part): it is working. Its report comes to you when it finishes. The team is 5 of 8 agents.", + "result": "Added db (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 5 of 8 agents.", "delivered": {} }, { @@ -2890,7 +2890,7 @@ } ], { - "result": "Added auth (owns a part): it is working. Its report comes to you when it finishes. The team is 5 of 8 agents.", + "result": "Added auth (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 5 of 8 agents.", "delivered": {} }, { @@ -2968,7 +2968,7 @@ "delivered": {} }, { - "result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 6 agents.", + "result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 6 agents.", "delivered": {} }, { @@ -3647,7 +3647,7 @@ "delivered": {} }, { - "result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 6 agents.", + "result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 6 agents.", "delivered": {} }, { @@ -3978,7 +3978,7 @@ "delivered": {} }, { - "result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 6 agents.", + "result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 6 agents.", "delivered": {} }, { @@ -4382,7 +4382,7 @@ "delivered": {} }, { - "result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 6 agents.", + "result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 6 agents.", "delivered": {} }, { @@ -4843,7 +4843,7 @@ "delivered": {} }, { - "result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 6 agents.", + "result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 6 agents.", "delivered": {} }, { diff --git a/harness/loop.json b/harness/loop.json index 5ab7d67..9f9ade4 100644 --- a/harness/loop.json +++ b/harness/loop.json @@ -146,6 +146,8 @@ "texts": { "added": "Added {{name}} ({{role}}): it is working. Its report comes to you when it finishes. The team is {{size}} of {{max}} agents.", "added_waiting": "Added {{name}} ({{role}}): it waits for a free slot — the model serves {{parallel}} requests at once and all are busy — and starts when one frees. Its report comes to you when it finishes. The team is {{size}} of {{max}} agents.", + "added_under": "Added {{name}} ({{role}}) under {{parent}}: it is working. Its report goes to {{parent}} when it finishes. The team is {{size}} of {{max}} agents.", + "added_waiting_under": "Added {{name}} ({{role}}) under {{parent}}: it waits for a free slot — the model serves {{parallel}} requests at once and all are busy — and starts when one frees. Its report goes to {{parent}} when it finishes. The team is {{size}} of {{max}} agents.", "sent": "Sent to {{to}}.", "stopped": "Stopped {{names}}. The team is {{size}} of {{max}} agents.", "manager": "You lead a team: give this to a member (team_add or team_send).", @@ -438,5 +440,48 @@ ], "what_max": 80, "pace_per_second": 4 + }, + "scheduled": { + "about": "Spec 3.5.0 (acp.md *Scheduled work on a linked computer*): a schedule's run as an agent turn on a linked computer, and the watch command of monitor mode. `setting` is how the device's switch is shown; `modes` the modes a schedule may choose (`default_mode` when it chose none), lowered to remote.max_mode; `approval_wait_seconds` how long an approval in a scheduled turn waits (the schedule's wait, clamped); `sentinel` the word a reply says when there is nothing to tell; `monitor` the watch command's limits. conformance/scheduled.json, silent.json, monitor.json.", + "setting": { + "key": "remote.scheduled", + "label": "Accept scheduled work", + "hint": "Let your schedules run here as agent turns, in the folders remote work may use. Off by default." + }, + "modes": [ + "manual", + "edit", + "auto" + ], + "default_mode": "edit", + "approval_wait_seconds": { + "default": 1800, + "min": 60, + "max": 86400 + }, + "sentinel": "[SILENT]", + "statuses": [ + "ok", + "silent", + "failed", + "cancelled" + ], + "monitor": { + "timeout_seconds": { + "default": 60, + "min": 1, + "max": 600 + }, + "excerpt_chars": 2000, + "output_max_bytes": 1048576 + }, + "texts": { + "off": "This computer does not accept scheduled work. Turn on \"Accept scheduled work\" on it (lembas service scheduled on).", + "plan": "A scheduled run cannot use plan mode: nobody is there to approve a plan. Choose Manual, Edit or Auto.", + "denied": "Nobody answered this approval within {{minutes}} minutes, so it counts as denied.", + "monitor_asks": "The watch command would need approval in {{mode}} mode ({{reason}}), and a watch has nobody to ask. Allow the command in the project's permission rules, or choose a wider mode for the schedule.", + "monitor_denied": "The watch command is refused here: {{reason}}.", + "no_reply": "The run ended without a reply." + } } }