Skip to content
10 changes: 8 additions & 2 deletions AGENTS.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/prompt/default-tool-args-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,12 +711,13 @@ const DEFAULT_TOOL_ARGS_SCHEMAS: ReadonlyMap<string, Schema> = new Map<
items: obj(
{
id: stringSchema,
// Optional since F44: the tool defaults it to the id.
title: stringSchema,
instructions: stringSchema,
deliverable: stringSchema,
files: { ...stringArraySchema, maxItems: 32 },
},
["id", "title", "instructions"],
["id", "instructions"],
),
},
// No upper bound: the orchestrator sizes its own fan-out and
Expand Down
2 changes: 1 addition & 1 deletion src/prompt/default-tool-descriptors-a.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const DEFAULT_TOOL_DESCRIPTORS_A: readonly ToolDescriptor[] = [
{
name: "os.fs.restore",
summary:
"Bring back the previous content of a file that os.fs.write / os.fs.edit / os.fs.patch replaced or shrank this session — the result of that call said it was saved (may require approval).",
"Bring back the previous content of a file that os.fs.write / os.fs.edit / os.fs.patch replaced or shrank in this working directory, by this session or another — the result of that call said it was saved (may require approval).",
argsSchema: "{ path: string }",
tier: "rare",
},
Expand Down
2 changes: 1 addition & 1 deletion src/prompt/default-tool-descriptors-b.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ export const DEFAULT_TOOL_DESCRIPTORS_B: readonly ToolDescriptor[] = [
summary:
"Delegate independent parts of the work to local worker agents that run concurrently and report back. Each task becomes one throwaway worker turn that sees the operator's original request and your `instructions`, but nothing else from this conversation, so `instructions` must carry what the request does not (exact paths, the contract between parts, acceptance criteria, the answer format you want). Returns every worker's reply plus a per-task status. You choose `maxWorkers`; it is bounded only by the task count and the machine. Call it on its own, never alongside other tool calls.",
argsSchema:
'{ tasks: [{ id: string, title: string, instructions: string, deliverable?: string, files?: string[] }] /* 1..8 */, maxWorkers?: number /* how many run at once; you decide */, contract?: { owners?: { [path]: taskId }, provides?: [{ task, kind: "symbol"|"file"|"id"|"endpoint"|"env"|"flag"|"other", name, in?: path }], requires?: [{ task, name /* a provides name */ }], checks?: [{ task?, ...verify.run args }] } /* the interface between the parts: shown to every worker, checked after the fan-out */ }',
'{ tasks: [{ id: string, instructions: string, title?: string /* defaults to the id */, deliverable?: string, files?: string[] }] /* 1..8 */, maxWorkers?: number /* how many run at once; you decide */, contract?: { owners?: { [path]: taskId }, provides?: [{ task, kind: "symbol"|"file"|"id"|"endpoint"|"env"|"flag"|"other", name, in?: path }], requires?: [{ task, name /* a provides name */ }], checks?: [{ task?, ...verify.run args }] } /* the interface between the parts: shown to every worker, checked after the fan-out */ }',
examples: [
'{"tasks":[{"id":"t1","title":"Map the auth routes","instructions":"List every route under src/http/ that touches auth. For each: path, method, and the middleware it runs.","deliverable":"one bullet per route"},{"id":"t2","title":"Summarise the session store","instructions":"Read src/session/session-store.ts and describe its public API and persistence model.","files":["src/session/session-store.ts"]}]}',
],
Expand Down
12 changes: 12 additions & 0 deletions src/prompt/fusion-guidance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,24 @@ describe("the ### fusion prefix section", () => {
expect(FUSION_GUIDANCE).toContain("needs_orchestrator");
});

it("says a task that replaced a user's file is not done until it is restored or the replacement was wanted (F43)", () => {
// The status table carries `replaced the user's file sales.csv
// (2,401 → 9 lines)` on the row; without this line the orchestrator
// read the fact and merged anyway. The restore is a worker's call,
// because the orchestrator's own writes are refused.
expect(FUSION_GUIDANCE).toContain(
"A task that replaced a pre-existing file is not done until the file is restored (`os.fs.restore` in a worker) or the replacement was asked for.",
);
});

it("stays short enough to live in every turn's prefix", () => {
// Every byte here is paid on every step of every fusion turn. The
// machine lines carry capacity as well as a count — slots, a
// worker's share of the shared context, the shared GPU — which is
// what the orchestrator needed and could not see when a four-worker
// fan-out overflowed its server; ~100 tokens is what that costs.
// F43's restore line was paid for by shortening the others — the
// budget is the budget.
expect(FUSION_GUIDANCE.length).toBeLessThan(1400);
expect(buildFusionGuidance(FACTS).length).toBeLessThan(1900);
expect(
Expand Down
19 changes: 11 additions & 8 deletions src/prompt/fusion-guidance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,19 @@ export function isFusionActive(
* exactly the block a machine-less build renders.
*/
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 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 orchestrate: 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 parts, a big one per worker, small ones shared.",
"One task per part, in one `fusion.delegate` call; its `files` name the paths it will produce (approved once by the operator, so the workers can write). Each `instructions` must stand alone: workers see the operator's request, not this chat, and cannot ask.",
"You choose `maxWorkers` per call; prefer sending more parts over doing any yourself.",
"Tools that change things are refused for you: 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 the mode working, not a fault.",
"Keep the design and the judgement: read every reply against its brief.",
"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.",
"Before accepting a fan-out: `verify.syntax` on the declared files and `verify.run` on what the request must do.",
"Rework goes back out: `failed`, `cancelled`, `needs_orchestrator` or not good enough is another `fusion.delegate` saying what was wrong and what good looks like.",
// F43: the status table now says `replaced the user's file sales.csv
// (2,401 → 9 lines)` on the row; this is what to do about it.
"A task that replaced a pre-existing file is not done until the file is restored (`os.fs.restore` in a worker) or the replacement was asked for.",
"Yours alone: the decision you were asked for, a part that only makes sense with this conversation in front of it, anything needing approval.",
"Call `fusion.delegate` on its own, never alongside other tool calls.",
].join("\n");

/**
Expand Down
18 changes: 15 additions & 3 deletions src/tools/fusion/contract-checks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ describe("inspectContractProvides", () => {
{ task: "ship", kind: "file", name: "js/ship.js" },
{ task: "ship", kind: "file", name: "js/hud.js" },
{ task: "ship", kind: "symbol", name: "X", in: "js/nope.js" },
// Nowhere to look: the parser's warning, not a finding — a
// "missing" verdict over a search that never happened would
// read as the worker's failure.
{ task: "lost", kind: "symbol", name: "Y" },
],
};
Expand All @@ -101,16 +104,13 @@ describe("inspectContractProvides", () => {
["js/ship.js", true, ["js/ship.js"]],
["js/hud.js", false, ["js/hud.js"]],
["X", false, ["js/nope.js"]],
["Y", false, []],
]);
expect(findings[6]!.detail).toBe("file missing or unreadable");
expect(findings[7]!.detail).toContain("no file to look in");
expect(describeMissing(findings[1]!)).toBe(
"[ship] symbol HD.Ship.fire not in js/ship.js",
);
expect(describeMissing(findings[2]!)).toBe("[html] id btn-launch not in index.html");
expect(describeMissing(findings[5]!)).toBe("[ship] file js/hud.js does not exist");
expect(describeMissing(findings[7]!)).toContain("nowhere (no file to look in");
});

it("is satisfied by any one of several owned paths", async () => {
Expand Down Expand Up @@ -289,7 +289,19 @@ describe("renderContractLine", () => {
).toBe("contract: 2 checks not run — no check runner is wired");
});

it("carries the warnings the call ran with, after everything else", () => {
const warning =
'requires "organized_files" (task index) has no provider — nothing produces it';
expect(
renderContractLine({ findings: [], checks: [], warnings: [warning] }),
).toBe(`contract: ${warning}`);
expect(
renderContractLine({ findings: [present], checks: [], warnings: [warning] }),
).toBe(`contract: all 1 provide present; ${warning}`);
});

it("is absent when there was nothing to report", () => {
expect(renderContractLine({ findings: [], checks: [] })).toBeUndefined();
expect(renderContractLine({ findings: [], checks: [], warnings: [] })).toBeUndefined();
});
});
49 changes: 19 additions & 30 deletions src/tools/fusion/contract-checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { readFile, stat } from "node:fs/promises";
import { resolveUserPath } from "../os/expand-home.js";
import {
describeProvide,
ownedPaths,
provideSearchPaths,
type ContractCheck,
type ContractProvide,
type DelegateContract,
Expand Down Expand Up @@ -65,6 +65,12 @@ export interface ContractReport {
checks: ContractCheckOutcome[];
/** Why the checks did not run, when they did not. */
checksSkipped?: string;
/**
* What the contract declared that could not be honoured and was run
* anyway — a `requires` no task provides (`contractWarnings`). The
* workers were told; this is the orchestrator's copy.
*/
warnings?: string[];
}

/** Files above this are not searched; a provide is not that big. */
Expand All @@ -74,8 +80,6 @@ const CHECK_DETAIL_CHARS = 400;
/** Bound on the `contract:` line of the status table. */
const CONTRACT_LINE_CHARS = 1200;

const GLOB_CHARS = /[*?[\]{}]/;

function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
Expand All @@ -100,24 +104,13 @@ export function contentProvides(
return content.includes(provide.name);
}

/** Where a non-file provide is looked for: `in`, else owned paths, else declared files. */
function searchPaths(
provide: ContractProvide,
contract: DelegateContract,
task: DelegateTask | undefined,
): string[] {
if (provide.in !== undefined) return [provide.in];
const owned = ownedPaths(contract, provide.task).filter(
(p) => !GLOB_CHARS.test(p),
);
if (owned.length > 0) return owned;
return (task?.files ?? []).filter((f) => !GLOB_CHARS.test(f));
}

/**
* Check every provide against the working directory. Never throws: a
* path that cannot be read counts as not providing, with the reason on
* the finding.
* the finding. A non-file provide with nowhere to be looked for gets no
* finding at all — it is the parser's warning (`contractWarnings`), on
* the `contract:` line already, and a "missing" verdict over a search
* that never happened would read as the worker's failure.
*/
export async function inspectContractProvides(
contract: DelegateContract,
Expand Down Expand Up @@ -159,16 +152,8 @@ export async function inspectContractProvides(
continue;
}
const task = tasks.find((t) => t.id === provide.task);
const where = searchPaths(provide, contract, task);
if (where.length === 0) {
findings.push({
...base,
where,
present: false,
detail: "no file to look in (give `in` or an owners entry)",
});
continue;
}
const where = provideSearchPaths(provide, contract, task);
if (where.length === 0) continue;
let present = false;
let unreadable = 0;
for (const path of where) {
Expand Down Expand Up @@ -318,8 +303,11 @@ export function applyCheckOutcomes(

/**
* The `contract:` line of the status table — presence first, then the
* checks that belong to no task, then why the checks did not run.
* Nothing when the contract declared nothing checkable.
* checks that belong to no task, then why the checks did not run, then
* the warnings the call was run with (an unprovided require, so the
* orchestrator fixes the contract on its next call instead of wondering
* why a worker never found it). Nothing when the contract declared
* nothing checkable and raised no warning.
*/
export function renderContractLine(report: ContractReport): string | undefined {
const parts: string[] = [];
Expand Down Expand Up @@ -348,6 +336,7 @@ export function renderContractLine(report: ContractReport): string | undefined {
);
}
if (report.checksSkipped !== undefined) parts.push(report.checksSkipped);
parts.push(...(report.warnings ?? []));
if (parts.length === 0) return undefined;
return `contract: ${head(parts.join("; "), CONTRACT_LINE_CHARS)}`;
}
156 changes: 156 additions & 0 deletions src/tools/fusion/contract-waves.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import type { DelegateContract } from "./contract.js";
import type { WorkerTaskResult, WorkerTaskStatus } from "./worker-result.js";

/**
* The order a contract puts on a fan-out (F45).
*
* A `requires` names something a sibling `provides`; a worker sent at
* the same time as its provider waits for a file that does not exist
* yet. Live, an orchestrator declared a pipeline — `analyze` provides a
* manifest, `organize` requires it, `index` requires what `organize`
* produces — and sent all three at once: two workers spent their whole
* step budget re-checking for the missing input and nothing was done.
*
* So the fan-out runs in waves: a task that requires X depends on every
* task that provides X; wave 1 is every task with no unmet dependency,
* and each later wave is the tasks whose dependencies have all finished
* — with any status. A provider that ended `failed`, `cancelled`,
* `no_changes` or `needs_orchestrator` did not deliver, but its
* dependent still runs: the orchestrator gets every report either way,
* and the dependent's CONTRACT block carries a warning naming what did
* not arrive (`dependencyWarnings`, through the F44 warning slot).
*
* A cycle in the requires is one wave, in the order given, with a
* warning. A contract without `requires`, or whose requires name
* nothing any task provides, orders nothing: one wave, exactly the
* fan-out that ran before waves existed.
*/
export interface WavePlan {
/** Task ids, wave by wave; each wave in the caller's task order. */
waves: string[][];
/**
* Task id → the provider tasks it waits for, in the contract's order
* with duplicates dropped. Empty when the contract orders nothing.
*/
dependencies: ReadonlyMap<string, readonly string[]>;
/** The warning when the requires form a cycle; the plan is then one wave. */
cycle?: string;
}

/** A task that requires X depends on every OTHER task that provides X. */
export function dependenciesOf(
taskIds: readonly string[],
contract: DelegateContract | undefined,
): Map<string, string[]> {
const known = new Set(taskIds);
const dependencies = new Map<string, string[]>();
for (const require of contract?.requires ?? []) {
if (!known.has(require.task)) continue;
for (const provide of contract?.provides ?? []) {
if (provide.name !== require.name) continue;
if (provide.task === require.task || !known.has(provide.task)) continue;
const own = dependencies.get(require.task) ?? [];
if (!own.includes(provide.task)) own.push(provide.task);
dependencies.set(require.task, own);
}
}
return dependencies;
}

/**
* Follow unmet dependencies from the first stuck task until one
* repeats: `a → b → a` reads "a requires b requires a". Every stuck
* task has an unmet dependency among the stuck ones (that is what
* stuck means), so the walk closes within their number.
*/
function describeCycle(
stuck: readonly string[],
dependencies: ReadonlyMap<string, readonly string[]>,
): string {
const among = new Set(stuck);
const path: string[] = [];
let node = stuck[0]!;
while (!path.includes(node)) {
path.push(node);
node = (dependencies.get(node) ?? []).find((d) => among.has(d)) ?? node;
}
return [...path.slice(path.indexOf(node)), node].join(" → ");
}

/** `requires form a cycle (a → b → a), so the tasks run in one wave in the order given` */
export function describeCycleWarning(cycle: string): string {
return `requires form a cycle (${cycle}), so the tasks run in one wave in the order given`;
}

export function planWaves(
taskIds: readonly string[],
contract: DelegateContract | undefined,
): WavePlan {
const dependencies = dependenciesOf(taskIds, contract);
const waves: string[][] = [];
const done = new Set<string>();
let remaining = [...taskIds];
while (remaining.length > 0) {
const wave = remaining.filter((id) =>
(dependencies.get(id) ?? []).every((d) => done.has(d)),
);
if (wave.length === 0) {
return {
waves: [[...taskIds]],
dependencies,
cycle: describeCycleWarning(describeCycle(remaining, dependencies)),
};
}
waves.push(wave);
for (const id of wave) done.add(id);
remaining = remaining.filter((id) => !done.has(id));
}
return { waves, dependencies };
}

/** A provider that ended one of these did not deliver what its dependents rely on. */
export const UNDELIVERED_STATUSES: ReadonlySet<WorkerTaskStatus> = new Set([
"failed",
"cancelled",
"no_changes",
"needs_orchestrator",
]);

/**
* One warning per (dependent, undelivered provider) pair in `wave`,
* from the results so far: `task organize depends on analyze, which
* ended no_changes`. Goes into the wave's CONTRACT block (the worker
* learns not to wait for it) and onto the result's `contract:` line.
*/
export function dependencyWarnings(
wave: readonly string[],
dependencies: ReadonlyMap<string, readonly string[]>,
finished: ReadonlyMap<string, WorkerTaskResult>,
): string[] {
const warnings: string[] = [];
for (const id of wave) {
for (const provider of dependencies.get(id) ?? []) {
const result = finished.get(provider);
if (result === undefined || !UNDELIVERED_STATUSES.has(result.status)) {
continue;
}
warnings.push(
`task ${id} depends on ${provider}, which ended ${result.status}`,
);
}
}
return warnings;
}

/**
* The contract a wave's workers read: the parsed one, plus the warnings
* the earlier waves produced. The very object when there is nothing to
* add, so a fan-out the contract does not order briefs byte-identically.
*/
export function contractForWave(
contract: DelegateContract | undefined,
extra: readonly string[],
): DelegateContract | undefined {
if (contract === undefined || extra.length === 0) return contract;
return { ...contract, warnings: [...(contract.warnings ?? []), ...extra] };
}
Loading