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/), basea3f6e9e6, no drift on the 12 paths. Builder Dewey. Approved by Darkwing (comment 27940). Sage's gate onf04185fcplus 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]>
190 lines
11 KiB
JavaScript
190 lines
11 KiB
JavaScript
// Typed tools: the only way a managed session acts on the bus. Each tool
|
|
// maps one-to-one onto a broker verb (or, for `launch`, onto the host's
|
|
// launch socket), and a session gets a tool only for an action its resolved
|
|
// instance holds within-role or cross-role, plus the reads and
|
|
// raise_decision every agent has. The broker still decides every call;
|
|
// a tool's presence is not permission.
|
|
//
|
|
// The harness never sees the capability. Tools call the runner's tool
|
|
// socket (MOSAIC_TOOL_SOCKET) with `{tool, args}`; the runner checks the
|
|
// name against this list and forwards the call with its own capability.
|
|
|
|
import { connect } from "node:net";
|
|
import { ACTIONS } from "../../business/src/vocabulary.mjs";
|
|
|
|
const str = (description, extra = {}) => ({ type: "string", description, ...extra });
|
|
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"),
|
|
expect: str("The task digest you last saw; the broker refuses if the task changed since", { pattern: "^[0-9a-f]{64}$" }),
|
|
};
|
|
const RELATION = obj({
|
|
kind: str("Relation kind", { enum: ["subtask", "parenttask", "related", "duplicateof", "duplicates", "blocking", "blocked", "precedes", "follows", "copiedfrom", "copiedto"] }),
|
|
task_ref: TASK_REF,
|
|
}, ["kind", "task_ref"]);
|
|
const LABEL_IDS = { type: "array", items: { type: "integer", minimum: 1 }, maxItems: 50, description: "Label ids from the business file" };
|
|
const DUE = { type: ["string", "null"], description: "Due date as an ISO 8601 UTC timestamp, or null to clear" };
|
|
const PRIORITY = { type: "integer", minimum: 0, maximum: 5, description: "Priority, 0 (unset) to 5 (do now)" };
|
|
|
|
// action -> [tool name, description, properties, required]
|
|
const TASK_TOOLS = {
|
|
"task.create": ["task_create", "Create a tracker task. Every task cites the request it came from and a requirement id.", {
|
|
title: str("Task title, one line"),
|
|
description: str("Task description"),
|
|
due_date: DUE,
|
|
priority: PRIORITY,
|
|
labels: LABEL_IDS,
|
|
relations: { type: "array", items: RELATION, maxItems: 50 },
|
|
request: str("Id of the message or human input this task answers"),
|
|
requirement: str("Requirement id, such as REQ-CRED-2", { pattern: "^REQ-[A-Z]+-[1-9][0-9]*$" }),
|
|
decision: COMMON.decision,
|
|
}, ["title", "request", "requirement"]],
|
|
"task.assign": ["task_assign", "Assign an unassigned task to a role.", { ...COMMON, role: str("Role instance to assign") }, ["task_ref", "role"]],
|
|
"task.reassign": ["task_reassign", "Move an assigned task to another role.", { ...COMMON, role: str("Role instance to assign") }, ["task_ref", "role"]],
|
|
"task.schedule": ["task_schedule", "Change a task's due date, labels or relations.", {
|
|
...COMMON,
|
|
due_date: DUE,
|
|
labels: obj({ add: LABEL_IDS, remove: LABEL_IDS }),
|
|
relations: obj({ add: { type: "array", items: RELATION, maxItems: 50 }, remove: { type: "array", items: RELATION, maxItems: 50 } }),
|
|
}, ["task_ref"]],
|
|
"task.priority.change": ["task_priority_change", "Change a task's priority.", { ...COMMON, priority: PRIORITY }, ["task_ref", "priority"]],
|
|
"task.scope.change": ["task_scope_change", "Change a task's title or description.", { ...COMMON, title: str("New title"), description: str("New description") }, ["task_ref"]],
|
|
"task.update.assigned": ["task_update_assigned", "Report progress on a task assigned to you: percent done, state or a comment.", {
|
|
...COMMON,
|
|
percent_done: { type: "number", minimum: 0, maximum: 1, description: "Fraction done, 0 to 1" },
|
|
state: str("Board state", { enum: ["todo", "in-progress", "in-review", "blocked"] }),
|
|
comment: str("Comment to add"),
|
|
}, ["task_ref"]],
|
|
"task.close": ["task_close", "Close a task after its review verdict.", { ...COMMON, verdict: str("Citation of the review verdict") }, ["task_ref", "verdict"]],
|
|
};
|
|
|
|
const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESULT", "RESULT", "INFO", "DECISION", "REACTION"];
|
|
|
|
// typedTools({ resolved, arbiter, tracker }) -> [{ name, verb, action, description, parameters }]
|
|
// resolved resolveInstance(...) output for the session's instance
|
|
// arbiter true when the business names this instance as an arbiter
|
|
// tracker true when the broker has task verbs for the business
|
|
// `verb` is the broker verb; the `launch` tool has verb "launch" and goes
|
|
// to the host instead.
|
|
export function typedTools({ resolved, arbiter = false, tracker = false }) {
|
|
const held = new Set([...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole]);
|
|
const tools = [];
|
|
const add = (name, verb, action, description, parameters) => tools.push({ name, verb, action, description, parameters });
|
|
if (held.has("message.send")) {
|
|
add("send_message", "message.send", "message.send", "Send a message to another role instance, or to \"human\". Your final answer to a message is sent back to its sender for you; use this for anything else.", obj({
|
|
to: str("Role instance, or \"human\""),
|
|
body: str("Message text"),
|
|
class: str("Message class", { enum: MESSAGE_CLASSES }),
|
|
in_reply_to: str("Id of the message this answers"),
|
|
decision: COMMON.decision,
|
|
}, ["to", "body"]));
|
|
}
|
|
if (resolved.launch) {
|
|
add("launch", "launch", "role.launch", `Launch a managed session for one of these role instances: ${resolved.launch.instances.join(", ")}. The host refuses an instance that is already running, a full model family, or a launch while launches are switched off.`, obj({
|
|
instance: str("Role instance to launch", { enum: [...resolved.launch.instances] }),
|
|
}, ["instance"]));
|
|
}
|
|
if (tracker) {
|
|
for (const [action, [name, description, properties, required]] of Object.entries(TASK_TOOLS)) {
|
|
if (held.has(action)) add(name, action, action, description, obj(properties, required));
|
|
}
|
|
}
|
|
add("raise_decision", "decision.raise", null, "Ask for a decision before a gated or cross-role action, or when you need a choice made. The answer arrives as a message.", obj({
|
|
action: str("The action the decision is about", { enum: [...ACTIONS] }),
|
|
question: str("The question, with enough context to answer it"),
|
|
options: { type: "array", minItems: 2, maxItems: 9, items: obj({ key: str("Short key"), text: str("What this option means") }, ["key", "text"]) },
|
|
recommendation: str("Your recommended option key, with a reason"),
|
|
blocking: { type: "boolean", description: "True when you can't continue without the answer" },
|
|
target: str("What the action applies to, such as a role instance or task reference"),
|
|
approvalChoice: str("The option key that approves the action"),
|
|
task_ref: TASK_REF,
|
|
requirement_ref: str("Requirement id"),
|
|
}, ["action", "question", "options", "recommendation", "blocking"]));
|
|
if (arbiter) {
|
|
add("resolve_decision", "decision.resolve", null, "Resolve a decision routed to you.", obj({
|
|
id: str("Decision id"),
|
|
choice: str("The option key you choose"),
|
|
note: str("Reason, one or two sentences"),
|
|
}, ["id", "choice"]));
|
|
}
|
|
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, 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) {
|
|
const typed = new Set(tools.map((t) => t.action).filter(Boolean));
|
|
return [...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole].filter((a) => !typed.has(a));
|
|
}
|
|
|
|
// One call over a tool socket: a JSON line out, a JSON line back.
|
|
export function callTool(path, tool, args = {}, { timeout = 60_000 } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = connect(path);
|
|
let input = "";
|
|
let settled = false;
|
|
const end = (error, value) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
socket.destroy();
|
|
error ? reject(error) : resolve(value);
|
|
};
|
|
socket.setTimeout(timeout, () => end(new Error("tool call timed out")));
|
|
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) => {
|
|
input += b.toString("utf8");
|
|
if (input.length > 4 * 1024 * 1024) return end(new Error("tool reply too large"));
|
|
if (!input.includes("\n")) return;
|
|
try {
|
|
const r = JSON.parse(input);
|
|
r.ok ? end(null, r.result) : end(new Error(`refused: ${r.error}`));
|
|
} catch {
|
|
end(new Error("tool reply isn't JSON"));
|
|
}
|
|
});
|
|
});
|
|
}
|