Row 105. Built by a Haiku subagent under Researcher (decision 129),
packet agents/researcher/work/queue-105/r1/, candidate manifest
cd6d5eb3e4093ac157f63d05cb639d243d76c6bde1a03e78efb8f29e191b6b44.
Filbert approved on #1599, comment 28247.
The review.request reply and the review.requested event carry held,
the run id holding the reviewer role or null. The request_review tool
description says what null means. The broker change is live only at
a bus restart.
Sage's gate rerun on 79e126be0 with rows 101 and 105 applied, Docker
up: node suites bus 128, business 67, cli 146, control-board 124,
conversation 182, discord 178, harness 70, ledger 78, mosaic 69,
queue 155, runs 43, seat 109, tasks 92, webui 108; scripts auth 15,
conductor 17, config 24, discord 66, extension-package 18,
foundation 44, queue 28, release 14, task 98. All pass, 0 fail.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
228 lines
15 KiB
JavaScript
228 lines
15 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 for push_working and fetch_review, onto the host's git
|
|
// socket through git-tools.mjs), 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"),
|
|
project: { type: "integer", minimum: 1, description: "Tracker project id, an installed project in the business's tree; the root project when left out" },
|
|
description: str("Task description"),
|
|
due_date: DUE,
|
|
priority: PRIORITY,
|
|
labels: LABEL_IDS,
|
|
relations: { type: "array", items: RELATION, maxItems: 50 },
|
|
request: str("The request id of the human message this task answers, shown as \"request <id>\" in that message's header; not the message's own id"),
|
|
requirement: str("Requirement id from list_requirements, 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. A task with pushed work closes only on an approve verdict for its latest push.", { ...COMMON, verdict: str("The review verdict id review_verdict returned, as the REVIEW-RESULT message names it") }, ["task_ref", "verdict"]],
|
|
};
|
|
|
|
const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESULT", "RESULT", "INFO", "DECISION", "REACTION"];
|
|
|
|
// typedTools({ resolved, arbiter, tracker, projects }) -> [{ 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
|
|
// projects the project ids the session has a clone of
|
|
// `verb` is the broker verb; the `launch` tool has verb "launch" and goes
|
|
// to the host instead, and "working.push" and "working.fetch" go to the
|
|
// host's git socket.
|
|
export function typedTools({ resolved, arbiter = false, tracker = false, projects = [] }) {
|
|
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));
|
|
}
|
|
}
|
|
// Row 93 (#1587): the review loop. Pushes and fetches need a clone to work in.
|
|
if (held.has("git.push.working") && projects.length) {
|
|
add("push_working", "working.push", "git.push.working", "Push a branch of your clone to the business's working repository as the task's work branch, work/vikunja-<project>-<task>. Only a fast-forward of what the task already pushed is taken. Commit first; the reply names the pushed commit, which request_review takes.", obj({
|
|
task_ref: TASK_REF,
|
|
project: str("The project whose clone holds the branch", { enum: [...projects] }),
|
|
branch: str("Local branch to push, such as the branch you committed on"),
|
|
}, ["task_ref", "project", "branch"]));
|
|
}
|
|
if (held.has("review.request")) {
|
|
add("request_review", "review.request", "review.request", "Ask a reviewer role to review the task's latest pushed commit. The reviewer gets a REVIEW-REQUEST message, and its verdict comes back to you as a REVIEW-RESULT. The reply's held names the run holding the reviewer role, or is null when no reviewer is running. A null request waits until a reviewer launches, so tell whoever launches roles that a review is waiting.", obj({
|
|
task_ref: TASK_REF,
|
|
commit: str("The commit push_working returned", { pattern: "^[0-9a-f]{40}([0-9a-f]{24})?$" }),
|
|
to: str("Reviewer role instance"),
|
|
note: str("What to look at, for the reviewer"),
|
|
decision: COMMON.decision,
|
|
}, ["task_ref", "commit", "to"]));
|
|
}
|
|
if (held.has("review.verdict")) {
|
|
if (projects.length)
|
|
add("fetch_review", "working.fetch", null, "Fetch the commit a review request names into your clone of its project, as the remote branch the reply names. Check it out or diff it there; nothing is merged.", obj({
|
|
task_ref: TASK_REF,
|
|
request: str("The review request id from the REVIEW-REQUEST message"),
|
|
}, ["task_ref", "request"]));
|
|
add("review_verdict", "review.verdict", "review.verdict", "Answer a review request: approve, or changes with what must change. One verdict per request; it goes back to the role that asked.", obj({
|
|
task_ref: TASK_REF,
|
|
request: str("The review request id from the REVIEW-REQUEST message"),
|
|
verdict: str("Your verdict", { enum: ["approve", "changes"] }),
|
|
body: str("The review: what you checked and what you found"),
|
|
decision: COMMON.decision,
|
|
}, ["task_ref", "request", "verdict", "body"]));
|
|
}
|
|
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 only for a decision that holds a task: it needs task_ref, and the task waits for the answer. A request with no task is false" },
|
|
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"]));
|
|
// Row 90 (#1584): the ids task_create takes, for the sessions that hold it.
|
|
if (held.has("task.create"))
|
|
add("list_requirements", "requirements", null, "List the approved PRD's requirement ids, each with a one-line title. task_create takes only these. When none fits a request, raise a prd.approve decision instead of creating the task.", obj({}));
|
|
}
|
|
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"));
|
|
}
|
|
});
|
|
});
|
|
}
|