Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion grammars/tool-call.gbnf
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
root ::= tool-call-array
tool-call ::= "{" ws "\"tool\"" ws ":" ws tool-name ws "," ws "\"args\"" ws ":" ws object ws "}"
tool-call-array ::= "[" ws tool-call ( ws "," ws tool-call ){0,15} ws "]"
tool-name ::= browser-tool | os-tool | discovery-tool | memory-tool | tasks-tool | vision-tool | fusion-tool | mcp-native-tool | mcp-server-tool | "\"reply\"" | "\"finish\""
tool-name ::= browser-tool | os-tool | discovery-tool | memory-tool | tasks-tool | vision-tool | fusion-tool | verify-tool | mcp-native-tool | mcp-server-tool | "\"reply\"" | "\"finish\""
browser-tool ::= "\"browser." ( "navigate" | "click" | "type" | "read_aria" | "search" | "tabs" | "scroll" ) "\""
os-tool ::= "\"os." ( "shell.run" | "fs.read" | "fs.read_document" | "fs.write" | "fs.trash" | "fs.list" | "fs.grep" | "fs.glob" | "fs.locate_project" | "fs.edit" | "fs.hash" | "fs.diff" | "fs.patch" | "fs.watch" | "fs.archive.list" | "fs.archive.read_entry" | "fs.archive.extract" | "http.request" | "web.search" | "web.fetch" | "git.status" | "git.log" | "git.diff" | "git.show" | "git.blame" | "git.branch" | "git.init" | "git.add" | "git.commit" | "git.checkout" | "git.clone" | "git.remote" | "git.fetch" | "git.pull" | "git.push" | "proc.list" | "proc.kill" | "clipboard.read" | "clipboard.write" | "window.list" | "window.focus" | "notify" | "email.inbox" | "email.send" ) "\""
discovery-tool ::= "\"skill." ( "view" | "run_script" ) "\"" | "\"tool.view\""
Expand All @@ -23,6 +23,10 @@ vision-tool ::= "\"vision.describe\""
# also grammar-constrained) is refused by `fusion.delegate` itself when
# it tries to fan out again.
fusion-tool ::= "\"fusion.delegate\""
# Read-only verification (syntax per file; a command, service or page
# run against a throwaway copy). A local orchestrator reviewing a fan-out
# needs these in its vocabulary for the same reason it needs the fan-out.
verify-tool ::= "\"verify." ( "syntax" | "run" ) "\""
# Native MCP discovery / resource / prompt tools — always available.
mcp-native-tool ::= "\"mcp." ( "resource.list" | "resource.read" | "prompt.list" | "prompt.get" ) "\""
# Per-server MCP tool branch. The static fallback (`mcp.<any>.<any>`)
Expand Down
32 changes: 32 additions & 0 deletions src/agent/fusion-orchestrator-mode.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ApprovalGate } from "../approval/approval-gate.js";
import { buildVerifyRunTool, verifySyntaxTool } from "../tools/verify/index.js";
import {
checkFusionOrchestrator,
emptyFusionOrchestratorState,
Expand All @@ -8,6 +10,23 @@ import {
wouldRefuse,
} from "./fusion-orchestrator-mode.js";

/** The flag as the shipped definition carries it; the gate never runs the tool here. */
const verifyRunReadonly = buildVerifyRunTool({
approvals: {} as ApprovalGate,
approvalRequired: false,
config: {
browser: {
enabled: false,
channel: "chrome",
headless: true,
cdpUrl: null,
executablePath: null,
noSandbox: false,
launchTimeoutMs: 1_000,
},
},
}).readonly;

/** The two facts the gate reads off a tool: does it exist, does it mutate. */
function registryWith(
tools: Record<string, { readonly: boolean }>,
Expand Down Expand Up @@ -67,6 +86,19 @@ describe("the fusion orchestrator gate", () => {
).toBe(true);
});

it("lets the orchestrator verify — read-only checks are review, not building (D1)", () => {
// The real definitions, not a fixture: the gate reads `readonly`
// off the registry, so this pins the flag the tools actually ship.
const registry = registryWith({
"verify.syntax": { readonly: verifySyntaxTool.readonly },
"verify.run": { readonly: verifyRunReadonly },
});
for (const tool of ["verify.syntax", "verify.run"]) {
expect(checkFusionOrchestrator(tool, registry, BEFORE).allowed).toBe(true);
expect(checkFusionOrchestrator(tool, registry, AFTER).allowed).toBe(true);
}
});

it("never gates the terminal verbs", () => {
// Vetoing `reply` would veto the turn's own exit — the mistake
// plan mode documents and avoids for the same reason.
Expand Down
11 changes: 11 additions & 0 deletions src/agent/tool-resource-class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,15 @@ const TOOL_RESOURCE_CLASS: Record<string, ResourceClass> = {
// stack the orchestrator's own fan-out on top of the fan-out.
"fusion.delegate": "approval_gated",

// verify.* — read-only checks. `verify.syntax` spawns nothing that
// writes (node --check, tsc --noEmit, python compile(), bash -n), so
// several may run side by side.
"verify.syntax": "pure_read",
// `verify.run` executes a command, a server or a page — in a
// throwaway copy, so the workspace is untouched, but it still asks
// under the shell category below level 4, and it must be solo.
"verify.run": "approval_gated",

// mcp.* discovery / read tools — pure_read regardless of per-server
// trust because they only inspect the local catalog or fetch
// declared resources/prompts. Per-server-tool calls go through
Expand Down Expand Up @@ -283,6 +292,8 @@ const FS_WRITE_CATEGORIES: readonly ApprovalCategory[] = [
const APPROVAL_CATEGORIES_BY_TOOL: Record<string, readonly ApprovalCategory[]> =
{
"os.shell.run": ["shell"],
// Same rung as a shell command: it runs one, in a copy.
"verify.run": ["shell"],
"os.fs.write": FS_WRITE_CATEGORIES,
"os.fs.edit": FS_WRITE_CATEGORIES,
"os.fs.patch": FS_WRITE_CATEGORIES,
Expand Down
19 changes: 19 additions & 0 deletions src/llm/grammar/build-grammar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,25 @@ describe("fusion.delegate in the local-model grammar", () => {
});
});

describe("verify.* in the local-model grammar", () => {
it("admits both verification tools, so a local orchestrator can review a fan-out", async () => {
for (const profile of [
PLAIN_INSTRUCT_PROFILE,
QWEN_THINK_PROFILE,
GEMMA4_THINK_PROFILE,
]) {
const grammar = await buildGrammar(profile);
const toolName =
grammar.split("\n").find((l) => l.startsWith("tool-name ::=")) ?? "";
expect(toolName, profile.id).toContain("verify-tool");
const rule =
grammar.split("\n").find((l) => l.startsWith("verify-tool ::=")) ?? "";
expect(rule, profile.id).toContain('"syntax"');
expect(rule, profile.id).toContain('"run"');
}
});
});

describe("os-tool names the local-model grammar admits", () => {
it("includes the agent's e-mail tools — a descriptor the grammar cannot emit is a tool local models cannot call", () => {
const { readFileSync } = require("node:fs") as typeof import("node:fs");
Expand Down
6 changes: 4 additions & 2 deletions src/llm/provider/openai/openai-strict-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,15 +483,17 @@ describe("strict-tool conformance over every registered tool", () => {
.map((tool) => String((tool.function as Schema).name));
// `os.http.request` carries a free-form header map and a free-form
// JSON body; `mcp.prompt.get` forwards a server-defined argument
// map. Both are the tool's actual payload, so neither can be closed.
// map; `verify.run` takes an `env` map of arbitrary variable names.
// `fusion.delegate` joined them with its `contract`: `owners` is a
// path → task map, and each `checks` entry is a `verify.run` spec
// whose keys belong to that tool — closing it would leave the model
// unable to write a check at all. `parseDelegateArgs` validates the
// shape at run time, as it always did.
// shape at run time, as it always did. Each is the tool's actual
// payload, so none can be closed.
expect(refused).toEqual([
"os__http__request",
"mcp__prompt__get",
"verify__run",
"fusion__delegate",
]);
});
Expand Down
12 changes: 8 additions & 4 deletions src/llm/provider/openai/strict-tool-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ describe("toStrictJsonSchema", () => {
* knowingly — either the schema loses a bound it does not need, or
* the tool joins this list.
*/
it("converts all but five of the sampled built-in tool schemas", () => {
it("converts all but six of the sampled built-in tool schemas", () => {
const refused: string[] = [];
let converted = 0;
for (const name of DEFAULT_TOOL_NAMES) {
Expand All @@ -550,14 +550,16 @@ describe("toStrictJsonSchema", () => {
"os.fs.archive.extract",
// `headers` is a map; `body` may be any object.
"os.http.request",
// `env` is a map of arbitrary variable names.
"verify.run",
// maxItems on `paths`.
"vision.describe",
]);
expect(converted).toBe(DEFAULT_TOOL_NAMES.length - refused.length);
// 82 registered schemas, 77 of them strict. Pinned as a number so
// 84 registered schemas, 78 of them strict. Pinned as a number so
// the sample cannot quietly shrink.
expect(DEFAULT_TOOL_NAMES.length).toBe(82);
expect(converted).toBe(77);
expect(DEFAULT_TOOL_NAMES.length).toBe(84);
expect(converted).toBe(78);
});

/**
Expand Down Expand Up @@ -671,6 +673,8 @@ const DEFAULT_TOOL_NAMES: readonly string[] = [
"mcp.prompt.list",
"mcp.prompt.get",
"fusion.delegate",
"verify.syntax",
"verify.run",
// The nine from `github-tool-args-schemas.ts`, spread into the same
// registry. Left out of this list, a bound added to one of them would
// have joined the refusal set silently — the exact surprise the pin
Expand Down
52 changes: 52 additions & 0 deletions src/prompt/default-tool-args-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,58 @@ const DEFAULT_TOOL_ARGS_SCHEMAS: ReadonlyMap<string, Schema> = new Map<
["tasks"],
),
],
["verify.syntax", obj({ files: stringArraySchema }, ["files"])],
[
"verify.run",
obj(
{
kind: { type: "string", enum: ["command", "service", "page"] },
cwd: stringSchema,
// A map of arbitrary keys, like `os.http.request.headers`; the
// strict converter refuses it, knowingly.
env: { type: "object", additionalProperties: { type: "string" } },
timeoutMs: numberSchema,
network: booleanSchema,
cmd: stringSchema,
args: stringArraySchema,
start: obj({ cmd: stringSchema, args: stringArraySchema }, ["cmd"]),
ready: obj({ port: integerSchema, url: stringSchema, timeoutMs: numberSchema }),
requests: {
type: "array",
items: obj({
method: stringSchema,
path: stringSchema,
url: stringSchema,
body: stringSchema,
expectStatus: integerSchema,
expectBody: stringSchema,
}),
},
path: stringSchema,
url: stringSchema,
script: {
type: "array",
items: obj(
{
action: { type: "string", enum: ["click", "key", "type", "wait"] },
selector: stringSchema,
key: stringSchema,
text: stringSchema,
ms: numberSchema,
},
["action"],
),
},
seconds: numberSchema,
probes: {
type: "array",
items: obj({ name: stringSchema, expr: stringSchema }, ["name", "expr"]),
},
checks: stringArraySchema,
},
["kind"],
),
],

// ── terminal verbs ───────────────────────────────────────────────────────
// The OpenAI adapter overrides these with hand-tuned schemas (see
Expand Down
25 changes: 25 additions & 0 deletions src/prompt/default-tool-descriptors-b.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,31 @@ export const DEFAULT_TOOL_DESCRIPTORS_B: readonly ToolDescriptor[] = [
"{ server: string, name: string, arguments?: Record<string,string> }",
tier: "rare",
},
{
// Frequent tier: the review step of a fan-out reaches for it, and a
// reviewer that has to `tool.view` first reviews less. Read-only, so
// the fusion orchestrator gate lets it through (decision D1).
name: "verify.syntax",
summary:
"Syntax-check files, one checker per file by extension (.js/.json in-process then node --check, .ts via the project's tsc, .py, .sh, .html inline scripts + a warning for content after </html>, .css braces). Read-only. Reports a file with no checker as unchecked — never as passing.",
argsSchema: "{ files: string[] }",
},
{
// Frequent tier for the same reason, and with an example: the
// runtime failures no syntax check sees (a throw on load, a dead
// button, a probe that never moves) are what this catches, and a
// page run with two probes is not something a model first-shots
// from a one-line manifest.
name: "verify.run",
summary:
"Run what was built against a throwaway copy of the working directory (nothing it writes reaches the workspace; may require approval). kind 'command': a test runner, compiler or script. kind 'service': start a server, wait for a port/url, send requests. kind 'page': a local HTML file or url in a headless browser — scripted input, then `seconds` of runtime with `probes` sampled; collects uncaught errors, console errors and failed getElementById/querySelector lookups. `checks` are assertions on the result. Default `network: false` (a soft proxy block, not a sandbox).",
argsSchema:
"{ kind: 'command' | 'service' | 'page', cmd?: string, args?: string[], start?: { cmd: string, args?: string[] }, ready?: { port?: number, url?: string, timeoutMs?: number }, requests?: [{ method?: string, path?: string, url?: string, body?: string, expectStatus?: number, expectBody?: string }], path?: string, url?: string, script?: [{ action: 'click' | 'key' | 'type' | 'wait', selector?: string, key?: string, text?: string, ms?: number }], seconds?: number /* page runtime, default 5 */, probes?: [{ name: string, expr: string /* JS expression */ }], checks?: string[] /* 'exit 0' | 'exit != 0' | 'stdout contains \"x\"' | 'stderr not contains \"x\"' | 'status 200' | 'no errors' | 'missing selectors 0' | 'probe <name> decreases|increases|equals <v>|reaches <v>|stays <v>' */, cwd?: string, env?: Record<string,string>, timeoutMs?: number, network?: boolean }",
examples: [
'{"kind":"page","path":"index.html","script":[{"action":"click","selector":"#launch"},{"action":"key","key":"ArrowLeft"}],"seconds":6,"probes":[{"name":"lives","expr":"window.game.lives"},{"name":"score","expr":"window.game.score"}],"checks":["no errors","missing selectors 0","probe score increases"]}',
'{"kind":"command","cmd":"npm","args":["test"],"checks":["exit 0","stdout not contains \\"failed\\""]}',
],
},
{
// `frequent` tier and a full example: the whole point of the tool is
// that a cloud orchestrator reaches for it instead of doing the bulk
Expand Down
5 changes: 4 additions & 1 deletion src/prompt/fusion-guidance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,10 @@ describe("the machine facts in the ### fusion block", () => {
// the price of sending work that cannot succeed without the
// conversation the worker will not have.
expect(FUSION_GUIDANCE).toMatch(/prefer sending more/i);
expect(FUSION_GUIDANCE).toContain("independent, self-contained parts");
expect(FUSION_GUIDANCE).toContain("independent parts");
// And to check a fan-out before accepting it, with the tools that can.
expect(FUSION_GUIDANCE).toContain("`verify.syntax` on the declared files");
expect(FUSION_GUIDANCE).toContain("`verify.run` on what the request must do");
expect(FUSION_GUIDANCE).toMatch(/only makes sense with this conversation/i);
// And the width is stated as the model's own call.
expect(FUSION_GUIDANCE).toMatch(/You choose `maxWorkers`/);
Expand Down
11 changes: 6 additions & 5 deletions src/prompt/fusion-guidance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,14 @@ export function isFusionActive(
*/
export const FUSION_GUIDANCE = [
"You orchestrate the workers: read enough to decide, plan, delegate the doing, review what comes back.",
"Plan in the open, then delegate in the same turn — never stop at the plan: list the independent, self-contained parts, sized so a big one gets its own worker and small ones share.",
"One task per part, in one `fusion.delegate` call. List the paths a task will produce in its `files` — the operator is asked once, about those directories, and that answer is what lets the workers write. Each `instructions` must stand alone: workers see the operator's request, not this chat, and cannot ask you anything.",
"Plan in the open, then delegate in the same turn — never stop at the plan: list the independent parts, sized so a big one gets its own worker and small ones share.",
"One task per part, in one `fusion.delegate` call. List the paths a task will produce in its `files` — the operator is asked once about those directories, and that is what lets the workers write. Each `instructions` must stand alone: workers see the operator's request, not this chat, and cannot ask you.",
"You choose `maxWorkers` per call; prefer sending more parts over doing any yourself.",
"Tools that change things are refused for you, always: the workers build, you do not. That is the mode working, not a fault.",
"Tools that change things are refused for you: the workers build, you do not. That is the mode working, not a fault.",
"Keep the design and the judgement: read every reply against its brief.",
"Rework goes back out: anything `failed`, `cancelled`, `needs_orchestrator` or just not good enough is another `fusion.delegate` saying what was wrong and what good looks like. Keep going until you would sign off on it.",
"Yours alone: the decision you were asked for, a part that only makes sense with this conversation in front of it, and anything needing operator approval — workers cannot reach the user.",
"Before accepting a fan-out, check it: `verify.syntax` on the declared files and `verify.run` on what the request must do.",
"Rework goes back out: anything `failed`, `cancelled`, `needs_orchestrator` or not good enough is another `fusion.delegate` saying what was wrong and what good looks like.",
"Yours alone: the decision you were asked for, a part that only makes sense with this conversation in front of it, and anything needing operator approval.",
"Call `fusion.delegate` on its own, never alongside other tool calls — it runs several turns internally.",
].join("\n");

Expand Down
18 changes: 18 additions & 0 deletions src/runtime/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { buildBrowserTools } from "../tools/browser/index.js";
import { PlaywrightBackend } from "../tools/browser/playwright-backend.js";
import type { BrowserBackend } from "../tools/browser/browser-backend.js";
import { registerOsTools } from "../tools/os/index.js";
import { registerVerifyTools, runChecks } from "../tools/verify/index.js";
import { registerGithubTools } from "../tools/github/index.js";
import { resolveGithubToken } from "../github/index.js";
import { registerSkillTools } from "../tools/skill/index.js";
Expand Down Expand Up @@ -1445,6 +1446,14 @@ export async function createAgentRuntime(
isGitRemoteSyncEnabled: () => getConfig().git.remoteSync,
},
});
// The read-only `verify.*` family: syntax per file, and (below) a
// command / service / page run against a throwaway copy of the
// working directory. Registered next to the OS tools because it is
// the review half of what they build.
registerVerifyTools(toolRegistry, {
...dangerous,
config: { browser: config.browser },
});
// Always registered; each call resolves `GITHUB_TOKEN` afresh so a
// token saved in the Integrations hub works on the next turn. The
// descriptors, by contrast, are gated on the token (see
Expand Down Expand Up @@ -3066,6 +3075,15 @@ export async function createAgentRuntime(
emitEvent: emitAgentLoopEventFor,
workingDir,
outputCharCap: config.agent.batchToolResultCharCap,
// A contract's declared `checks` run through the verify family,
// each on a throwaway copy of the workspace, so a fan-out is judged
// by what its output does, never by what a worker's reply says.
runChecks: (specs, ctx) =>
runChecks(specs, {
workingDir: ctx.workingDir,
signal: ctx.signal,
config,
}),
logger,
}),
);
Expand Down
8 changes: 8 additions & 0 deletions src/tools/fusion/worker-read-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ export const WORKER_READ_TOOL_TARGETS: ReadonlyMap<string, TargetsOf> =
...(Array.isArray(args.paths) ? args.paths.map(nonEmpty) : []),
].filter((path): path is string => path !== undefined),
],
[
// A syntax check reads every file it is handed.
"verify.syntax",
(args) =>
(Array.isArray(args.files) ? args.files.map(nonEmpty) : []).filter(
(path): path is string => path !== undefined,
),
],
]);

/** `https://…`, `data:…` — not a filesystem path, not this module's business. */
Expand Down
Loading