@xingjisoft/neutrino 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,239 @@
1
+ import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
2
+ const DEFAULT_LIMITS = { cpuMs: 3000, memoryBytes: 32 * 1024 * 1024, idleMs: 30_000 };
3
+ /** The worker owns QuickJS; only named tool RPCs can reach the host. */
4
+ export class LocalCodeExecutor {
5
+ signal;
6
+ limits;
7
+ constructor(signal, limits = {}) {
8
+ this.signal = signal;
9
+ this.limits = limits;
10
+ }
11
+ async execute(code, providersOrFns) {
12
+ if (this.signal?.aborted)
13
+ return { result: null, error: "Execution cancelled" };
14
+ const providers = Array.isArray(providersOrFns)
15
+ ? providersOrFns : [{ name: "codemode", fns: providersOrFns }];
16
+ const limits = { ...DEFAULT_LIMITS, ...this.limits };
17
+ return new Promise(resolve => {
18
+ const worker = new Worker(new URL(import.meta.url), {
19
+ execArgv: process.execArgv.filter(arg => !arg.startsWith("--input-type")),
20
+ workerData: { neutrinoCode: true, code, limits, providers: providers.map(p => ({ name: p.name, tools: Object.keys(p.fns) })) },
21
+ });
22
+ let finished = false;
23
+ let finalResultReceived = false;
24
+ let pending = 0;
25
+ let lastActivity = Date.now();
26
+ let calls = 0;
27
+ // The existing approval UI has one active decision. Serialize RPCs so a
28
+ // Promise.all cannot overwrite an earlier approval or race side effects.
29
+ let queue = Promise.resolve();
30
+ const finish = (result) => {
31
+ if (finished)
32
+ return;
33
+ finished = true;
34
+ clearInterval(watchdog);
35
+ this.signal?.removeEventListener("abort", abort);
36
+ void worker.terminate();
37
+ resolve(result);
38
+ };
39
+ const abort = () => finish({ result: null, error: String(this.signal?.reason ?? "Execution cancelled") });
40
+ const watchdog = setInterval(() => {
41
+ if (!pending && Date.now() - lastActivity > limits.idleMs)
42
+ finish({ result: null, error: "Program timed out without completing" });
43
+ }, 100);
44
+ this.signal?.addEventListener("abort", abort, { once: true });
45
+ if (this.signal?.aborted) {
46
+ abort();
47
+ return;
48
+ }
49
+ worker.on("error", error => finish({ result: null, error: error.message }));
50
+ worker.on("exit", code => { if (!finished && !finalResultReceived)
51
+ finish({ result: null, error: `Execution worker exited (${code})` }); });
52
+ worker.on("message", message => {
53
+ if (finished)
54
+ return;
55
+ lastActivity = Date.now();
56
+ if (message.type === "result") {
57
+ finalResultReceived = true;
58
+ // Report promptly so the program owner can abort host operations,
59
+ // including an outstanding human approval, then settle its ledger.
60
+ finish(pending && !message.value.error
61
+ ? { ...message.value, error: "Program returned with unawaited tool calls; inspect completed operations" }
62
+ : message.value);
63
+ return;
64
+ }
65
+ if (message.type !== "tool")
66
+ return;
67
+ if (++calls > 256) {
68
+ finish({ result: null, error: "Program exceeded 256 tool/progress calls" });
69
+ return;
70
+ }
71
+ pending++;
72
+ queue = queue.then(async () => {
73
+ if (finished || finalResultReceived) {
74
+ pending--;
75
+ return;
76
+ }
77
+ let value;
78
+ let error;
79
+ try {
80
+ const provider = providers.find(p => p.name === message.provider);
81
+ if (!provider || !Object.hasOwn(provider.fns, message.name))
82
+ throw new Error("Unknown program tool");
83
+ value = await provider.fns[message.name](message.args);
84
+ }
85
+ catch (err) {
86
+ error = err instanceof Error ? err.message : String(err);
87
+ }
88
+ finally {
89
+ pending--;
90
+ lastActivity = Date.now();
91
+ }
92
+ if (!finished && !finalResultReceived)
93
+ worker.postMessage({ type: "tool-result", id: message.id, value, error });
94
+ }).catch(error => finish({ result: null, error: String(error) }));
95
+ });
96
+ });
97
+ }
98
+ }
99
+ async function runSandbox() {
100
+ const { getQuickJS } = await import("quickjs-emscripten");
101
+ const module = await getQuickJS();
102
+ const runtime = module.newRuntime();
103
+ runtime.setMemoryLimit(workerData.limits.memoryBytes);
104
+ runtime.setMaxStackSize(256 * 1024);
105
+ const vm = runtime.newContext();
106
+ const pending = new Map();
107
+ const logs = [];
108
+ let logSize = 0;
109
+ let nextId = 0;
110
+ let remainingCpu = workerData.limits.cpuMs;
111
+ let sliceStart = Date.now();
112
+ let closed = false;
113
+ runtime.setInterruptHandler(() => Date.now() - sliceStart >= remainingCpu);
114
+ const run = (operation) => {
115
+ sliceStart = Date.now();
116
+ try {
117
+ return operation();
118
+ }
119
+ finally {
120
+ remainingCpu -= Date.now() - sliceStart;
121
+ }
122
+ };
123
+ const errorText = (value) => typeof value === "object" && value && "message" in value
124
+ ? String(value.message) : String(value);
125
+ const complete = (value) => {
126
+ if (closed)
127
+ return;
128
+ closed = true;
129
+ parentPort.postMessage({ type: "result", value: { ...value, logs } });
130
+ };
131
+ for (const provider of workerData.providers) {
132
+ const object = vm.newObject();
133
+ for (const name of provider.tools) {
134
+ const fn = vm.newFunction(name, args => {
135
+ const deferred = vm.newPromise();
136
+ const id = ++nextId;
137
+ pending.set(id, deferred);
138
+ parentPort.postMessage({ type: "tool", id, provider: provider.name, name, args: args ? vm.dump(args) : {} });
139
+ return deferred.handle;
140
+ });
141
+ vm.setProp(object, name, fn);
142
+ fn.dispose();
143
+ }
144
+ vm.setProp(vm.global, provider.name, object);
145
+ object.dispose();
146
+ }
147
+ const consoleObject = vm.newObject();
148
+ for (const name of ["log", "warn", "error"]) {
149
+ const fn = vm.newFunction(name, (...args) => {
150
+ if (logSize >= 8000)
151
+ return;
152
+ const line = args.map(arg => {
153
+ const value = vm.dump(arg);
154
+ return typeof value === "string" ? value : JSON.stringify(value);
155
+ }).join(" ").slice(0, 8000 - logSize);
156
+ logs.push(line);
157
+ logSize += line.length;
158
+ });
159
+ vm.setProp(consoleObject, name, fn);
160
+ fn.dispose();
161
+ }
162
+ vm.setProp(vm.global, "console", consoleObject);
163
+ consoleObject.dispose();
164
+ parentPort.on("message", message => {
165
+ if (closed || message.type !== "tool-result")
166
+ return;
167
+ const deferred = pending.get(message.id);
168
+ if (!deferred)
169
+ return;
170
+ pending.delete(message.id);
171
+ try {
172
+ if (message.error) {
173
+ const error = vm.newError(message.error);
174
+ deferred.reject(error);
175
+ error.dispose();
176
+ }
177
+ else {
178
+ const handle = run(() => vm.unwrapResult(vm.evalCode(`(${JSON.stringify(message.value ?? null)})`)));
179
+ deferred.resolve(handle);
180
+ handle.dispose();
181
+ }
182
+ }
183
+ catch (error) {
184
+ complete({ result: null, error: errorText(error) });
185
+ }
186
+ finally {
187
+ deferred.dispose();
188
+ }
189
+ });
190
+ const program = String(workerData.code).trim().replace(/;+\s*$/, "");
191
+ const evaluated = run(() => vm.evalCode(`(${program})()`));
192
+ if (evaluated.error) {
193
+ const error = errorText(vm.dump(evaluated.error));
194
+ evaluated.dispose();
195
+ complete({ result: null, error });
196
+ }
197
+ else {
198
+ const resultHandle = evaluated.value;
199
+ while (!closed) {
200
+ if (remainingCpu <= 0) {
201
+ complete({ result: null, error: "Program exceeded CPU time limit" });
202
+ break;
203
+ }
204
+ const jobs = run(() => runtime.executePendingJobs(100));
205
+ if (jobs.error) {
206
+ const error = errorText(vm.dump(jobs.error));
207
+ jobs.dispose();
208
+ complete({ result: null, error });
209
+ break;
210
+ }
211
+ const state = run(() => vm.getPromiseState(resultHandle));
212
+ if (state.type === "fulfilled") {
213
+ try {
214
+ complete({ result: vm.dump(state.value), ...(pending.size ? { error: "Program returned with unawaited tool calls; inspect completed operations" } : {}) });
215
+ }
216
+ finally {
217
+ state.value.dispose();
218
+ }
219
+ break;
220
+ }
221
+ if (state.type === "rejected") {
222
+ const error = errorText(vm.dump(state.error));
223
+ state.error.dispose();
224
+ complete({ result: null, error });
225
+ break;
226
+ }
227
+ await new Promise(resolve => setTimeout(resolve, 2));
228
+ }
229
+ resultHandle.dispose();
230
+ }
231
+ for (const deferred of pending.values())
232
+ deferred.dispose();
233
+ vm.dispose();
234
+ runtime.dispose();
235
+ parentPort.close();
236
+ }
237
+ if (!isMainThread && workerData?.neutrinoCode) {
238
+ void runSandbox().catch(error => parentPort.postMessage({ type: "result", value: { result: null, error: String(error) } }));
239
+ }
@@ -0,0 +1,164 @@
1
+ import { createBrowserCodeTool } from "@cloudflare/codemode/browser";
2
+ import ts from "typescript";
3
+ import { getToolDefinitions } from "./tools.js";
4
+ import { LocalCodeExecutor } from "./codeExecutor.js";
5
+ export const CODE_TOOL = "execute_ts";
6
+ export class ProgramToolDenied extends Error {
7
+ }
8
+ function descriptors(call, step) {
9
+ const tools = {};
10
+ for (const definition of getToolDefinitions()) {
11
+ const { name, description, parameters } = definition.function;
12
+ tools[name] = {
13
+ description, inputSchema: parameters, outputSchema: { type: "string" },
14
+ // Approval is inside the callback. SDK needsApproval would exclude tools
15
+ // entirely rather than use Neutrino's per-call, argument-aware gate.
16
+ execute: async (args) => {
17
+ if (!args || typeof args !== "object" || Array.isArray(args))
18
+ throw new Error(`${name} needs an argument object`);
19
+ const properties = parameters.properties;
20
+ for (const required of parameters.required ?? []) {
21
+ if (typeof args[required] !== properties[required]?.type || (typeof args[required] === "string" && !args[required].trim())) {
22
+ throw new Error(`${name}: invalid or missing ${required}`);
23
+ }
24
+ }
25
+ if (typeof args.reason !== "string" || !args.reason.trim())
26
+ throw new Error(`${name} needs an honest reason`);
27
+ return call(name, args);
28
+ },
29
+ };
30
+ }
31
+ for (const [name, status] of [["set_step", "active"], ["complete_step", "done"]]) {
32
+ tools[name] = {
33
+ description: `${status === "active" ? "Start" : "Complete after verification"} a zero-based step of this execution's plan.`,
34
+ inputSchema: { type: "object", properties: { index: { type: "integer" } }, required: ["index"] },
35
+ outputSchema: { type: "string" },
36
+ execute: async (args) => { step(Number(args.index), status); return "Progress recorded"; },
37
+ };
38
+ }
39
+ return tools;
40
+ }
41
+ const DESCRIPTION = `Plan-Then-Code: execute a TypeScript async arrow function to compose multiple tool operations.
42
+ Supply a short plan before the code. All external work uses codemode tools below;
43
+ each actual call must include a reason and passes the normal approval gate.
44
+ Use codemode.set_step({index}) and codemode.complete_step({index}) for progress.
45
+ Imports, process, require, direct network and host filesystem APIs are unavailable.
46
+ Keep intermediate data local and return only necessary evidence. console output is bounded.
47
+ Tool RPCs are serialized; Promise.all preserves correctness but does not parallelize side effects.
48
+ On error, inspect completed calls before continuing; never blindly repeat writes or commands.
49
+ Available typed API:
50
+ {{types}}`;
51
+ let definition;
52
+ export function getCodeToolDefinition() {
53
+ if (definition)
54
+ return definition;
55
+ const descriptor = createBrowserCodeTool({
56
+ tools: descriptors(async () => "", () => { }),
57
+ executor: new LocalCodeExecutor(), description: DESCRIPTION,
58
+ });
59
+ return definition = {
60
+ type: "function",
61
+ function: {
62
+ name: CODE_TOOL, description: descriptor.description,
63
+ parameters: {
64
+ type: "object",
65
+ properties: {
66
+ plan: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 10, description: "Short ordered steps, written before the code." },
67
+ code: { type: "string", description: "An async TypeScript arrow function: async () => { ...; return evidence; }" },
68
+ reason: { type: "string", description: "An honest sentence explaining why this execution is needed." },
69
+ },
70
+ required: ["plan", "code", "reason"],
71
+ },
72
+ },
73
+ };
74
+ }
75
+ /** Only the program's return, bounded logs and completion ledger enter LLM context. */
76
+ export async function executeCodeProgram(args, options) {
77
+ const completed = [];
78
+ const controller = new AbortController();
79
+ const inFlight = new Set();
80
+ const abort = () => controller.abort(options.signal?.reason ?? "Execution cancelled");
81
+ options.signal?.addEventListener("abort", abort, { once: true });
82
+ if (options.signal?.aborted)
83
+ abort();
84
+ let plan;
85
+ try {
86
+ if (typeof args.code !== "string" || !args.code.trim() || args.code.length > 200_000)
87
+ throw new Error("code must contain at most 200000 characters");
88
+ if (typeof args.reason !== "string" || !args.reason.trim())
89
+ throw new Error("execute_ts needs an honest reason");
90
+ if (!Array.isArray(args.plan) || args.plan.length < 1 || args.plan.length > 10 || args.plan.some(s => typeof s !== "string" || !s.trim() || s.length > 160)) {
91
+ throw new Error("plan must contain 1–10 short nonempty steps (160 characters each)");
92
+ }
93
+ if (controller.signal.aborted)
94
+ throw new Error("Execution cancelled");
95
+ plan = { goal: args.reason, steps: args.plan.map(title => ({ title: title.trim(), status: "pending" })) };
96
+ options.onPlan(plan);
97
+ options.onStep(0, "active");
98
+ const source = args.code.trim().replace(/^```(?:typescript|ts|javascript|js)?\s*\n([\s\S]*?)\n```$/i, "$1");
99
+ const compiled = ts.transpileModule(source, {
100
+ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
101
+ fileName: "agent-program.ts", reportDiagnostics: true,
102
+ });
103
+ const errors = compiled.diagnostics?.filter(d => d.category === ts.DiagnosticCategory.Error) ?? [];
104
+ if (errors.length)
105
+ throw new Error(errors.map(d => ts.flattenDiagnosticMessageText(d.messageText, "\n")).join("\n"));
106
+ const tool = createBrowserCodeTool({
107
+ executor: new LocalCodeExecutor(controller.signal), description: DESCRIPTION,
108
+ tools: descriptors(async (name, callArgs) => {
109
+ if (controller.signal.aborted)
110
+ throw new Error("Execution cancelled");
111
+ try {
112
+ const operation = options.call(name, callArgs, controller.signal);
113
+ inFlight.add(operation);
114
+ let value;
115
+ try {
116
+ value = await operation;
117
+ }
118
+ finally {
119
+ inFlight.delete(operation);
120
+ }
121
+ const target = typeof callArgs.path === "string" ? callArgs.path : typeof callArgs.command === "string" ? callArgs.command.slice(0, 200) : undefined;
122
+ completed.push({ tool: name, ...(target ? { target } : {}) });
123
+ return value;
124
+ }
125
+ catch (error) {
126
+ // Once denied, even code that catches the exception cannot issue a
127
+ // different tool call to work around the verdict.
128
+ if (error instanceof ProgramToolDenied)
129
+ controller.abort(error.message);
130
+ throw error;
131
+ }
132
+ }, (index, status) => {
133
+ if (!Number.isInteger(index) || index < 0 || index >= plan.steps.length)
134
+ throw new Error("Invalid plan step index");
135
+ options.onStep(index, status);
136
+ }),
137
+ });
138
+ const output = await tool.execute({ code: compiled.outputText });
139
+ if (plan.steps.length === 1)
140
+ options.onStep(0, "done");
141
+ if (plan.steps.some(s => s.status !== "done"))
142
+ plan.stopped = true;
143
+ return boundedJson({ ok: true, ...output, completed });
144
+ }
145
+ catch (error) {
146
+ controller.abort(error instanceof Error ? error.message : String(error));
147
+ await Promise.allSettled([...inFlight]);
148
+ if (plan) {
149
+ for (const step of plan.steps)
150
+ if (step.status === "active")
151
+ step.status = "failed";
152
+ plan.stopped = true;
153
+ }
154
+ return boundedJson({ ok: false, error: error instanceof Error ? error.message : String(error), completed,
155
+ instruction: "Inspect the completed-call ledger and saved tool results. Do not replay completed side effects. Respect any approval denial." });
156
+ }
157
+ finally {
158
+ options.signal?.removeEventListener("abort", abort);
159
+ }
160
+ }
161
+ function boundedJson(value) {
162
+ const text = JSON.stringify(value, (_key, v) => typeof v === "bigint" ? String(v) : v);
163
+ return text.length > 16_000 ? `${text.slice(0, 16_000)}\n[Program result truncated; return a smaller summary.]` : text;
164
+ }
@@ -0,0 +1,97 @@
1
+ import { TaskControlError } from "./runtime.js";
2
+ /** Spend only the root's reserved report request; local evidence remains the fallback. */
3
+ export async function finalTaskReport(runtime, createClient, onUsage) {
4
+ const recorded = runtime.partialReport();
5
+ try {
6
+ const client = createClient();
7
+ const report = await client.complete("The task stopped at a shared budget limit. Produce a brief partial report in the user's language using only the recorded operations. State what was actually completed and what remains unverified. Do not claim the entire task succeeded. No tools or further work are available.", JSON.stringify({ request: runtime.originalRequest, evidence: recorded }), { signal: runtime.signal, final: true, maxOutputTokens: 1024 });
8
+ if (client.usage)
9
+ onUsage?.(client.usage);
10
+ if (client.outcome?.status === "completed" && report.trim())
11
+ return `${report}\n\n[Task result is partial: shared budget exhausted.]`;
12
+ }
13
+ catch { /* A request/token limit may leave no reporting capacity. */ }
14
+ return recorded;
15
+ }
16
+ /** Context scheduling estimate for mixed ASCII/CJK; provider usage is also checked. */
17
+ export function estimateTokens(value) {
18
+ const text = typeof value === "string" ? value : JSON.stringify(value);
19
+ let ascii = 0;
20
+ let other = 0;
21
+ for (const char of text) {
22
+ if (char.codePointAt(0) < 128)
23
+ ascii++;
24
+ else
25
+ other++;
26
+ }
27
+ return Math.ceil(ascii / 3) + other;
28
+ }
29
+ export const DEFAULT_OUTPUT_TOKENS = 16_384;
30
+ export function outputAllowance(capacity) { return Math.max(64, Math.min(DEFAULT_OUTPUT_TOKENS, Math.floor(capacity * 0.1))); }
31
+ /** Reserve a byte-based upper estimate, including framing, when usage is unknown. */
32
+ export function inputTokenBound(value) { return Buffer.byteLength(JSON.stringify(value), "utf8") + 512; }
33
+ function truncate(text, tokens) {
34
+ if (tokens <= 0)
35
+ return "";
36
+ let low = 0;
37
+ let high = text.length;
38
+ while (low < high) {
39
+ const mid = Math.ceil((low + high) / 2);
40
+ if (estimateTokens(text.slice(0, mid)) <= tokens)
41
+ low = mid;
42
+ else
43
+ high = mid - 1;
44
+ }
45
+ return text.slice(0, low);
46
+ }
47
+ /** Compact complete assistant/tool groups before any request, including wrap-up. */
48
+ export async function prepareContext(options) {
49
+ const { messages, tools, capacity } = options;
50
+ const schema = estimateTokens(tools) + 32;
51
+ const allowance = outputAllowance(capacity);
52
+ const limit = Math.floor(capacity * 0.8) - schema - allowance;
53
+ if (Math.max(estimateTokens(messages), (options.lastPromptTokens ?? 0) - schema) <= limit)
54
+ return;
55
+ // Keep the system policy and current user input intact. Earlier history is evidence.
56
+ let lastUser = -1;
57
+ for (let i = 0; i < messages.length; i++)
58
+ if (messages[i].role === "user")
59
+ lastUser = i;
60
+ // A wrap-up instruction is appended after preparation, so this is the actual brief.
61
+ const head = options.preserveBrief ? messages.slice(0, 2) : [messages[0], ...(lastUser > 0 ? [messages[lastUser]] : [])];
62
+ const available = limit - estimateTokens(head) - 64;
63
+ if (available < 64)
64
+ throw new TaskControlError("context_capacity", "The task brief and tool definitions exceed this model's context capacity");
65
+ const body = options.preserveBrief ? messages.slice(2) : messages.filter((_, index) => index > 0 && index !== lastUser);
66
+ if (!body.length)
67
+ return;
68
+ options.onProgress?.();
69
+ const starts = body.flatMap((message, index) => message.role === "assistant" ? [index] : []);
70
+ let keep = starts.at(-1) ?? body.length;
71
+ if (estimateTokens(body.slice(keep)) > available * 0.45)
72
+ keep = body.length;
73
+ const transcript = JSON.stringify(body.slice(0, keep));
74
+ const inputBudget = Math.max(64, Math.floor(capacity * 0.45));
75
+ const input = estimateTokens(transcript) > inputBudget
76
+ ? `${truncate(transcript, Math.floor(inputBudget / 2))}\n[Truncated; full tool records remain in the task ledger]\n${[...truncate([...transcript].reverse().join(""), Math.floor(inputBudget / 2))].reverse().join("")}`
77
+ : transcript;
78
+ let summary = "";
79
+ try {
80
+ const client = options.createClient();
81
+ summary = await client.summarize(input, { ...options.stream, maxOutputTokens: Math.min(allowance, Math.floor(available / 2)) });
82
+ if (client.usage)
83
+ options.onUsage?.(client.usage);
84
+ }
85
+ catch { /* Fall back to a bounded local record, never erase the durable ledger. */ }
86
+ if (options.stream?.cancelled?.() || options.stream?.signal?.aborted)
87
+ throw new TaskControlError("cancelled", "Task cancelled during context compression");
88
+ const retained = body.slice(keep);
89
+ const fallback = "Earlier activity is recorded in the task ledger. Unverified work remains incomplete.\n" +
90
+ body.filter(message => message.role === "tool").slice(-12).map(message => `${message.tool_call_id}: ${message.content?.slice(0, 160) ?? ""}`).join("\n");
91
+ const summaryBudget = Math.max(32, available - estimateTokens(retained) - 32);
92
+ const compacted = { role: "assistant", content: truncate(summary || fallback, summaryBudget) };
93
+ messages.splice(0, messages.length, ...head, compacted, ...retained);
94
+ if (estimateTokens(messages) > limit) {
95
+ messages.splice(head.length, messages.length - head.length, { role: "assistant", content: truncate(summary || fallback, available - 32) });
96
+ }
97
+ }
package/dist/llm/plan.js CHANGED
@@ -12,6 +12,9 @@
12
12
  * The model calls themselves live in the two agent loops.
13
13
  */
14
14
  import { displayWidth, ellipsize } from "../width.js";
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { currentTask } from "./runtime.js";
15
18
  /**
16
19
  * One step is not a plan, it is the answer: such a turn runs as a plain turn.
17
20
  * The ceiling keeps the card readable and the turn's cost proportionate.
@@ -89,10 +92,15 @@ function normalizeStep(value) {
89
92
  if (!title)
90
93
  return null;
91
94
  const detail = cleanText(o.detail ?? o.description ?? o.how ?? o.acceptance, PLAN_MAX_DETAIL);
92
- return { title, status: "pending", ...(detail ? { detail } : {}) };
95
+ const id = cleanText(o.id, 80);
96
+ const strings = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string").slice(0, 10).map(v => cleanText(v, PLAN_MAX_DETAIL)) : undefined;
97
+ const dependsOn = strings(o.dependsOn ?? o.depends_on);
98
+ const acceptanceCriteria = strings(o.acceptanceCriteria ?? o.acceptance_criteria);
99
+ return { title, status: "pending", ...(detail ? { detail } : {}), ...(id ? { id } : {}),
100
+ ...(dependsOn ? { dependsOn } : {}), ...(acceptanceCriteria ? { acceptanceCriteria } : {}) };
93
101
  }
94
102
  /** Turn an already-parsed planner answer into a plan, or null when unusable. */
95
- export function normalizePlan(value) {
103
+ export function normalizePlan(value, minSteps = PLAN_MIN_STEPS) {
96
104
  if (!value || typeof value !== "object")
97
105
  return null;
98
106
  const o = value;
@@ -107,7 +115,7 @@ export function normalizePlan(value) {
107
115
  if (steps.length >= PLAN_MAX_STEPS)
108
116
  break;
109
117
  }
110
- if (steps.length < PLAN_MIN_STEPS)
118
+ if (steps.length < minSteps)
111
119
  return null;
112
120
  const goal = cleanText(o.goal ?? o.title ?? o.summary ?? o.request, 240);
113
121
  return { goal, steps };
@@ -157,7 +165,7 @@ function jsonCandidates(text) {
157
165
  return out;
158
166
  }
159
167
  /** A numbered or bulleted list, for a planner that answered in prose. */
160
- function planFromList(text) {
168
+ function planFromList(text, minSteps = PLAN_MIN_STEPS) {
161
169
  const steps = [];
162
170
  for (const line of text.split("\n")) {
163
171
  // "1." "1)" "1、" and bullets all show up in practice, with or without a
@@ -172,7 +180,7 @@ function planFromList(text) {
172
180
  if (steps.length >= PLAN_MAX_STEPS)
173
181
  break;
174
182
  }
175
- if (steps.length < PLAN_MIN_STEPS)
183
+ if (steps.length < minSteps)
176
184
  return null;
177
185
  return { goal: "", steps };
178
186
  }
@@ -181,7 +189,7 @@ function planFromList(text) {
181
189
  * then a numbered list so a model that ignores the format still produces a
182
190
  * usable plan. Anything else is "no plan": the turn then runs as before.
183
191
  */
184
- export function parsePlan(text) {
192
+ export function parsePlan(text, minSteps = PLAN_MIN_STEPS) {
185
193
  const raw = String(text ?? "").trim();
186
194
  if (!raw || /^\[Error/.test(raw))
187
195
  return null;
@@ -193,11 +201,11 @@ export function parsePlan(text) {
193
201
  catch {
194
202
  continue;
195
203
  }
196
- const plan = normalizePlan(value);
204
+ const plan = normalizePlan(value, minSteps);
197
205
  if (plan)
198
206
  return plan;
199
207
  }
200
- return planFromList(raw);
208
+ return planFromList(raw, minSteps);
201
209
  }
202
210
  /**
203
211
  * The user turn handed to the planner agent. The planner gets no tools, so
@@ -281,7 +289,7 @@ export function resumePlan(loaded) {
281
289
  return null;
282
290
  loaded.steps = steps;
283
291
  for (const step of steps) {
284
- if (step.status !== "pending" && step.status !== "active" && step.status !== "done" && step.status !== "failed") {
292
+ if (!["pending", "active", "done", "failed", "blocked", "cancelled", "skipped"].includes(step.status)) {
285
293
  step.status = "pending";
286
294
  }
287
295
  if (step.status === "active") {
@@ -372,6 +380,9 @@ const STEP_GLYPH = {
372
380
  active: "●",
373
381
  done: "✓",
374
382
  failed: "✗",
383
+ blocked: "!",
384
+ cancelled: "⊘",
385
+ skipped: "–",
375
386
  };
376
387
  /**
377
388
  * Lay the plan out as a vertical timeline, top to bottom in execution order.
@@ -450,3 +461,80 @@ export function planRowsWidth(rows) {
450
461
  width = Math.max(width, displayWidth(row.text));
451
462
  return width;
452
463
  }
464
+ /** Pro plans use explicit step acceptance instead of inferring success from prose. */
465
+ export const FINISH_STEP_TOOL = "finish_step";
466
+ export const FINISH_STEP_DEFINITION = {
467
+ type: "function", function: {
468
+ name: FINISH_STEP_TOOL,
469
+ description: "Record acceptance of one plan step. Only the main coordinator can accept steps. For done, reference actual successful evidence IDs or existing artifact paths, or provide the final text deliverable. Use blocked/failed for unresolved work. Finishing a reply never completes plan steps automatically.",
470
+ parameters: { type: "object", properties: {
471
+ step_id: { type: "string" },
472
+ status: { type: "string", enum: ["done", "blocked", "failed", "skipped"] },
473
+ summary: { type: "string" },
474
+ evidence_ids: { type: "array", items: { type: "string" } },
475
+ artifact_paths: { type: "array", items: { type: "string" } },
476
+ deliverable: { type: "string", description: "The actual final content, only for a text deliverable." },
477
+ reason: { type: "string" },
478
+ }, required: ["step_id", "status", "summary", "reason"] },
479
+ },
480
+ };
481
+ export function preparePlan(plan) {
482
+ const ids = new Set();
483
+ for (let i = 0; i < plan.steps.length; i++) {
484
+ const step = plan.steps[i];
485
+ step.id ||= `step-${i + 1}`;
486
+ if (ids.has(step.id))
487
+ throw new Error("Plan contains duplicate step IDs");
488
+ const dependencies = step.dependsOn ?? (i ? [plan.steps[i - 1].id] : []);
489
+ if (dependencies.some(id => !ids.has(id)))
490
+ throw new Error("Plan dependencies must refer to earlier steps");
491
+ ids.add(step.id);
492
+ step.dependsOn = dependencies;
493
+ step.acceptanceCriteria ??= [step.detail || step.title];
494
+ }
495
+ return plan;
496
+ }
497
+ export function finishPlanStep(plan, args, runtime) {
498
+ if (currentTask()?.runId !== runtime.rootRunId)
499
+ return "[Error: only the root coordinator can accept plan steps]";
500
+ const step = plan?.steps.find(entry => entry.id === args.step_id);
501
+ if (!plan || !step)
502
+ return "[Error: unknown plan step_id]";
503
+ const status = String(args.status);
504
+ if (!["done", "blocked", "failed", "skipped"].includes(status) || typeof args.summary !== "string" || !args.summary.trim()) {
505
+ return "[Error: finish_step requires a valid status and a nonempty summary]";
506
+ }
507
+ const ids = Array.isArray(args.evidence_ids) ? args.evidence_ids.filter((id) => typeof id === "string") : [];
508
+ const artifacts = Array.isArray(args.artifact_paths) ? args.artifact_paths.filter((p) => typeof p === "string") : [];
509
+ if (status === "done") {
510
+ if (step.dependsOn?.some(id => plan.steps.find(entry => entry.id === id)?.status !== "done"))
511
+ return "[Error: step dependencies have not been completed]";
512
+ if (ids.some(id => !runtime.evidence.some(entry => entry.id === id && entry.status === "completed")))
513
+ return "[Error: completion references missing or unsuccessful evidence]";
514
+ if (artifacts.some(p => !fs.existsSync(path.resolve(runtime.cwd, p))))
515
+ return "[Error: completion references a missing artifact]";
516
+ if (!ids.length && !artifacts.length && !(typeof args.deliverable === "string" && args.deliverable.trim())) {
517
+ return "[Error: done requires actual evidence, an existing artifact, or the final text deliverable]";
518
+ }
519
+ }
520
+ step.status = status;
521
+ step.summary = args.summary.trim();
522
+ if (typeof args.deliverable === "string")
523
+ step.deliverable = args.deliverable;
524
+ step.evidenceIds = ids;
525
+ step.artifacts = artifacts;
526
+ plan.stopped = plan.steps.some(entry => ["blocked", "failed", "cancelled"].includes(entry.status));
527
+ const next = plan.steps.find(entry => entry.status === "pending" && entry.dependsOn?.every(id => plan.steps.find(s => s.id === id)?.status === "done"));
528
+ if (status === "done" && next)
529
+ next.status = "active";
530
+ return JSON.stringify({ step_id: step.id, status: step.status, summary: step.summary });
531
+ }
532
+ export function settlePlan(plan, cancelled = false) {
533
+ if (!plan)
534
+ return;
535
+ for (const step of plan.steps)
536
+ if (step.status === "active")
537
+ step.status = cancelled ? "cancelled" : "blocked";
538
+ if (plan.steps.some(step => step.status !== "done" && step.status !== "skipped"))
539
+ plan.stopped = true;
540
+ }