@chrok/pi-braid 0.4.0 → 0.5.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.
- package/README.md +119 -56
- package/dist/command.js +126 -7
- package/dist/display.js +62 -14
- package/dist/drain.js +11 -5
- package/dist/index.js +136 -90
- package/dist/jobs.js +136 -16
- package/dist/runner.js +166 -80
- package/dist/status-text.js +89 -3
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { registerBraidCommand, registerBraidWidget } from "./command.js";
|
|
|
9
9
|
import { registerReviewCommand } from "./review.js";
|
|
10
10
|
import { BRAID_DRAIN_GUIDANCE, BRAID_FINISH_GUIDANCE, isSingleShot, registerDrain } from "./drain.js";
|
|
11
11
|
import { renderGraphCall, renderGraphResult, renderNodeResult } from "./display.js";
|
|
12
|
-
import { formatJobList, formatJobStatus, formatNodeStatus, formatReceipt, formatResume, formatUpdate } from "./status-text.js";
|
|
12
|
+
import { formatCancelExecutions, formatHold, formatJobList, formatJobStatus, formatNodeStatus, formatPause, formatReceipt, formatResume, formatTimeoutPause, formatUpdate } from "./status-text.js";
|
|
13
13
|
const text = (description) => Type.String({ minLength: 1, pattern: "\\S", ...(description ? { description } : {}) });
|
|
14
14
|
const prompt = () => Type.Union([
|
|
15
15
|
text("Instructions for this node; it does not receive the parent conversation."),
|
|
@@ -25,7 +25,12 @@ const timeout = (scope) => Type.Optional(Type.Number({
|
|
|
25
25
|
maximum: 2_147_483_647,
|
|
26
26
|
description: scope === "graph"
|
|
27
27
|
? "Total wall-clock timeout in milliseconds, including queueing and pauses. Updates/resume do not reset it; reserve time for final integration. Omit for no time limit."
|
|
28
|
-
: "
|
|
28
|
+
: "Active time in milliseconds per node execution, excluding pauses; omit for no time limit. See onNodeTimeout for what happens when it runs out.",
|
|
29
|
+
}));
|
|
30
|
+
const resumeTimeout = Type.Optional(Type.Number({
|
|
31
|
+
exclusiveMinimum: 0,
|
|
32
|
+
maximum: 2_147_483_647,
|
|
33
|
+
description: "New node time budget in milliseconds for the suspended executions, counted from when each continues. Default: a node timeout pause gets the job's nodeTimeoutMs again; other pauses keep their remaining budget.",
|
|
29
34
|
}));
|
|
30
35
|
const toolBudget = (unit) => Type.Optional(Type.Integer({
|
|
31
36
|
minimum: 1,
|
|
@@ -35,8 +40,8 @@ const toolBudget = (unit) => Type.Optional(Type.Integer({
|
|
|
35
40
|
const commonNodeParameters = {
|
|
36
41
|
id: text("Unique node definition ID; edges refer to this exact ID. This is not an executionId."),
|
|
37
42
|
model: Type.Optional(text("Model override as provider/model-id; omit to use the parent model.")),
|
|
38
|
-
notifyOnCompletion: Type.Optional(Type.Boolean({ description: "
|
|
39
|
-
pauseAfter: Type.Optional(Type.Boolean({ description: "
|
|
43
|
+
notifyOnCompletion: Type.Optional(Type.Boolean({ description: "Send an execution completion reminder; default false. Does not pause scheduling." })),
|
|
44
|
+
pauseAfter: Type.Optional(Type.Boolean({ description: "Hold this execution's outgoing dependencies until braid_resume or an atomic braid_update with resume. Sends a pause reminder." })),
|
|
40
45
|
requireSuccess: Type.Optional(Type.Boolean({ description: "If true, failure aborts the entire job and cancels running siblings. Default false: unconditional successors can recover." })),
|
|
41
46
|
};
|
|
42
47
|
const workspace = Type.Optional(StringEnum(["read-only", "worktree"], {
|
|
@@ -85,6 +90,8 @@ const braidParameters = Type.Object({
|
|
|
85
90
|
maxConcurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum simultaneous node executions; default 4." })),
|
|
86
91
|
maxExecutions: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "Total execution limit across all iterations and updates; default 1000." })),
|
|
87
92
|
nodeTimeoutMs: timeout("node"), graphTimeoutMs: timeout("graph"), maxToolRounds: toolBudget("rounds"), maxToolCalls: toolBudget("calls"),
|
|
93
|
+
onNodeTimeout: Type.Optional(StringEnum(["pause", "fail"], { description: "What a node timeout does. pause (default): interrupt the current step and suspend the execution with its work, and send you a reminder to resume it with a new time budget or cancel it. fail: the execution fails with NODE_TIMEOUT." })),
|
|
94
|
+
maxModelRetries: Type.Optional(Type.Integer({ minimum: 0, maximum: 10, description: "Retries per model request for transient provider errors such as rate limits, overload, and network failures. The node continues where it was; completed tool calls are not repeated. Default 3; 0 disables." })),
|
|
88
95
|
}, { additionalProperties: false })),
|
|
89
96
|
}, { additionalProperties: false });
|
|
90
97
|
const BRAID_FILESYSTEM_GUIDANCE = "Every execute/decision activation gets a fresh worktree in Git, based on its predecessor execution checkpoint (roots use the initial job snapshot). Set workspace=read-only to disable writes. Multiple independent code snapshots require an explicit merge node. " +
|
|
@@ -92,20 +99,19 @@ const BRAID_FILESYSTEM_GUIDANCE = "Every execute/decision activation gets a fres
|
|
|
92
99
|
"Do not set workspace on merge/integrate nodes. Checkpoints remain recoverable after cleanup. Optional failed predecessors pass errors and partial work along unconditional edges; requireSuccess=true makes failure abort the job. " +
|
|
93
100
|
"Nodes have local read/ls and Git inspection; writable nodes have write/edit and Pi shell tools for dependencies, builds, and tests, while merge/integrate also have local Git integration tools. Search tools require local rg/fd. Outside Git all filesystem access is read-only; read-only nodes have no shell. Worktrees are not an OS sandbox: prompts constrain shell writes and shared resources. Checkpoints omit ignored new files. Parent extension/MCP tools and recursive Braid calls are not provided. Nodes should verify their changes; the parent reviews results and performs any remaining validation after integration.";
|
|
94
101
|
/** Lifecycle guidance shared by the system prompt and tool descriptions. */
|
|
95
|
-
export const BRAID_ASYNC_GUIDANCE = "
|
|
102
|
+
export const BRAID_ASYNC_GUIDANCE = "Braid is asynchronous: braid returns a jobId immediately and the job keeps running independently of the foreground response; ending your turn does not cancel it. " +
|
|
96
103
|
"Completion, failure, or cancellation sends a reminder: an idle agent is resumed automatically, and a running agent receives it at the next model step. " +
|
|
97
104
|
"After submitting, continue useful independent work. If nothing else can proceed until the result arrives, end your turn and let the reminder resume the task. " +
|
|
98
105
|
"Do not call sleep, run a shell sleep, use a timer, or enter a sleep/poll loop solely to wait for a Braid job; delays required by the user's actual task remain allowed. " +
|
|
99
106
|
"On the reminder, retrieve terminal outputs with braid_status before relying on them and continue the original task. Between reminders, use braid_status only for deliberate progress checks or diagnosis. " +
|
|
100
|
-
"Set notifyOnCompletion=true on selected nodes to receive intermediate success/failure reminders. Use braid_status({jobId, executionId}) to retrieve the exact execution; nodeId selects the latest instance. Use pauseAfter=true to hold outgoing scheduling. Definitions can always be changed with braid_update using expectedRevision; existing executions retain their captured inputs. Use resume in the same update to apply changes and release held executions atomically, or braid_resume for no graph changes."
|
|
101
|
-
|
|
102
|
-
"
|
|
107
|
+
"Set notifyOnCompletion=true on selected nodes to receive intermediate success/failure reminders. Use braid_status({jobId, executionId}) to retrieve the exact execution; nodeId selects the latest instance. Use pauseAfter=true to hold outgoing scheduling. Definitions can always be changed with braid_update using expectedRevision; existing executions retain their captured inputs. Use resume in the same update to apply changes and release held executions atomically, or braid_resume for no graph changes. " +
|
|
108
|
+
"Use braid_pause to stop running executions temporarily (each stops before its next model request and keeps its conversation and workspace), or the whole job; send them a message or adjust the graph, then resume. " +
|
|
109
|
+
"pauseAfter is captured when an execution starts; to review a running execution's result before downstream work starts, use braid_pause({jobId, executionIds, after: true}) instead, and braid_resume({jobId, executionIds, after: true}) removes that hold. " +
|
|
110
|
+
"The user can also pause and resume from /braid; you get a notice with your next turn. Do not resume work the user paused unless the user asks. " +
|
|
111
|
+
"When an execution exceeds its nodeTimeoutMs it is paused, not failed, and you get a reminder: resume it with braid_resume (optionally a new nodeTimeoutMs), or fail only that execution with braid_cancel and executionIds.";
|
|
103
112
|
/** Short reminder repeated in every submission receipt. */
|
|
104
113
|
export const BRAID_WAIT_REMINDER = "Braid runs asynchronously and notifies you when the job finishes. Continue independent work, or end your turn if you need the result before proceeding; the completion reminder will resume you. " +
|
|
105
114
|
"Do not sleep or poll in a loop to wait for this job. Retrieve its results with braid_status after the reminder.";
|
|
106
|
-
const BRAID_BLOCKING_GUIDANCE = "In blocking mode (enabled by --braid-blocking or /braid:blocking on), braid waits for the entire graph and workspace cleanup, then returns final status, outputs, and usage directly. " +
|
|
107
|
-
"Foreground cancellation cancels the submitted job and waits for cleanup. Completion reminders are suppressed unless the job is cancelled; cancellation reminders preserve its handle and access to partial results. " +
|
|
108
|
-
"Do not set pauseAfter=true in blocking mode: it requires a later resume call and is rejected. Use braid_status for focused reads, diagnosis, or retrieving an existing job after cancellation or a wait/result error.";
|
|
109
115
|
const BRAID_USAGE_GUIDANCE = [
|
|
110
116
|
"Braid is a proactive execution primitive, not only a user-requested command.",
|
|
111
117
|
"Selection rule: for a code review, bug investigation, design comparison, test-planning request, or change spanning multiple files, call braid FIRST when two or more concerns can be handled independently. Nodes can analyze the project and implement changes in isolated Git worktrees. Do this without waiting for the user to say Braid; do not read everything in the parent and then decide whether to delegate.",
|
|
@@ -136,7 +142,7 @@ async function boundedText(text, name) {
|
|
|
136
142
|
await writeFile(path, text, { mode: 0o600 });
|
|
137
143
|
return { text: `${preview.content}\n[Truncated. Full text: ${path}]`, path };
|
|
138
144
|
}
|
|
139
|
-
export function createBraidTools(jobs
|
|
145
|
+
export function createBraidTools(jobs) {
|
|
140
146
|
const braidTool = defineTool({
|
|
141
147
|
name: "braid",
|
|
142
148
|
label: "Braid",
|
|
@@ -161,7 +167,7 @@ export function createBraidTools(jobs, blocking = () => false) {
|
|
|
161
167
|
"Do not use braid for simple one-step answers or trivial direct edits. Let writable nodes run shell commands and tests for their work; use merge nodes to combine and validate snapshots before explicit integration.",
|
|
162
168
|
BRAID_FILESYSTEM_GUIDANCE,
|
|
163
169
|
"Decision nodes additionally receive decide. Nodes cannot call recursive Braid.",
|
|
164
|
-
"Tool and time budgets are unlimited by default. Set maxToolRounds, maxToolCalls, nodeTimeoutMs, or graphTimeoutMs in options to impose hard limits; nodes receive system reminders of their remaining budgets before each model call.",
|
|
170
|
+
"Tool and time budgets are unlimited by default. Set maxToolRounds, maxToolCalls, nodeTimeoutMs, or graphTimeoutMs in options to impose hard limits; nodes receive system reminders of their remaining budgets before each model call. By default a node timeout pauses the execution instead of failing it, and you get a reminder to resume or cancel it; set onNodeTimeout=fail to fail it instead.",
|
|
165
171
|
],
|
|
166
172
|
parameters: braidParameters,
|
|
167
173
|
renderCall(args, theme) {
|
|
@@ -181,9 +187,8 @@ export function createBraidTools(jobs, blocking = () => false) {
|
|
|
181
187
|
return statusTool.renderResult(result, options, theme, { ...ctx, args: { jobId: job.jobId } });
|
|
182
188
|
return new Text(theme.fg("accent", `Braid ${options.isPartial ? "job" : "background job"} ${job.handle ?? job.jobId} · ${job.status} · /braid to view${job.error ? `\n${job.error}` : ""}`), 0, 0);
|
|
183
189
|
},
|
|
184
|
-
async execute(_toolCallId, params, signal,
|
|
190
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
185
191
|
signal?.throwIfAborted();
|
|
186
|
-
const waitForCompletion = blocking();
|
|
187
192
|
let job;
|
|
188
193
|
try {
|
|
189
194
|
job = jobs.start({
|
|
@@ -192,42 +197,17 @@ export function createBraidTools(jobs, blocking = () => false) {
|
|
|
192
197
|
edges: params.edges,
|
|
193
198
|
...(params.loops !== undefined ? { loops: params.loops } : {}),
|
|
194
199
|
...(params.promptTemplates !== undefined ? { promptTemplates: params.promptTemplates } : {}),
|
|
195
|
-
},
|
|
200
|
+
},
|
|
201
|
+
// StringEnum is typed as string; the schema has already checked the value.
|
|
202
|
+
(params.options ?? {}), ctx);
|
|
196
203
|
}
|
|
197
204
|
catch (error) {
|
|
198
205
|
throw new Error(`Braid submission failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
199
206
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
};
|
|
205
|
-
const cancel = () => { jobs.cancel(job.jobId); };
|
|
206
|
-
signal?.addEventListener("abort", cancel, { once: true });
|
|
207
|
-
try {
|
|
208
|
-
try {
|
|
209
|
-
onUpdate?.({
|
|
210
|
-
content: [{ type: "text", text: formatReceipt(job, "Waiting for the graph and workspace cleanup. Humans can follow it live in /braid.") }],
|
|
211
|
-
details: job,
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
catch { /* Progress observers must not stop the foreground wait. */ }
|
|
215
|
-
await jobs.wait(job.jobId);
|
|
216
|
-
return await statusTool.execute(_toolCallId, { jobId: job.jobId }, signal, undefined, ctx);
|
|
217
|
-
}
|
|
218
|
-
catch (error) {
|
|
219
|
-
const current = jobs.get(job.jobId);
|
|
220
|
-
const usage = current.status === "running" || signal?.aborted ? undefined : jobs.claimUsage(job.jobId);
|
|
221
|
-
return {
|
|
222
|
-
content: [{ type: "text", text: `Braid job ${job.handle} was started and is ${current.status}. Could not wait for or retrieve its results: ${error instanceof Error ? error.message : String(error)}. Do not resubmit this graph: it may already have changed the checkout. Retrieve the existing job with braid_status(${JSON.stringify({ jobId: job.handle })}).` }],
|
|
223
|
-
details: current,
|
|
224
|
-
isError: true,
|
|
225
|
-
...(usage ? { usage } : {}),
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
finally {
|
|
229
|
-
signal?.removeEventListener("abort", cancel);
|
|
230
|
-
}
|
|
207
|
+
return {
|
|
208
|
+
content: [{ type: "text", text: formatReceipt(job, `${BRAID_WAIT_REMINDER} Humans can follow it live in /braid.`) }],
|
|
209
|
+
details: job,
|
|
210
|
+
};
|
|
231
211
|
},
|
|
232
212
|
});
|
|
233
213
|
const statusParameters = Type.Object({
|
|
@@ -308,13 +288,20 @@ export function createBraidTools(jobs, blocking = () => false) {
|
|
|
308
288
|
};
|
|
309
289
|
},
|
|
310
290
|
});
|
|
311
|
-
const cancelParameters = Type.Object({
|
|
291
|
+
const cancelParameters = Type.Object({
|
|
292
|
+
jobId: text("Exact session handle (e.g. job-1) or UUID returned by braid or braid_status."),
|
|
293
|
+
executionIds: Type.Optional(Type.Array(text(), { minItems: 1, uniqueItems: true, description: "Running or paused execution IDs from braid_status, not node IDs. Cancels only these executions; omit to cancel the whole job." })),
|
|
294
|
+
}, { additionalProperties: false });
|
|
312
295
|
const cancelTool = defineTool({
|
|
313
296
|
name: "braid_cancel",
|
|
314
297
|
label: "Cancel Braid",
|
|
315
|
-
description: "Cancel a background Braid job by jobId. Stopping the foreground response does not cancel background jobs.",
|
|
298
|
+
description: "Cancel a background Braid job by jobId. Stopping the foreground response does not cancel background jobs. With executionIds, fail only those running or paused executions and keep the job running: their successors follow the usual failure rules (unconditional edges receive the error; requireSuccess aborts the job). A node timeout pause fails with NODE_TIMEOUT, anything else with CANCELLED.",
|
|
316
299
|
parameters: cancelParameters,
|
|
317
300
|
async execute(_id, params) {
|
|
301
|
+
if (params.executionIds) {
|
|
302
|
+
const job = jobs.cancelExecutions(params.jobId, params.executionIds, "agent");
|
|
303
|
+
return { content: [{ type: "text", text: formatCancelExecutions(job, params.executionIds) }], details: job };
|
|
304
|
+
}
|
|
318
305
|
const cancelled = jobs.cancel(params.jobId);
|
|
319
306
|
return {
|
|
320
307
|
content: [
|
|
@@ -338,7 +325,8 @@ export function createBraidTools(jobs, blocking = () => false) {
|
|
|
338
325
|
removeEdges: Type.Optional(Type.Array(updateEdgeParameters, { description: "Exact edge identities to remove: copy from, to, and every present choice/feedback/executionId from the current graph edges in braid_status. Omitted optional fields do not act as wildcards." })),
|
|
339
326
|
promptTemplates: Type.Optional(Type.Record(Type.String(), text(), { description: `${templateDescription} Replaces the entire template map; omit to preserve it. Include every template still referenced by nodes.` })),
|
|
340
327
|
loops: Type.Optional(Type.Array(loopParameters, { description: "Replaces all loop definitions; omit to preserve them, or use [] to remove all (also remove their feedback edges)." })),
|
|
341
|
-
resume: Type.Optional(Type.Array(text(), { uniqueItems: true, description: "Paused execution IDs from braid_status, not node IDs. Releases them atomically with the graph edit." })),
|
|
328
|
+
resume: Type.Optional(Type.Array(text(), { uniqueItems: true, description: "Paused or suspended execution IDs from braid_status, not node IDs. Releases them atomically with the graph edit." })),
|
|
329
|
+
resumeOptions: Type.Optional(Type.Object({ nodeTimeoutMs: resumeTimeout }, { additionalProperties: false, description: "Requires resume. Applies to the suspended executions in resume." })),
|
|
342
330
|
}, { additionalProperties: false });
|
|
343
331
|
const updateTool = defineTool({
|
|
344
332
|
name: "braid_update", label: "Update Braid",
|
|
@@ -358,52 +346,83 @@ export function createBraidTools(jobs, blocking = () => false) {
|
|
|
358
346
|
});
|
|
359
347
|
const resumeParameters = Type.Object({
|
|
360
348
|
jobId: text("Exact session handle (e.g. job-1) or UUID."),
|
|
361
|
-
expectedRevision: Type.Integer({ minimum: 0, description: "Current graph revision from braid_status." }),
|
|
362
|
-
executionIds: Type.Array(text(), { minItems: 1, uniqueItems: true, description: "Paused execution IDs from braid_status, not node IDs.
|
|
349
|
+
expectedRevision: Type.Optional(Type.Integer({ minimum: 0, description: "Current graph revision from braid_status. Required with executionIds." })),
|
|
350
|
+
executionIds: Type.Optional(Type.Array(text(), { minItems: 1, uniqueItems: true, description: "Paused (pauseAfter) or suspended execution IDs from braid_status, not node IDs. Omit to resume a paused job." })),
|
|
351
|
+
after: Type.Optional(Type.Boolean({ description: "true: remove the hold that braid_pause after=true or pauseAfter put on these still-running executions, so their results go downstream when they finish. Requires executionIds; expectedRevision is not needed. A result that is already held is released without after." })),
|
|
352
|
+
nodeTimeoutMs: resumeTimeout,
|
|
363
353
|
}, { additionalProperties: false });
|
|
364
354
|
const resumeTool = defineTool({
|
|
365
355
|
name: "braid_resume", label: "Resume Braid",
|
|
366
|
-
description: "Release
|
|
356
|
+
description: "Release paused (pauseAfter) executions, or resume suspended ones in the same conversation, by execution ID with expectedRevision from braid_status. Omit executionIds to resume a job paused with braid_pause; executions you paused separately stay suspended. Independent branches keep running during a pause; this does not reset the graph deadline or execution limits. An execution paused by its node timeout gets the job's nodeTimeoutMs again, or nodeTimeoutMs if given; resuming the job does not resume it. With after=true it instead removes the hold on still-running executions. Do not resume work the user paused or held unless the user asks.",
|
|
367
357
|
parameters: resumeParameters,
|
|
368
358
|
async execute(_id, params) {
|
|
369
|
-
|
|
359
|
+
if (params.after) {
|
|
360
|
+
if (params.nodeTimeoutMs !== undefined)
|
|
361
|
+
throw new Error("nodeTimeoutMs cannot be combined with after");
|
|
362
|
+
const job = hold(params.jobId, params.executionIds, false);
|
|
363
|
+
return { content: [{ type: "text", text: formatHold(job, params.executionIds, false) }], details: job };
|
|
364
|
+
}
|
|
365
|
+
if (!params.executionIds) {
|
|
366
|
+
if (params.nodeTimeoutMs !== undefined)
|
|
367
|
+
throw new Error("nodeTimeoutMs requires executionIds");
|
|
368
|
+
return { content: [{ type: "text", text: formatResume(jobs.resumeJob(params.jobId)) }], details: jobs.get(params.jobId) };
|
|
369
|
+
}
|
|
370
|
+
if (params.expectedRevision === undefined)
|
|
371
|
+
throw new Error("expectedRevision is required with executionIds; read it from braid_status");
|
|
372
|
+
const job = jobs.resume(params.jobId, params.executionIds, params.expectedRevision, "agent", params.nodeTimeoutMs === undefined ? undefined : { nodeTimeoutMs: params.nodeTimeoutMs });
|
|
370
373
|
return { content: [{ type: "text", text: formatResume(job) }], details: job };
|
|
371
374
|
},
|
|
372
375
|
});
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
return;
|
|
387
|
-
warnedBlocking = true;
|
|
388
|
-
// Print and JSON runs have no UI; stderr keeps JSON stdout parseable.
|
|
389
|
-
if (ctx.hasUI)
|
|
390
|
-
ctx.ui.notify(BRAID_BLOCKING_DEPRECATION, "warning");
|
|
391
|
-
else
|
|
392
|
-
console.error(`Braid: ${BRAID_BLOCKING_DEPRECATION}`);
|
|
376
|
+
/** Holds and their removal apply to running executions only; explain the usual mistake. */
|
|
377
|
+
const hold = (jobId, executionIds, value) => {
|
|
378
|
+
if (!executionIds)
|
|
379
|
+
throw new Error("after=true requires executionIds of running executions; read them from braid_status");
|
|
380
|
+
try {
|
|
381
|
+
return jobs.holdAfter(jobId, executionIds, value, "agent");
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
385
|
+
if (!/running execution IDs/.test(message))
|
|
386
|
+
throw error;
|
|
387
|
+
throw new Error(`${message}. after=true applies only while an execution is still running; a finished result that is held is released with braid_resume({jobId, expectedRevision, executionIds}) without after.`);
|
|
388
|
+
}
|
|
393
389
|
};
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
390
|
+
const pauseParameters = Type.Object({
|
|
391
|
+
jobId: text("Exact session handle (e.g. job-1) or UUID."),
|
|
392
|
+
executionIds: Type.Optional(Type.Array(text(), { minItems: 1, uniqueItems: true, description: "Running execution IDs from braid_status, not node IDs. Omit to pause the whole job." })),
|
|
393
|
+
after: Type.Optional(Type.Boolean({ description: "true: do not suspend; let these running executions finish, then hold their results like pauseAfter, so downstream work waits for braid_resume. Use it to review a result that is still being produced. Requires executionIds; applies to these executions only, not their node definitions." })),
|
|
394
|
+
}, { additionalProperties: false });
|
|
395
|
+
const pauseTool = defineTool({
|
|
396
|
+
name: "braid_pause", label: "Pause Braid",
|
|
397
|
+
description: "Temporarily stop running executions without losing their work: each finishes its running tool, then stops before its next model request, keeping its conversation, workspace, and tool budgets, and releasing its concurrency slot. Omit executionIds to pause the whole job: no new executions start and running ones pause. Use it to inspect a node, correct it with a message, or stop spending while you adjust the graph. Resume with braid_resume. The node timeout stops while suspended; the graph timeout keeps running, so resume or cancel paused work. With after=true it does not suspend: each execution finishes, then its result is held until braid_resume.",
|
|
398
|
+
parameters: pauseParameters,
|
|
399
|
+
async execute(_id, params) {
|
|
400
|
+
if (params.after) {
|
|
401
|
+
const job = hold(params.jobId, params.executionIds, true);
|
|
402
|
+
return { content: [{ type: "text", text: formatHold(job, params.executionIds, true) }], details: job };
|
|
403
|
+
}
|
|
404
|
+
const job = jobs.pause(params.jobId, params.executionIds, "agent");
|
|
405
|
+
return { content: [{ type: "text", text: formatPause(job, params.executionIds) }], details: job };
|
|
405
406
|
},
|
|
406
407
|
});
|
|
408
|
+
return { braidTool, statusTool, cancelTool, updateTool, resumeTool, pauseTool };
|
|
409
|
+
}
|
|
410
|
+
/** One sentence for a pause, resume, or hold change the parent did not make. */
|
|
411
|
+
export function formatControlNotice(notice) {
|
|
412
|
+
const who = notice.source === "user" ? "The user" : `A ${notice.source} control`;
|
|
413
|
+
const target = notice.executions
|
|
414
|
+
? notice.executions.map(execution => `node ${JSON.stringify(execution.nodeId)} execution ${execution.executionId}`).join(", ")
|
|
415
|
+
: "the whole job";
|
|
416
|
+
const lookup = JSON.stringify({ jobId: notice.handle, ...(notice.executions?.length === 1 ? { executionId: notice.executions[0].executionId } : {}) });
|
|
417
|
+
switch (notice.action) {
|
|
418
|
+
case "paused": return `${who} paused ${target} of Braid job ${notice.handle}. Paused work keeps its conversation and workspace. Do not resume it unless the user asks; check the current state with braid_status(${lookup}).`;
|
|
419
|
+
case "resumed": return `${who} resumed ${target} of Braid job ${notice.handle}; it continues where it stopped.`;
|
|
420
|
+
case "held": return `${who} set ${target} of Braid job ${notice.handle} to hold its result once it finishes; downstream work waits for a resume. Do not release it unless the user asks; check the current state with braid_status(${lookup}).`;
|
|
421
|
+
case "unheld": return `${who} removed the hold on ${target} of Braid job ${notice.handle}; its result goes downstream when it finishes.`;
|
|
422
|
+
case "cancelled": return `${who} cancelled ${target} of Braid job ${notice.handle}. It failed; its successors follow the usual failure rules. Check the current state with braid_status(${lookup}).`;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
export default function braidExtension(pi) {
|
|
407
426
|
const pending = new Map();
|
|
408
427
|
const remind = (message) => {
|
|
409
428
|
try {
|
|
@@ -415,6 +434,23 @@ export default function braidExtension(pi) {
|
|
|
415
434
|
// Keep failed deliveries pending for the next settled retry.
|
|
416
435
|
}
|
|
417
436
|
};
|
|
437
|
+
// Pauses, resumes, and holds made by someone else wait for the parent's next turn instead of
|
|
438
|
+
// waking it: a woken parent could resume work the user deliberately paused.
|
|
439
|
+
const notices = new Map();
|
|
440
|
+
const noteControl = (notice) => {
|
|
441
|
+
for (const execution of notice.executions ?? [undefined]) {
|
|
442
|
+
// The latest change per execution or job wins; holds and pauses are tracked separately.
|
|
443
|
+
const kind = notice.action === "held" || notice.action === "unheld" ? "hold" : "pause";
|
|
444
|
+
notices.set(JSON.stringify([notice.jobId, execution?.executionId ?? "job", kind]), { ...notice, ...(execution ? { executions: [execution] } : {}) });
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
pi.on("before_agent_start", () => {
|
|
448
|
+
if (!notices.size)
|
|
449
|
+
return;
|
|
450
|
+
const lines = [...notices.values()].map(formatControlNotice);
|
|
451
|
+
notices.clear();
|
|
452
|
+
return { message: { customType: "braid-control", display: true, content: `[system-reminder] ${lines.join(" ")} [/system-reminder]`, details: { notices: lines } } };
|
|
453
|
+
});
|
|
418
454
|
const jobs = new BraidJobs((job) => {
|
|
419
455
|
const { jobId, handle, status } = job;
|
|
420
456
|
const reminderId = JSON.stringify([jobId, "job"]);
|
|
@@ -438,13 +474,23 @@ export default function braidExtension(pi) {
|
|
|
438
474
|
};
|
|
439
475
|
pending.set(reminderId, message);
|
|
440
476
|
remind(message);
|
|
477
|
+
}, noteControl, (pause) => {
|
|
478
|
+
const reminderId = JSON.stringify([pause.jobId, "timeout", pause.executionId, pause.eventSequence]);
|
|
479
|
+
const message = {
|
|
480
|
+
customType: "braid-node-timeout",
|
|
481
|
+
display: true,
|
|
482
|
+
content: `[system-reminder] ${formatTimeoutPause(pause)} [/system-reminder]`,
|
|
483
|
+
details: { ...pause, reminderId },
|
|
484
|
+
};
|
|
485
|
+
pending.set(reminderId, message);
|
|
486
|
+
remind(message);
|
|
441
487
|
});
|
|
442
488
|
// Foreground cancellation can discard queued steering. Retry only reminders
|
|
443
489
|
// that never entered context, once Pi has settled and emptied its queues.
|
|
444
490
|
pi.on("message_start", (event) => {
|
|
445
491
|
const message = event.message;
|
|
446
492
|
if (message.role === "custom" &&
|
|
447
|
-
(message.customType === "braid-completed" || message.customType === "braid-node-completed")) {
|
|
493
|
+
(message.customType === "braid-completed" || message.customType === "braid-node-completed" || message.customType === "braid-node-timeout")) {
|
|
448
494
|
const details = message.details;
|
|
449
495
|
if (details?.reminderId)
|
|
450
496
|
pending.delete(details.reminderId);
|
|
@@ -456,7 +502,7 @@ export default function braidExtension(pi) {
|
|
|
456
502
|
remind(message);
|
|
457
503
|
}
|
|
458
504
|
});
|
|
459
|
-
const { braidTool, statusTool, cancelTool, updateTool, resumeTool } = createBraidTools(jobs
|
|
505
|
+
const { braidTool, statusTool, cancelTool, updateTool, resumeTool, pauseTool } = createBraidTools(jobs);
|
|
460
506
|
registerDrain(pi, jobs, () => pending.size > 0);
|
|
461
507
|
registerBraidCommand(pi, jobs);
|
|
462
508
|
registerReviewCommand(pi, jobs);
|
|
@@ -466,16 +512,16 @@ export default function braidExtension(pi) {
|
|
|
466
512
|
pi.registerTool(cancelTool);
|
|
467
513
|
pi.registerTool(updateTool);
|
|
468
514
|
pi.registerTool(resumeTool);
|
|
515
|
+
pi.registerTool(pauseTool);
|
|
469
516
|
pi.on("session_shutdown", () => {
|
|
470
517
|
pending.clear();
|
|
518
|
+
notices.clear();
|
|
471
519
|
jobs.dispose();
|
|
472
520
|
disposeWidget();
|
|
473
521
|
});
|
|
474
522
|
pi.on("before_agent_start", (event, ctx) => {
|
|
475
|
-
if (pi.getFlag("braid-blocking") === true)
|
|
476
|
-
warnBlocking(ctx);
|
|
477
523
|
return {
|
|
478
|
-
systemPrompt: `${event.systemPrompt}\n\n## Braid execution policy\n${BRAID_USAGE_GUIDANCE}\n${
|
|
524
|
+
systemPrompt: `${event.systemPrompt}\n\n## Braid execution policy\n${BRAID_USAGE_GUIDANCE}\n${BRAID_ASYNC_GUIDANCE}${isSingleShot(ctx) ? `\n${BRAID_DRAIN_GUIDANCE}\n${BRAID_FINISH_GUIDANCE}` : ""}\n\nFor the current user request, make this delegation choice before using read, grep, find, edit, write, or bash. Completion reminders refer to existing jobs: retrieve their results instead of submitting the same graph again.`,
|
|
479
525
|
};
|
|
480
526
|
});
|
|
481
527
|
}
|