bus, harness: PM task reads that fit a session (#1583)

Row 89, round 1. The broker's `tasks` read takes optional filter and
page arguments and returns a short form. The harness adds `list_tasks`
arguments (open tasks by default), a `get_task` tool, and compact JSON
replies capped below the 50000-character threshold where Claude Code
2.1.296 saves a tool result to a file outside the workspace, which was
the PM's fault in Jason's test. The bundle's tools line names the
harness's own tools.

Candidate manifest b8c5bb3a (agents/dewey/work/queue-89/), base
a3f6e9e6, no drift on the 12 paths. Builder Dewey. Approved by
Darkwing (comment 27940). Sage's gate on f04185fc plus the patch
2026-10-12T22:00:55Z-22:03:38Z: bus 108, harness 65, webui 86, cli
115, control-board 124, every scripts/test-*.sh green.

Live at the next bus restart (broker) and role session launch
(harness), both Mos's call (decision 109); Mos had the notice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 17:04:15 -05:00
co-authored by Claude Opus 5.5
parent 4643911ac1
commit ce104b27cc
12 changed files with 491 additions and 20 deletions
+16 -2
View File
@@ -125,7 +125,8 @@ an operator inspect content and delivery evidence after an uncertain result.
| `message.read` | `{id}`; recipient holder's acknowledgement; no new holder can acknowledge old holder's delivery. |
| `event.emit` | `{kind:'action.allowed',body:{action:'routine',target?},subject?}`; routine observations only. |
| `launch.revoke`, `launch.restore` | No args; outside-agent human CLI only. |
| `inbox`, `tasks` | No args; plain JSON projections scoped to capability's business. |
| `inbox` | No args; plain JSON projection scoped to capability's business. |
| `tasks` | Optional args, scoped to capability's business (row 89, #1583). None: every row of the tasks projection (below). `{task_ref}` alone: that one row, or `task-not-found`. Any of `{done,assignee,label,requirement,limit,after}`: `{tasks,total,next}`, the short form (below). An unknown arg or bad value refuses `invalid-request`. |
| `agents` | No args; each held role's latest claim row in the capability's business. Human and reader sessions also get `state`, `working` and `inputNeeded` (Agent state, below); a role session gets the claim rows only. |
| `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. |
| `decisions` | `{list}`: `roles` or `recent`; human and reader sessions only. Open decisions routed to a role, or the last 50 closed. |
@@ -295,7 +296,20 @@ must not pretend a missing/inaccessible task is open or completed. Each row adds
computed at each read and never stored. `priority` is the current row's `fields.priority`, null
for a tombstone or a read with none (Vikunja's 0 stays 0). `requirement` is the requirement of the
first `task.created` for the same Vikunja task id in the business, so a task moved to another
project keeps it; null for a task the stack didn't create. Trail joins
project keeps it; null for a task the stack didn't create.
A `tasks` read with filters (row 89, #1583) is what a session's `list_tasks` makes; the full
projection is too large for a tool reply on a real board. It leaves out tombstones and pages in
`task_ref` order (as text). Each task is `{task_ref,title,done,bucket,assignees,labels,priority,
requirement,changed_at}`: no description; `assignees` are the roles whose tracker bot is
assigned, sorted, so a person's assignment shows as none; `changed_at` is the row's `at`, as the
Console shows it. Filters: `done` (boolean), `assignee` (a role of the business), `label` (a
label id), `requirement` (`REQ-[A-Z]+-[1-9][0-9]*`). `limit` is 1 to `TASK_PAGE_MAX` (200),
`TASK_PAGE` (50) by default. `after` is a task ref, which needn't be on the board; the page holds
the matches past it. `total` counts every match, `next` is the `after` for the following page,
null on the last.
Trail joins
human request, messages/deliveries, decisions/resolutions, task snapshots and
session/claim evidence. Broker timestamps advance monotonically across all
its writes, including after reopen; per-table `seq` stays the primary table
+60 -2
View File
@@ -42,6 +42,10 @@ const CLOSED = "('resolved','withdrawn','expired')";
export const RECENT = 50;
// How many replies `replies` returns, oldest first.
export const REPLIES = 50;
// Row 89 (#1583): the arguments `tasks` takes, and its page size by default and at most.
const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref'];
export const TASK_PAGE = 50;
export const TASK_PAGE_MAX = 200;
export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task.')));
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
const POLL_KINDS = new Set([
@@ -328,6 +332,59 @@ export class Broker {
return { ...r, fields, priority, requirement: requirement.get(taskId(r.task_ref)) ?? null };
});
}
// Row 89 (#1583): with no arguments, every row as #taskRows gives it, which the Console and
// `mosaic tasks` read. `task_ref`, alone, gives that one row. Any other argument filters and pages
// the short form in task_ref order: no description, and the assignees that are a role's tracker
// bot, by role. A tombstone isn't on the board, so a filtered read leaves it out. `total` counts
// every task the filters match; `next` is the `after` for the next page, null on the last.
#tasks(business, a) {
const rows = this.#taskRows(business);
if (!Object.keys(a).length) return rows;
if ('task_ref' in a) {
if (Object.keys(a).length !== 1 || !taskRef(a.task_ref)) fail('invalid-request');
return rows.find((r) => r.task_ref === a.task_ref) ?? fail('task-not-found');
}
const roles = this.#business(business).roles ?? {};
const positive = (x, max = Number.MAX_SAFE_INTEGER) => Number.isSafeInteger(x) && x >= 1 && x <= max;
if (
('done' in a && typeof a.done !== 'boolean') ||
('assignee' in a && !(typeof a.assignee === 'string' && Object.hasOwn(roles, a.assignee))) ||
('label' in a && !positive(a.label)) ||
('requirement' in a && !(typeof a.requirement === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(a.requirement))) ||
('limit' in a && !positive(a.limit, TASK_PAGE_MAX)) ||
('after' in a && !taskRef(a.after))
)
fail('invalid-request');
const bots = new Map(
Object.entries(roles)
.filter(([, r]) => r.tracker?.botId !== undefined)
.map(([role, r]) => [r.tracker.botId, role]),
);
const matched = rows
.filter((r) => !r.fields.gone)
.map(({ task_ref, fields: f, priority, requirement, at }) => ({
task_ref,
title: f.title ?? null,
done: Boolean(f.done),
bucket: f.bucket ?? null,
assignees: (f.assignees ?? []).filter((u) => bots.has(u)).map((u) => bots.get(u)).sort(),
labels: f.labels ?? [],
priority,
requirement,
changed_at: at,
}))
.filter(
(t) =>
(!('done' in a) || t.done === a.done) &&
(!('assignee' in a) || t.assignees.includes(a.assignee)) &&
(!('label' in a) || t.labels.includes(a.label)) &&
(!('requirement' in a) || t.requirement === a.requirement),
);
const rest = 'after' in a ? matched.filter((t) => t.task_ref > a.after) : matched;
const limit = a.limit ?? TASK_PAGE;
const tasks = rest.slice(0, limit);
return { tasks, total: matched.length, next: rest.length > limit ? tasks.at(-1).task_ref : null };
}
#checkAuthority(s, action, { decision = null, target = null } = {}) {
this.#agent(s);
if (
@@ -841,7 +898,8 @@ export class Broker {
case 'agents':
case 'tasks':
case 'trail': {
keys(a, verb === 'trail' ? ['subject'] : [], verb === 'trail' ? ['subject'] : []);
const allowed = verb === 'trail' ? ['subject'] : verb === 'tasks' ? TASK_ARGS : [];
keys(a, allowed, verb === 'trail' ? ['subject'] : []);
if (!s.human && !s.reader) this.#agent(s);
if (verb === 'inbox')
return this.#store
@@ -859,7 +917,7 @@ export class Broker {
// A role session gets the claims only: another role's decisions aren't its to read.
return s.human || s.reader ? claims.map((c) => this.#agentState(c)) : claims;
}
if (verb === 'tasks') return this.#taskRows(s.business);
if (verb === 'tasks') return this.#tasks(s.business, a);
return this.#trail(s, identifier(a.subject));
}
// Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists, for human and
+225
View File
@@ -0,0 +1,225 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker, TASK_PAGE, TASK_PAGE_MAX } from '../src/broker.mjs';
import { REPLY_CAP } from '../../harness/src/tools.mjs';
// Row 89 (#1583): `tasks` with arguments filters and pages a short form a session can read; with
// none it is the whole board, as the Console and `mosaic tasks` read it.
const authority = { withinRole: ['message.send', 'task.create'], crossRole: [] };
const roles = {
pm: { authority, tracker: { bot: 'bot-pm', botId: 101 } },
coder: { authority, tracker: { bot: 'bot-coder', botId: 102 } },
reviewer: { authority, tracker: null },
};
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles } };
const at = '2026-10-12T12:00:00.000Z';
const digest = 'd'.repeat(64);
const N = 500;
const words = 'the board holds this task so a session can page through it without the description '.repeat(30);
const ref = (i) => `vikunja:3/${i}`;
// Task i: done when i % 5 == 0; the pm's bot on i % 3 == 0, the coder's on i % 4 == 0, a person (7)
// on i % 2 == 0; label 9 on i % 7 == 0; tombstones at 13 and 26.
const fieldsOf = (i) => ({
project_id: 3,
title: `Task ${i}: a title of ordinary length for a tracker card`,
description: `<p>${i} ${words}</p>`,
done: i % 5 === 0,
due_date: null,
priority: i % 4,
percent_done: 0,
bucket: i % 5 === 0 ? 12 : 11,
labels: i % 7 === 0 ? [9, 4] : [4],
assignees: [...(i % 3 === 0 ? [101] : []), ...(i % 4 === 0 ? [102] : []), ...(i % 2 === 0 ? [7] : [])],
});
const GONE = new Set([13, 26]);
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-task-page-'));
const store = new Store(root);
const b = new Broker({ store, businesses });
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const pm = b.bindLaunch({ business: 'demo', role: 'pm', run: 'demo-pm', harness: 'claude-code' });
b.request(pm, { verb: 'role.claim' });
b.recordTask({
business: 'demo',
snapshots: Array.from({ length: N }, (_, k) => k + 1).map((i) => ({
task_ref: ref(i),
updated: at,
digest,
fields: GONE.has(i) ? { gone: 'no-access' } : fieldsOf(i),
via: GONE.has(i) ? 'task' : 'board',
read_at: at,
})),
});
// Task 2 and task 6 were created by the stack, under two requirements.
for (const [i, requirement] of [[2, 'REQ-SLICE-1'], [6, 'REQ-SLICE-2']]) {
const { request } = b.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } });
b.recordTask({
cap: pm,
business: 'demo',
snapshots: [{ task_ref: ref(i), updated: at, digest, fields: fieldsOf(i) }],
events: [{ kind: 'task.created', subject: ref(i), body: { request, requirement } }],
});
}
const tasks = (args, cap = pm) => b.request(cap, { verb: 'tasks', args });
return { b, human, pm, reader: b.bindReader({ business: 'demo' }), tasks };
}
const refusesWith = (code) => (e) => e.code === code;
// Every task, in the broker's order (task_ref, as text).
const ALL = Array.from({ length: N }, (_, k) => k + 1).sort((x, y) => (ref(x) < ref(y) ? -1 : 1));
const live = (keep) => ALL.filter((i) => !GONE.has(i) && keep(i)).map(ref);
// Every page of a read, by following next.
function pages(tasks, args) {
const out = [];
let after;
for (;;) {
const page = tasks(after === undefined ? args : { ...args, after });
out.push(page);
if (page.next === null) return out;
after = page.next;
}
}
test('the session default fits under the reply cap and pages to the end with next', (t) => {
const { tasks } = setup(t);
const whole = tasks({});
assert.equal(whole.length, N);
assert.ok(JSON.stringify(whole).length > 20 * REPLY_CAP, 'the whole board is far over the cap');
const read = pages(tasks, { done: false });
const open = live((i) => i % 5 !== 0);
assert.equal(read.length, Math.ceil(open.length / TASK_PAGE));
for (const page of read) {
assert.ok(JSON.stringify(page).length < REPLY_CAP, `${JSON.stringify(page).length}`);
assert.equal(page.total, open.length);
assert.ok(page.tasks.length <= TASK_PAGE);
}
assert.deepEqual(read.flatMap((p) => p.tasks.map((x) => x.task_ref)), open);
assert.deepEqual(read.slice(0, -1).map((p) => p.next), read.slice(0, -1).map((p) => p.tasks.at(-1).task_ref));
assert.equal(read.at(-1).next, null);
// limit at its most pages the same tasks in fewer pages.
const big = pages(tasks, { done: false, limit: TASK_PAGE_MAX });
assert.equal(big.length, Math.ceil(open.length / TASK_PAGE_MAX));
assert.deepEqual(big.flatMap((p) => p.tasks.map((x) => x.task_ref)), open);
});
test('the short form: no description, assignees by role, every listed key', (t) => {
const { tasks } = setup(t);
const whole = new Map(tasks({}).map((r) => [r.task_ref, r]));
const page = tasks({ limit: TASK_PAGE_MAX });
for (const x of page.tasks) {
const row = whole.get(x.task_ref);
const i = Number(x.task_ref.split('/')[1]);
assert.deepEqual(x, {
task_ref: row.task_ref,
title: row.fields.title,
done: row.fields.done,
bucket: row.fields.bucket,
assignees: [...(i % 4 === 0 ? ['coder'] : []), ...(i % 3 === 0 ? ['pm'] : [])],
labels: row.fields.labels,
priority: row.priority,
requirement: row.requirement,
changed_at: row.at,
});
}
const two = page.tasks.find((x) => x.task_ref === ref(2));
assert.equal(two.requirement, 'REQ-SLICE-1');
assert.equal(JSON.stringify(page).includes('description'), false);
// A tombstone is on the whole board, never in a filtered read.
assert.deepEqual(whole.get(ref(13)).fields, { gone: 'no-access' });
const every = pages(tasks, { limit: TASK_PAGE_MAX }).flatMap((p) => p.tasks.map((x) => x.task_ref));
assert.deepEqual(every, live(() => true));
});
test('each filter, alone and together, and total counts every match', (t) => {
const { tasks } = setup(t);
const refs = (args) => pages(tasks, { ...args, limit: TASK_PAGE_MAX }).flatMap((p) => p.tasks.map((x) => x.task_ref));
assert.deepEqual(refs({ done: true }), live((i) => i % 5 === 0));
assert.deepEqual(refs({ done: false }), live((i) => i % 5 !== 0));
assert.deepEqual(refs({ assignee: 'pm' }), live((i) => i % 3 === 0));
assert.deepEqual(refs({ assignee: 'coder' }), live((i) => i % 4 === 0));
// A role with no tracker bot holds no task; a person's assignment is no role's.
assert.deepEqual(refs({ assignee: 'reviewer' }), []);
assert.deepEqual(refs({ label: 9 }), live((i) => i % 7 === 0));
assert.deepEqual(refs({ label: 4 }), live(() => true));
assert.deepEqual(refs({ label: 5 }), []);
assert.deepEqual(refs({ requirement: 'REQ-SLICE-2' }), [ref(6)]);
assert.deepEqual(refs({ requirement: 'REQ-NONE-1' }), []);
assert.deepEqual(refs({ done: false, assignee: 'pm', label: 9 }), live((i) => i % 5 !== 0 && i % 3 === 0 && i % 7 === 0));
const page = tasks({ done: true, assignee: 'coder', limit: 3 });
assert.equal(page.total, live((i) => i % 5 === 0 && i % 4 === 0).length);
assert.equal(page.tasks.length, 3);
assert.equal(page.next, page.tasks[2].task_ref);
// after needn't be a task on the board, or one the filters match: the page starts past it, in
// text order (3/500 < 3/501 < 3/51), and total still counts every match.
const past = tasks({ after: 'vikunja:3/501', limit: 2 });
assert.deepEqual(past.tasks.map((x) => x.task_ref), [ref(51), ref(52)]);
assert.equal(past.total, live(() => true).length);
assert.deepEqual(tasks({ done: true, after: ref(51), limit: 1 }).tasks.map((x) => x.task_ref), [ref(55)]);
const last = tasks({ after: 'vikunja:9/1' });
assert.deepEqual(last, { tasks: [], total: live(() => true).length, next: null });
// A page that ends exactly on the last match has no next.
const exact = tasks({ requirement: 'REQ-SLICE-1', limit: 1 });
assert.deepEqual([exact.tasks.length, exact.next], [1, null]);
});
test('task_ref reads one task in full, as the whole board has it', (t) => {
const { tasks, human, reader } = setup(t);
const whole = tasks({});
for (const r of [ref(2), ref(13), ref(500)]) assert.deepEqual(tasks({ task_ref: r }), whole.find((x) => x.task_ref === r));
assert.ok(tasks({ task_ref: ref(2) }).fields.description.length > 2000);
assert.deepEqual(tasks({ task_ref: ref(2) }, human), tasks({ task_ref: ref(2) }, reader));
assert.throws(() => tasks({ task_ref: 'vikunja:3/501' }), refusesWith('task-not-found'));
assert.throws(() => tasks({ task_ref: 'vikunja:4/2' }), refusesWith('task-not-found'));
});
test('no arguments is the read the Console and mosaic make, unchanged', (t) => {
const { b, tasks, human, reader } = setup(t);
const rows = b.taskView('demo', 'current');
const whole = tasks({});
assert.deepEqual(whole.map(({ priority, requirement, ...r }) => r), rows);
assert.deepEqual(tasks({}, human), whole);
assert.deepEqual(tasks({}, reader), whole);
assert.deepEqual(b.request(reader, { verb: 'tasks' }), whole);
});
test('an unknown argument or a bad value refuses invalid-request', (t) => {
const { tasks, reader } = setup(t);
const bad = [
{ status: 'open' },
{ description: 'x' },
{ done: 'false' },
{ done: 0 },
{ done: null },
{ assignee: 'cto' },
{ assignee: 'toString' },
{ assignee: 101 },
{ label: 0 },
{ label: -1 },
{ label: 1.5 },
{ label: '9' },
{ requirement: 'REQ-slice-1' },
{ requirement: 'REQ-SLICE-0' },
{ requirement: 7 },
{ limit: 0 },
{ limit: TASK_PAGE_MAX + 1 },
{ limit: 2.5 },
{ limit: '50' },
{ after: 'vikunja:3' },
{ after: 3 },
{ task_ref: 'vikunja:3/x' },
{ task_ref: ref(2), done: false },
{ task_ref: ref(2), limit: 1 },
];
for (const args of bad) {
assert.throws(() => tasks(args), refusesWith('invalid-request'), JSON.stringify(args));
assert.throws(() => tasks(args, reader), refusesWith('invalid-request'), JSON.stringify(args));
}
assert.deepEqual([TASK_PAGE, TASK_PAGE_MAX], [50, 200]);
});
+25 -1
View File
@@ -41,7 +41,10 @@ writes the bundle once into `dir` (0700; every file 0600, created with `wx`,
so an existing bundle is never overwritten):
- `prompt.md`: the role contract, then a generated "This session" section
(business, instance, run, who launched it, workspace, typed tools).
(business, instance, run, who launched it, workspace, built-in and typed
tools). The built-in tools are named as the harness names them, as the
manifest lists them: `limits.tools` for Pi, `claudeBuiltins` for Claude
Code (Read, Write, Edit, Grep, Glob).
- `policy.json`: harness, workspace, built-in tools (`limits.tools`), typed
tool names, network, the services the role needs, and the credential
status the host read from the broker (metadata only, never a token).
@@ -71,6 +74,20 @@ The harness never holds the capability. Tools call the runner's tool socket
against `tools.json` and forwards the call with its own capability. A
refusal comes back to the model as `refused: <code>`.
A reply goes to the model as compact JSON, from the MCP server and the Pi
extension alike (`replyText` in `tools.mjs`). Over `REPLY_CAP`, 40000
characters, the tool returns an error that names the reply's size and the
cap and asks the session to narrow the query, never a cut-off reply. The
pinned Claude CLI (2.1.296) saves an MCP result over 50000 characters to a
file outside the workspace, which the gate refuses to read (row 89, #1583;
`agents/dewey/work/queue-89/build.md` has how that size was found).
`list_tasks` (tracker holders) takes the broker's `tasks` filters, `done`,
`assignee`, `label`, `requirement`, `limit` and `after`, and sends
`done: false` when the session leaves `done` out, so its default is open
tasks, 50 at a time, without descriptions. `get_task` takes a `task_ref`
and returns that task in full. `packages/bus/README.md` has the reply.
## The gate
`gate.mjs` `decide(policy, tool, input)` answers one tool call: the
@@ -188,6 +205,13 @@ These are limits, not bugs, and nothing in this package claims otherwise.
- **Resolution** runs without the project layer, there is no skills source
(bundles list none), and the resolved `thinking` level is recorded in the
manifest but not applied to either harness.
- **The reply cap is per reply.** Claude Code also persists the largest
results of one message when their total passes 200000 characters, so
six or more replies near the cap in one turn can still be saved to a
file. A single task whose full row is over the cap (a description near
40000 characters) can't be read through `get_task`. The cap matches
Claude CLI 2.1.296; the binary the bus unit names updates itself, so a
later version can move the threshold.
- **`--restricted`** (Claude Code) is not one of the S0 lines. It is what
keeps `CLAUDE.md` files and auto-memory out of the prompt (above).
- **Other spellings.** A read is refused when a spelling Pi might open
+2 -1
View File
@@ -64,7 +64,8 @@ function prompt({ contract, resolved, session, tools, untyped }) {
`- Business: ${resolved.business}. Role instance: ${resolved.instance} (definition ${resolved.definition}). Run: ${session.run}.`,
`- Launched by: ${session.launchedBy}. Harness: ${session.harness}, model ${session.model}.`,
`- Workspace: ${session.workspace}. File tools work inside it only.`,
`- Built-in tools: ${resolved.limits.tools.length ? resolved.limits.tools.join(", ") : "none"}.`,
// Row 89 (#1583): the names the harness gives its built-ins, as the manifest lists them.
`- Built-in tools: ${(session.harness === "pi" ? resolved.limits.tools : claudeBuiltins({ tools: resolved.limits.tools })).join(", ") || "none"}.`,
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal names its reason.`,
"- Each message to you arrives as one turn. Your final answer goes back to the sender as the reply, so end each turn with the answer itself.",
"- You never hold a token. You act on the tracker, the forge and other roles only through the bus tools.",
+4 -3
View File
@@ -1,11 +1,12 @@
// Minimal stdio MCP server that gives a Claude Code session its typed
// tools: `node mcp-server.mjs <tools.json> <tool socket>`. Newline-delimited
// JSON-RPC 2.0 with initialize, tools/list, tools/call and ping. Each call
// goes to the runner's tool socket; the server holds no capability.
// goes to the runner's tool socket; the server holds no capability. A
// reply over REPLY_CAP is a tool error (tools.mjs, replyText).
import { readFileSync } from "node:fs";
import { createInterface } from "node:readline";
import { callTool } from "./tools.mjs";
import { callTool, replyText } from "./tools.mjs";
const [toolsFile, socket] = process.argv.slice(2);
if (!toolsFile || !socket) {
@@ -37,7 +38,7 @@ async function handle(m) {
if (!byName.has(name)) return error(id, -32602, `unknown tool: ${name}`);
try {
const result = await callTool(socket, name, m.params?.arguments ?? {});
return reply(id, { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] });
return reply(id, { content: [{ type: "text", text: replyText(result) }] });
} catch (e) {
return reply(id, { content: [{ type: "text", text: e.message }], isError: true });
}
+3 -3
View File
@@ -10,7 +10,7 @@
import { readFileSync, writeFileSync } from "node:fs";
import { decide } from "./gate.mjs";
import { callTool } from "./tools.mjs";
import { callTool, replyText } from "./tools.mjs";
function need(name) {
const value = process.env[name];
@@ -40,9 +40,9 @@ export default function (pi) {
description: t.description,
parameters: t.parameters,
async execute(_toolCallId, params) {
// A refusal throws, which pi reports to the model as a tool error.
// A refusal, or a reply over REPLY_CAP, throws, which pi reports to the model as a tool error.
const result = await callTool(socket, t.name, params ?? {});
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: {} };
return { content: [{ type: "text", text: replyText(result) }], details: {} };
},
});
}
+36 -2
View File
@@ -16,6 +16,11 @@ const str = (description, extra = {}) => ({ type: "string", description, ...extr
const obj = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
const TASK_REF = str("Task reference, as list_tasks shows it");
// The broker's page size for `tasks`, by default and at most (TASK_PAGE and TASK_PAGE_MAX in
// packages/bus/src/broker.mjs; a test holds them equal). Kept here so the session's tool server
// doesn't load the broker.
const TASK_PAGE = 50;
const TASK_PAGE_MAX = 200;
const COMMON = {
task_ref: TASK_REF,
decision: str("Id of a resolved decision that approves this call; needed when the action is cross-role or gated for you"),
@@ -113,10 +118,39 @@ export function typedTools({ resolved, arbiter = false, tracker = false }) {
add("list_agents", "agents", null, "List the role instances that hold a claim now.", obj({}));
add("inbox", "inbox", null, "List the open decisions routed to you.", obj({}));
add("trail", "trail", null, "Show the evidence trail for a subject: a run, message, decision or task.", obj({ subject: str("Subject id") }, ["subject"]));
if (tracker) add("list_tasks", "tasks", null, "List the business's tracker tasks as the broker last saw them.", obj({}));
if (tracker) {
add("list_tasks", "tasks", null, `List the business's tracker tasks as the broker last saw them, in short form with no description. By default open tasks (done false), ${TASK_PAGE} at a time; pass the reply's next as after for the next page. get_task reads one task in full.`, obj({
done: { type: "boolean", description: "true for closed tasks, false (the default) for open ones" },
assignee: str("Only tasks assigned to this role"),
label: { type: "integer", minimum: 1, description: "Only tasks with this label id" },
requirement: str("Only tasks created for this requirement id", { pattern: "^REQ-[A-Z]+-[1-9][0-9]*$" }),
limit: { type: "integer", minimum: 1, maximum: TASK_PAGE_MAX, description: `Tasks per page, ${TASK_PAGE} by default` },
after: str("The next value of the previous page"),
}));
add("get_task", "tasks", null, "Read one tracker task in full, description included.", obj({ task_ref: TASK_REF }, ["task_ref"]));
}
return tools;
}
// Arguments a tool sends when the session leaves them out. list_tasks always filters: the
// broker's `tasks` with no arguments is the whole board, which is the Console's read.
const ARG_DEFAULTS = { list_tasks: { done: false } };
// The most characters a tool reply may hold. The pinned Claude CLI (2.1.296, decision 91) saves
// an MCP result over 50000 characters to a file outside the workspace, which the run's file guard
// refuses to read (#1583; agents/dewey/work/queue-89/build.md has how that was found). The cap keeps
// 20% under it, counts as JavaScript does (UTF-16 units, as the CLI does), and holds for pi too.
export const REPLY_CAP = 40_000;
// The text a tool returns to the model: compact JSON, or an error over the cap. A reply is never
// cut short.
export function replyText(result) {
const text = JSON.stringify(result) ?? "null";
if (text.length > REPLY_CAP)
throw new Error(`reply too large: ${text.length} characters, over the ${REPLY_CAP}-character cap. Narrow the query: a filter, a smaller limit, or get_task for one task.`);
return text;
}
// Held actions that have no typed tool. The manifest lists them so a
// reviewer sees what the session can't do through the bus.
export function untypedActions(resolved, tools) {
@@ -137,7 +171,7 @@ export function callTool(path, tool, args = {}, { timeout = 60_000 } = {}) {
error ? reject(error) : resolve(value);
};
socket.setTimeout(timeout, () => end(new Error("tool call timed out")));
socket.on("connect", () => socket.write(JSON.stringify({ tool, args }) + "\n"));
socket.on("connect", () => socket.write(JSON.stringify({ tool, args: { ...ARG_DEFAULTS[tool], ...args } }) + "\n"));
socket.on("error", (e) => end(new Error(`tool socket: ${e.code ?? e.message}`)));
socket.on("end", () => end(new Error("tool socket closed without a reply")));
socket.on("data", (b) => {
+14
View File
@@ -87,6 +87,20 @@ test("a claude-code bundle adds the wrapped gate hook and the MCP config", (t) =
assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "gate", "mcpServer", "tools"]);
});
// Row 89 (#1583): the prompt names the built-ins as the session's harness names them.
test("the prompt's built-in tools line is the harness's own names, as the manifest lists them", (t) => {
const line = (b) => readFileSync(b.files.prompt, "utf8").split("\n").find((l) => l.startsWith("- Built-in tools: "));
const pi = build(t, resolvedFor(t, "pm"));
assert.equal(line(pi), "- Built-in tools: read, write, edit, grep, find, ls.");
const claude = build(t, resolvedFor(t, "pm", (d) => (d.roles.pm.vars = { ...d.roles.pm.vars, harness: "claude-code", model: "claude-opus-5-5" })));
assert.equal(line(claude), "- Built-in tools: Read, Write, Edit, Grep, Glob.");
for (const b of [pi, claude]) assert.equal(line(b), `- Built-in tools: ${b.manifest.builtins.join(", ")}.`);
for (const harness of ["pi", "claude-code"]) {
const none = build(t, resolvedFor(t, "pm", (d) => (d.roles.pm.vars = { ...d.roles.pm.vars, harness, model: "claude-opus-5-5", "limits.tools": [] })));
assert.equal(line(none), "- Built-in tools: none.", harness);
}
});
test("a bundle is written once: an existing file refuses", (t) => {
const dir = scratch(t);
mkdirSync(join(dir, "bundle"));
+22 -1
View File
@@ -5,7 +5,7 @@ import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { SOURCES } from "../src/bundle.mjs";
import { typedTools } from "../src/tools.mjs";
import { REPLY_CAP, typedTools } from "../src/tools.mjs";
import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs";
// The server over stdio: send(message) resolves with the reply to its id.
@@ -43,6 +43,8 @@ async function setup(t) {
writeFileSync(toolsFile, JSON.stringify(tools));
const sock = await fakeToolSocket(t, dir, (tool, args) => {
if (tool === "launch") throw new Error("capacity-full");
// trail answers a string `size` characters long once JSON-encoded, for the reply cap.
if (tool === "trail" && /^size:/.test(args.subject)) return "x".repeat(Number(args.subject.slice(5)) - 2);
return { tool, args };
});
return { dir, tools, sock, server: serve(t, toolsFile, sock.path) };
@@ -70,6 +72,25 @@ test("tools/call goes through the tool socket; a refusal is an isError result",
assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]);
});
test("replies are compact JSON; over the cap a tool error names the size and the cap", async (t) => {
const { sock, server } = await setup(t);
const call = async (name, args) => (await server.send("tools/call", { name, arguments: args })).result;
const ok = await call("get_task", { task_ref: "vikunja:3/7" });
assert.equal(ok.content[0].text, '{"tool":"get_task","args":{"task_ref":"vikunja:3/7"}}');
// list_tasks with no arguments asks the broker for open tasks.
assert.equal((await call("list_tasks", {})).content[0].text, '{"tool":"list_tasks","args":{"done":false}}');
const fits = await call("trail", { subject: `size:${REPLY_CAP}` });
assert.equal(fits.isError, undefined);
assert.equal(fits.content[0].text.length, REPLY_CAP);
const over = await call("trail", { subject: `size:${REPLY_CAP + 1}` });
assert.equal(over.isError, true);
assert.deepEqual(over.content, [{
type: "text",
text: `reply too large: ${REPLY_CAP + 1} characters, over the ${REPLY_CAP}-character cap. Narrow the query: a filter, a smaller limit, or get_task for one task.`,
}]);
assert.deepEqual(sock.calls.map((c) => c.tool), ["get_task", "list_tasks", "trail", "trail"]);
});
test("unknown tools and methods are JSON-RPC errors and never reach the socket", async (t) => {
const { sock, server } = await setup(t);
const unknown = await server.send("tools/call", { name: "resolve_decision", arguments: {} });
+28 -1
View File
@@ -8,6 +8,7 @@ import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from "
import { join } from "node:path";
import { buildBundle } from "../src/bundle.mjs";
import { adapterEnv } from "../src/runner.mjs";
import { REPLY_CAP } from "../src/tools.mjs";
import { fakeToolSocket, mockAnthropic, REPO, resolvedFor, scratch } from "./helpers.mjs";
const ADAPTER = join(REPO, "adapters", "pi", "adapter.sh");
@@ -25,6 +26,8 @@ async function session(t, script, place = (dir) => join(dir, "ws")) {
const resolved = resolvedFor(t, "pm", (d) => (d.roles.pm.vars = { ...d.roles.pm.vars, model: "probe-model" }));
const sock = await fakeToolSocket(t, dir, (tool, args) => {
if (tool === "launch") throw new Error("capacity-full");
// trail answers a string `size` characters long once JSON-encoded, for the reply cap.
if (tool === "trail" && /^size:/.test(args.subject)) return "x".repeat(Number(args.subject.slice(5)) - 2);
return { tool, args };
});
const s = {
@@ -78,7 +81,7 @@ test("pi: typed tools reach the socket, the gate blocks, agent_end writes the ma
assert.equal(results.length, 5);
// list_agents went through the socket; launch was refused by it.
assert.equal(results[0][0], false);
assert.match(results[0][1], /"tool": "list_agents"/);
assert.match(results[0][1], /^\{"tool":"list_agents"/);
assert.equal(results[1][0], true);
assert.match(results[1][1], /refused: capacity-full/);
// read inside passes; outside is gated; bash isn't in the PM's tools.
@@ -96,6 +99,30 @@ test("pi: typed tools reach the socket, the gate blocks, agent_end writes the ma
assert.ok(readdirSync(s.sessionDir).length > 0);
});
test("pi: a reply over the cap is a tool error naming the size and the cap; list_tasks asks for open tasks", async (t) => {
const { sock, s, env } = await session(t, [
{ name: "trail", input: { subject: `size:${REPLY_CAP}` } },
{ name: "trail", input: { subject: `size:${REPLY_CAP + 1}` } },
{ name: "list_tasks", input: {} },
{ name: "get_task", input: { task_ref: "vikunja:3/7" } },
]);
const r = await turn(env, "Message 1 from jason, class REQUEST:\n\nshow the board", s.workspace);
assert.equal(r.code, 0, r.stderr);
const results = JSON.parse(r.stdout.trim().slice("ANSWER ".length));
assert.equal(results.length, 4);
assert.deepEqual(results[0], [false, `"${"x".repeat(79)}`]);
assert.equal(results[1][0], true);
assert.match(results[1][1], new RegExp(`^reply too large: ${REPLY_CAP + 1} characters, over the ${REPLY_CAP}-character cap`));
assert.deepEqual(results[2], [false, '{"tool":"list_tasks","args":{"done":false}}']);
assert.deepEqual(results[3], [false, '{"tool":"get_task","args":{"task_ref":"vikunja:3/7"}}']);
assert.deepEqual(sock.calls.map((c) => [c.tool, c.args]), [
["trail", { subject: `size:${REPLY_CAP}` }],
["trail", { subject: `size:${REPLY_CAP + 1}` }],
["list_tasks", { done: false }],
["get_task", { task_ref: "vikunja:3/7" }],
]);
});
test("pi: a write through a dangling symlink is blocked, and nothing appears outside", async (t) => {
const { dir, workspace, s, env } = await session(t, [
{ name: "write", input: { path: "notes.md", content: "planted\n" } },
+56 -4
View File
@@ -1,6 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { callTool, typedTools, untypedActions } from "../src/tools.mjs";
import { callTool, REPLY_CAP, replyText, typedTools, untypedActions } from "../src/tools.mjs";
import { TASK_PAGE, TASK_PAGE_MAX } from "../../bus/src/broker.mjs";
import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs";
const names = (tools) => tools.map((t) => t.name);
@@ -12,7 +13,7 @@ test("the PM gets launch, its task verbs and the reads", (t) => {
"send_message", "launch",
"task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change",
"task_update_assigned", "task_close",
"raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks",
"raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
]);
const launch = tools.find((x) => x.name === "launch");
assert.equal(launch.verb, "launch");
@@ -37,7 +38,7 @@ test("a coder with a tracker gets its own task tools and no task_reassign", (t)
const coder = resolvedFor(t, "coder");
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), [
"send_message", "task_scope_change", "task_update_assigned",
"raise_decision", "list_agents", "inbox", "trail", "list_tasks",
"raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
]);
});
@@ -49,7 +50,7 @@ test("launch only when the business's launch block names the instance as launche
test("an action outside the instance's authority has no tool", (t) => {
const coder = resolvedFor(t, "coder", (d) => (d.roles.coder.vars["limits.authority"] = ["review.request"]));
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), ["raise_decision", "list_agents", "inbox", "trail", "list_tasks"]);
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), ["raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
});
test("callTool: one JSON line out, the result back, a refusal rejects", async (t) => {
@@ -63,3 +64,54 @@ test("callTool: one JSON line out, the result back, a refusal rejects", async (t
await assert.rejects(callTool(`${dir}/missing.sock`, "x"), /tool socket: ENOENT/);
assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]);
});
// Row 89 (#1583): task reads a session can page, and replies that fit.
test("list_tasks takes the broker's filters and pages; get_task takes a task_ref", (t) => {
const tools = typedTools({ resolved: resolvedFor(t, "pm"), tracker: true });
const list = tools.find((x) => x.name === "list_tasks");
const get = tools.find((x) => x.name === "get_task");
assert.deepEqual([list.verb, list.action, get.verb, get.action], ["tasks", null, "tasks", null]);
const p = list.parameters;
assert.deepEqual(Object.keys(p.properties), ["done", "assignee", "label", "requirement", "limit", "after"]);
assert.deepEqual(p.required, []);
assert.equal(p.properties.done.type, "boolean");
assert.equal(p.properties.assignee.type, "string");
assert.deepEqual([p.properties.label.type, p.properties.label.minimum], ["integer", 1]);
assert.equal(p.properties.requirement.pattern, "^REQ-[A-Z]+-[1-9][0-9]*$");
assert.equal(p.properties.after.type, "string");
// The page sizes are the broker's own.
assert.deepEqual([p.properties.limit.type, p.properties.limit.minimum, p.properties.limit.maximum], ["integer", 1, TASK_PAGE_MAX]);
assert.match(p.properties.limit.description, new RegExp(`\\b${TASK_PAGE} by default`));
assert.match(list.description, new RegExp(`open tasks \\(done false\\), ${TASK_PAGE} at a time`));
assert.match(list.description, /next as after/);
assert.match(list.description, /get_task/);
assert.deepEqual(get.parameters.properties, { task_ref: tools.find((x) => x.name === "task_close").parameters.properties.task_ref });
assert.deepEqual(get.parameters.required, ["task_ref"]);
// Neither without a tracker.
assert.deepEqual(names(typedTools({ resolved: resolvedFor(t, "pm") })).filter((n) => n.endsWith("_tasks") || n === "get_task"), []);
});
test("callTool sends list_tasks done false unless the session names done; other tools as given", async (t) => {
const sock = await fakeToolSocket(t, scratch(t), (tool, args) => args);
assert.deepEqual(await callTool(sock.path, "list_tasks"), { done: false });
assert.deepEqual(await callTool(sock.path, "list_tasks", { limit: 5 }), { done: false, limit: 5 });
assert.deepEqual(await callTool(sock.path, "list_tasks", { done: true }), { done: true });
assert.deepEqual(await callTool(sock.path, "get_task", { task_ref: "vikunja:3/7" }), { task_ref: "vikunja:3/7" });
assert.deepEqual(await callTool(sock.path, "list_agents"), {});
});
test("replyText: compact JSON up to the cap, an error naming the size and the cap over it", () => {
assert.equal(REPLY_CAP, 40_000);
assert.equal(replyText({ a: [1, { b: "c" }] }), '{"a":[1,{"b":"c"}]}');
assert.equal(replyText(null), "null");
assert.equal(replyText(undefined), "null");
// A JSON string adds its two quotes; length counts UTF-16 units, as the CLI does.
assert.equal(replyText("x".repeat(REPLY_CAP - 2)).length, REPLY_CAP);
assert.throws(() => replyText("x".repeat(REPLY_CAP - 1)), (e) => {
assert.equal(e.message, `reply too large: ${REPLY_CAP + 1} characters, over the ${REPLY_CAP}-character cap. Narrow the query: a filter, a smaller limit, or get_task for one task.`);
return true;
});
assert.throws(() => replyText("\u{1F600}".repeat(REPLY_CAP / 2)), /reply too large: 40002 characters/);
// 40000 units fit, though they are 80002 bytes: the cap isn't a byte count.
assert.equal(replyText("\u{1F600}".repeat(REPLY_CAP / 2 - 1)).length, REPLY_CAP);
});