@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/jobs.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { setImmediate as nextTurn } from "node:timers/promises";
|
|
2
|
-
import { ExecutionActivityTracker, startBraid, validateGraph, } from "@chrok/braid";
|
|
2
|
+
import { ExecutionActivityTracker, formatActivityStatus, startBraid, validateGraph, } from "@chrok/braid";
|
|
3
3
|
import { applyEvent, applyProgress, createLiveState, } from "./display.js";
|
|
4
4
|
import { createPiRunner, sumPiUsage } from "./runner.js";
|
|
5
5
|
import { PiTranscripts } from "./transcript.js";
|
|
6
6
|
import { PiHumanInputs } from "./human-input.js";
|
|
7
7
|
import { BraidAssistant } from "./assistant.js";
|
|
8
|
+
function timedOut(state) {
|
|
9
|
+
const ids = (state?.suspendedExecutionIds ?? []).filter(id => state.executions[id]?.suspension?.source === "node_timeout");
|
|
10
|
+
return ids.length ? { timedOutExecutionIds: ids } : {};
|
|
11
|
+
}
|
|
8
12
|
/** Jobs belong to one extension/session lifetime, independently of foreground turns. */
|
|
9
13
|
export class BraidJobs {
|
|
10
14
|
onFinished;
|
|
11
15
|
onNodeFinished;
|
|
16
|
+
onControl;
|
|
17
|
+
onTimeoutPause;
|
|
12
18
|
jobs = new Map();
|
|
13
19
|
assistants = new Map();
|
|
14
20
|
cwd = process.cwd();
|
|
@@ -16,9 +22,24 @@ export class BraidJobs {
|
|
|
16
22
|
nextHandle = 1;
|
|
17
23
|
listeners = new Set();
|
|
18
24
|
disposed = false;
|
|
19
|
-
constructor(onFinished = () => { }, onNodeFinished = () => { }) {
|
|
25
|
+
constructor(onFinished = () => { }, onNodeFinished = () => { }, onControl = () => { }, onTimeoutPause = () => { }) {
|
|
20
26
|
this.onFinished = onFinished;
|
|
21
27
|
this.onNodeFinished = onNodeFinished;
|
|
28
|
+
this.onControl = onControl;
|
|
29
|
+
this.onTimeoutPause = onTimeoutPause;
|
|
30
|
+
}
|
|
31
|
+
/** The parent made the change itself; anyone else's change becomes a notice for it. */
|
|
32
|
+
notify(job, action, source, executionIds) {
|
|
33
|
+
if (source === "agent" || this.disposed)
|
|
34
|
+
return;
|
|
35
|
+
const state = job.run?.snapshot();
|
|
36
|
+
try {
|
|
37
|
+
this.onControl({
|
|
38
|
+
jobId: job.jobId, handle: job.handle, action, source,
|
|
39
|
+
...(executionIds ? { executions: executionIds.map(executionId => ({ executionId, nodeId: state?.executions[executionId]?.id ?? "?" })) } : {}),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch { /* Notices are advisory. */ }
|
|
22
43
|
}
|
|
23
44
|
subscribe(listener) {
|
|
24
45
|
this.listeners.add(listener);
|
|
@@ -36,12 +57,10 @@ export class BraidJobs {
|
|
|
36
57
|
}
|
|
37
58
|
}
|
|
38
59
|
}
|
|
39
|
-
start(input, options, ctx
|
|
60
|
+
start(input, options, ctx) {
|
|
40
61
|
if (this.disposed)
|
|
41
62
|
throw new Error("Braid session has closed");
|
|
42
63
|
validateGraph(input);
|
|
43
|
-
if (blocking && input.nodes.some(node => node.pauseAfter))
|
|
44
|
-
throw new Error("Blocking Braid jobs cannot use pauseAfter; remove it or disable blocking with /braid:blocking off.");
|
|
45
64
|
const snapshot = structuredClone(input);
|
|
46
65
|
const settings = { ...options };
|
|
47
66
|
const model = ctx.model
|
|
@@ -51,7 +70,6 @@ export class BraidJobs {
|
|
|
51
70
|
const cwd = ctx.cwd;
|
|
52
71
|
this.cwd = cwd;
|
|
53
72
|
const job = {
|
|
54
|
-
blocking,
|
|
55
73
|
handle: `job-${this.nextHandle++}`,
|
|
56
74
|
jobId: crypto.randomUUID(),
|
|
57
75
|
goal: snapshot.goal,
|
|
@@ -62,6 +80,8 @@ export class BraidJobs {
|
|
|
62
80
|
done: Promise.resolve(),
|
|
63
81
|
usageClaimed: false,
|
|
64
82
|
runningExecutions: new Map(),
|
|
83
|
+
suspendedExecutions: new Map(),
|
|
84
|
+
timeoutActivity: new Map(),
|
|
65
85
|
activity: new ExecutionActivityTracker({ onChange: () => this.changed() }),
|
|
66
86
|
transcripts: new PiTranscripts(() => this.changed()),
|
|
67
87
|
humanInputs: new PiHumanInputs(() => this.changed()),
|
|
@@ -92,6 +112,7 @@ export class BraidJobs {
|
|
|
92
112
|
humanInputs: job.humanInputs,
|
|
93
113
|
maxToolRounds: options.maxToolRounds ?? Infinity,
|
|
94
114
|
maxToolCalls: options.maxToolCalls ?? Infinity,
|
|
115
|
+
...(options.maxModelRetries === undefined ? {} : { maxModelRetries: options.maxModelRetries }),
|
|
95
116
|
onUsage: (usage) => {
|
|
96
117
|
if (job.status === "running" && !job.controller.signal.aborted)
|
|
97
118
|
reports.push(usage);
|
|
@@ -103,7 +124,7 @@ export class BraidJobs {
|
|
|
103
124
|
this.changed();
|
|
104
125
|
},
|
|
105
126
|
});
|
|
106
|
-
return async (request) => {
|
|
127
|
+
return Object.assign(async (request) => {
|
|
107
128
|
try {
|
|
108
129
|
await nextTurn();
|
|
109
130
|
return await invoke(request);
|
|
@@ -116,9 +137,15 @@ export class BraidJobs {
|
|
|
116
137
|
}
|
|
117
138
|
job.transcripts.finish(request.execution.executionId, request.signal.aborted ? "Execution was stopped before this step finished" : undefined);
|
|
118
139
|
}
|
|
119
|
-
};
|
|
140
|
+
}, { pausable: invoke.pausable === true, interruptible: invoke.interruptible === true });
|
|
120
141
|
})(),
|
|
121
142
|
onEvent: (event) => {
|
|
143
|
+
// Capture what the worker was doing before the interruption changes it.
|
|
144
|
+
if (event.type === "execution_suspending" && event.source === "node_timeout" && event.executionId) {
|
|
145
|
+
const activity = job.activity.get(event.executionId);
|
|
146
|
+
if (activity)
|
|
147
|
+
job.timeoutActivity.set(event.executionId, formatActivityStatus(activity, event.timestamp));
|
|
148
|
+
}
|
|
122
149
|
job.activity.observe(event);
|
|
123
150
|
if (event.type === "workspace_updated") {
|
|
124
151
|
job.workspaces ??= {};
|
|
@@ -127,14 +154,25 @@ export class BraidJobs {
|
|
|
127
154
|
});
|
|
128
155
|
}
|
|
129
156
|
// Definition edits can remove nodes whose executions are still running.
|
|
157
|
+
// A suspended execution is not running: drain can then see a stalled job.
|
|
130
158
|
if (event.type === "node_started" && event.executionId) {
|
|
131
159
|
job.runningExecutions.set(event.executionId, event.nodeId);
|
|
132
160
|
}
|
|
161
|
+
else if (event.type === "execution_suspended" && event.executionId) {
|
|
162
|
+
job.runningExecutions.delete(event.executionId);
|
|
163
|
+
job.suspendedExecutions.set(event.executionId, event.nodeId);
|
|
164
|
+
}
|
|
165
|
+
else if (event.type === "execution_resumed" && event.executionId && job.suspendedExecutions.has(event.executionId)) {
|
|
166
|
+
job.runningExecutions.set(event.executionId, event.nodeId);
|
|
167
|
+
job.suspendedExecutions.delete(event.executionId);
|
|
168
|
+
}
|
|
133
169
|
else if ((event.type === "node_completed" || event.type === "node_failed") && event.executionId) {
|
|
134
170
|
job.runningExecutions.delete(event.executionId);
|
|
171
|
+
job.suspendedExecutions.delete(event.executionId);
|
|
135
172
|
}
|
|
136
173
|
else if (event.type === "graph_completed" || event.type === "graph_failed") {
|
|
137
174
|
job.runningExecutions.clear();
|
|
175
|
+
job.suspendedExecutions.clear();
|
|
138
176
|
}
|
|
139
177
|
applyEvent(job.live, event);
|
|
140
178
|
if ("nodeId" in event && Object.hasOwn(job.live.nodes, event.nodeId)) {
|
|
@@ -147,13 +185,31 @@ export class BraidJobs {
|
|
|
147
185
|
});
|
|
148
186
|
}
|
|
149
187
|
this.changed();
|
|
188
|
+
if (event.type === "execution_suspended" && event.source === "node_timeout" && event.executionId) {
|
|
189
|
+
const instance = job.run?.snapshot().executions[event.executionId];
|
|
190
|
+
const timeout = instance?.suspension?.timeout;
|
|
191
|
+
const activity = job.timeoutActivity.get(event.executionId);
|
|
192
|
+
job.timeoutActivity.delete(event.executionId);
|
|
193
|
+
// A timeout pause always wakes the parent, which must resume or cancel it.
|
|
194
|
+
if (!this.disposed && instance && timeout) {
|
|
195
|
+
try {
|
|
196
|
+
void Promise.resolve(this.onTimeoutPause({
|
|
197
|
+
jobId: job.jobId, handle: job.handle, nodeId: event.nodeId, executionId: event.executionId,
|
|
198
|
+
...(instance.iteration ? { iteration: instance.iteration } : {}),
|
|
199
|
+
nodeTimeoutMs: timeout.nodeTimeoutMs, activeMs: timeout.activeMs,
|
|
200
|
+
...(activity ? { activity } : {}), eventSequence: event.sequence,
|
|
201
|
+
})).catch(() => { });
|
|
202
|
+
}
|
|
203
|
+
catch { /* Notification failures must not affect scheduling. */ }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
150
206
|
const terminal = event.type === "execution_paused" || event.type === "node_completed" || event.type === "node_failed";
|
|
151
207
|
const state = terminal ? job.run?.snapshot() : undefined;
|
|
152
208
|
const instance = event.executionId ? state?.executions[event.executionId] : undefined;
|
|
153
209
|
const shouldNotify = event.type === "execution_paused" ||
|
|
154
210
|
((event.type === "node_completed" || event.type === "node_failed") && instance?.node.notifyOnCompletion &&
|
|
155
|
-
(!instance.
|
|
156
|
-
if (!this.disposed &&
|
|
211
|
+
(!instance.holdAfter || state?.error));
|
|
212
|
+
if (!this.disposed && shouldNotify && "nodeId" in event && instance) {
|
|
157
213
|
try {
|
|
158
214
|
void Promise.resolve(this.onNodeFinished({
|
|
159
215
|
jobId: job.jobId,
|
|
@@ -188,12 +244,13 @@ export class BraidJobs {
|
|
|
188
244
|
job.live.status = "failed";
|
|
189
245
|
job.live.pausedExecutionIds = [];
|
|
190
246
|
job.runningExecutions.clear();
|
|
247
|
+
job.suspendedExecutions.clear();
|
|
191
248
|
job.error = error instanceof Error ? error.message : String(error);
|
|
192
249
|
if (reports.length)
|
|
193
250
|
job.usage = sumPiUsage(reports);
|
|
194
251
|
}
|
|
195
252
|
this.changed();
|
|
196
|
-
if (!this.disposed
|
|
253
|
+
if (!this.disposed) {
|
|
197
254
|
try {
|
|
198
255
|
this.onFinished(this.get(job.jobId));
|
|
199
256
|
}
|
|
@@ -213,7 +270,7 @@ export class BraidJobs {
|
|
|
213
270
|
const job = this.lookup(jobId);
|
|
214
271
|
if (!job)
|
|
215
272
|
return undefined;
|
|
216
|
-
const {
|
|
273
|
+
const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, suspendedExecutions: _suspendedExecutions, activity: _activity, transcripts: _transcripts, humanInputs: _humanInputs, ...snapshot } = job;
|
|
217
274
|
const copy = structuredClone(snapshot);
|
|
218
275
|
if (job.run)
|
|
219
276
|
copy.execution = job.run.snapshot();
|
|
@@ -292,13 +349,60 @@ export class BraidJobs {
|
|
|
292
349
|
this.changed();
|
|
293
350
|
return this.get(jobId);
|
|
294
351
|
}
|
|
295
|
-
resume
|
|
352
|
+
/** Release held executions or resume paused ones. */
|
|
353
|
+
resume(jobId, executionIds, expectedRevision, source = "agent", options) {
|
|
296
354
|
const job = this.lookup(jobId);
|
|
297
355
|
if (!job)
|
|
298
356
|
throw this.unknownJob(jobId);
|
|
299
357
|
if (!job.run)
|
|
300
358
|
throw new Error("Job is not accepting updates");
|
|
301
|
-
job.run.resume(executionIds, expectedRevision);
|
|
359
|
+
job.run.resume(executionIds, expectedRevision, options);
|
|
360
|
+
this.notify(job, "resumed", source, executionIds);
|
|
361
|
+
this.changed();
|
|
362
|
+
return this.get(jobId);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Pause running executions at their next model request, or the whole job
|
|
366
|
+
* when executionIds is omitted. source records who asked, e.g. "agent" or "user".
|
|
367
|
+
*/
|
|
368
|
+
pause(jobId, executionIds, source) {
|
|
369
|
+
const job = this.lookup(jobId);
|
|
370
|
+
if (!job)
|
|
371
|
+
throw this.unknownJob(jobId);
|
|
372
|
+
if (!job.run || job.status !== "running")
|
|
373
|
+
throw new Error("Job is not accepting updates");
|
|
374
|
+
if (executionIds)
|
|
375
|
+
job.run.pause(executionIds, { source });
|
|
376
|
+
else
|
|
377
|
+
job.run.pauseRun({ source });
|
|
378
|
+
this.notify(job, "paused", source, executionIds);
|
|
379
|
+
this.changed();
|
|
380
|
+
return this.get(jobId);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Hold running executions' results once they finish (hold = true), or remove
|
|
384
|
+
* that hold, without suspending them. source records who asked.
|
|
385
|
+
*/
|
|
386
|
+
holdAfter(jobId, executionIds, hold, source) {
|
|
387
|
+
const job = this.lookup(jobId);
|
|
388
|
+
if (!job)
|
|
389
|
+
throw this.unknownJob(jobId);
|
|
390
|
+
if (!job.run || job.status !== "running")
|
|
391
|
+
throw new Error("Job is not accepting updates");
|
|
392
|
+
job.run.holdAfter(executionIds, hold, { source });
|
|
393
|
+
this.notify(job, hold ? "held" : "unheld", source, executionIds);
|
|
394
|
+
this.changed();
|
|
395
|
+
return this.get(jobId);
|
|
396
|
+
}
|
|
397
|
+
/** Admit work again and resume the executions the job pause paused. */
|
|
398
|
+
resumeJob(jobId, source = "agent") {
|
|
399
|
+
const job = this.lookup(jobId);
|
|
400
|
+
if (!job)
|
|
401
|
+
throw this.unknownJob(jobId);
|
|
402
|
+
if (!job.run || job.status !== "running")
|
|
403
|
+
throw new Error("Job is not accepting updates");
|
|
404
|
+
job.run.resumeRun();
|
|
405
|
+
this.notify(job, "resumed", source);
|
|
302
406
|
this.changed();
|
|
303
407
|
return this.get(jobId);
|
|
304
408
|
}
|
|
@@ -315,7 +419,8 @@ export class BraidJobs {
|
|
|
315
419
|
}
|
|
316
420
|
/**
|
|
317
421
|
* Jobs that have not reached a terminal status. A job is stalled when only
|
|
318
|
-
* paused
|
|
422
|
+
* held, suspended, or job-paused work remains, so it cannot progress without
|
|
423
|
+
* resume or cancel.
|
|
319
424
|
*/
|
|
320
425
|
outstanding() {
|
|
321
426
|
return [...this.jobs.values()].filter(job => job.status === "running").map(job => {
|
|
@@ -327,6 +432,9 @@ export class BraidJobs {
|
|
|
327
432
|
stalled: state?.status === "waiting",
|
|
328
433
|
...(state ? { revision: state.revision } : {}),
|
|
329
434
|
pausedExecutionIds: state?.pausedExecutionIds ?? [],
|
|
435
|
+
suspendedExecutionIds: state?.suspendedExecutionIds ?? [],
|
|
436
|
+
...timedOut(state),
|
|
437
|
+
...(state?.pause ? { paused: true } : {}),
|
|
330
438
|
};
|
|
331
439
|
});
|
|
332
440
|
}
|
|
@@ -341,7 +449,7 @@ export class BraidJobs {
|
|
|
341
449
|
done: nodes.filter(node => node.status === "completed" || node.status === "failed" || node.status === "skipped").length,
|
|
342
450
|
failed: nodes.filter(node => node.status === "failed").length,
|
|
343
451
|
running: [...new Set(job.runningExecutions.values())],
|
|
344
|
-
paused: job.live.pausedExecutionIds?.length ?? 0,
|
|
452
|
+
paused: (job.live.pausedExecutionIds?.length ?? 0) + job.suspendedExecutions.size,
|
|
345
453
|
};
|
|
346
454
|
}
|
|
347
455
|
cancel(jobId) {
|
|
@@ -353,6 +461,18 @@ export class BraidJobs {
|
|
|
353
461
|
job.controller.abort();
|
|
354
462
|
return true;
|
|
355
463
|
}
|
|
464
|
+
/** Fail running or paused executions only; their successors follow the usual failure semantics. */
|
|
465
|
+
cancelExecutions(jobId, executionIds, source) {
|
|
466
|
+
const job = this.lookup(jobId);
|
|
467
|
+
if (!job)
|
|
468
|
+
throw this.unknownJob(jobId);
|
|
469
|
+
if (!job.run || job.status !== "running")
|
|
470
|
+
throw new Error("Job is not accepting updates");
|
|
471
|
+
job.run.cancelExecutions(executionIds, { source });
|
|
472
|
+
this.notify(job, "cancelled", source, executionIds);
|
|
473
|
+
this.changed();
|
|
474
|
+
return this.get(jobId);
|
|
475
|
+
}
|
|
356
476
|
/** Pi accounts usage only once, on the first retrieval of a terminal result. */
|
|
357
477
|
claimUsage(jobId) {
|
|
358
478
|
const job = this.lookup(jobId);
|
package/dist/runner.js
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
-
import { StringEnum, Type, validateToolCall, } from "@earendil-works/pi-ai";
|
|
3
|
-
import { formatBudgetReminder, gitToolDefinition, finishMergeToolDefinition, mergeInstructions, parseGitToolArguments, parseFinishMergeArguments, } from "@chrok/braid";
|
|
2
|
+
import { StringEnum, Type, isRetryableAssistantError, retryDelayMs, validateToolCall, } from "@earendil-works/pi-ai";
|
|
3
|
+
import { formatBudgetReminder, interruptedToolResult, isStepInterrupted, notRunToolResult, gitToolDefinition, finishMergeToolDefinition, mergeInstructions, parseGitToolArguments, parseFinishMergeArguments, } from "@chrok/braid";
|
|
4
4
|
import { createWorktreeWriteTools } from "./write-tools.js";
|
|
5
5
|
import { createAvailableReadTools } from "./read-tools.js";
|
|
6
6
|
import { createWorkspaceShellTools } from "./shell-tools.js";
|
|
7
7
|
import { formatHumanInput } from "./human-input.js";
|
|
8
|
+
const DEFAULT_MODEL_RETRIES = 3;
|
|
9
|
+
/** Caps both Braid's backoff and a provider's Retry-After; longer requested waits fail immediately. */
|
|
10
|
+
const MAX_MODEL_RETRY_DELAY_MS = 60_000;
|
|
11
|
+
function abortableDelay(ms, signal) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
if (signal.aborted)
|
|
14
|
+
return reject(signal.reason);
|
|
15
|
+
const onAbort = () => {
|
|
16
|
+
clearTimeout(timer);
|
|
17
|
+
reject(signal.reason);
|
|
18
|
+
};
|
|
19
|
+
const timer = setTimeout(() => {
|
|
20
|
+
signal.removeEventListener("abort", onAbort);
|
|
21
|
+
resolve();
|
|
22
|
+
}, ms);
|
|
23
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
8
26
|
/**
|
|
9
27
|
* Present a Git result the way Pi's bash tool presents command output. Output
|
|
10
28
|
* stays byte-exact, so a diff can be passed back to git apply.
|
|
@@ -30,6 +48,14 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
30
48
|
throw new TypeError(`${name} must be Infinity or a positive safe integer`);
|
|
31
49
|
}
|
|
32
50
|
}
|
|
51
|
+
const maxModelRetries = options.maxModelRetries ?? DEFAULT_MODEL_RETRIES;
|
|
52
|
+
const modelRetryDelayMs = options.modelRetryDelayMs ?? 2000;
|
|
53
|
+
if (!Number.isSafeInteger(maxModelRetries) || maxModelRetries < 0) {
|
|
54
|
+
throw new TypeError("maxModelRetries must be a non-negative safe integer");
|
|
55
|
+
}
|
|
56
|
+
if (!Number.isFinite(modelRetryDelayMs) || modelRetryDelayMs < 0) {
|
|
57
|
+
throw new TypeError("modelRetryDelayMs must be a non-negative finite number");
|
|
58
|
+
}
|
|
33
59
|
const onUsage = options.onUsage;
|
|
34
60
|
const onProgress = options.onProgress;
|
|
35
61
|
const reportProgress = (progress) => {
|
|
@@ -45,7 +71,7 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
45
71
|
messages: context.messages,
|
|
46
72
|
tools: context.tools,
|
|
47
73
|
}).length / 4);
|
|
48
|
-
|
|
74
|
+
const runner = async (request) => {
|
|
49
75
|
const name = request.model;
|
|
50
76
|
const slash = name?.indexOf("/") ?? -1;
|
|
51
77
|
if (!name || slash < 1 || slash === name.length - 1) {
|
|
@@ -182,8 +208,10 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
182
208
|
let decided = false;
|
|
183
209
|
let actualModel = name;
|
|
184
210
|
let reportedContextTokens;
|
|
185
|
-
const
|
|
211
|
+
const prepareRequest = async () => {
|
|
186
212
|
request.signal.throwIfAborted();
|
|
213
|
+
// A pause takes effect here; messages sent while suspended are delivered after resume.
|
|
214
|
+
await request.checkpoint?.();
|
|
187
215
|
deliverHumanInputs();
|
|
188
216
|
const budgets = [];
|
|
189
217
|
if (Number.isFinite(maxToolRounds)) {
|
|
@@ -195,6 +223,7 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
195
223
|
if (budgets.length > 0) {
|
|
196
224
|
budgets.push("When a tool budget reaches zero, make no further tool calls and return your final answer. Reserve budget for decide or finish_merge if required.");
|
|
197
225
|
}
|
|
226
|
+
// Rebuilt per attempt: a suspension moves the node deadline.
|
|
198
227
|
context.systemPrompt = systemPrompt + formatBudgetReminder(request, budgets);
|
|
199
228
|
reportProgress({
|
|
200
229
|
nodeId: request.node.id,
|
|
@@ -206,82 +235,125 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
206
235
|
toolRounds,
|
|
207
236
|
phase: "model",
|
|
208
237
|
});
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
238
|
+
};
|
|
239
|
+
const complete = async () => {
|
|
240
|
+
let retries = 0;
|
|
241
|
+
while (true) {
|
|
242
|
+
// Retries pass the checkpoint too, so a pause during backoff stops before the resend.
|
|
243
|
+
await prepareRequest();
|
|
244
|
+
// A node timeout pause interrupts only this step; the request is sent again after resume.
|
|
245
|
+
const step = request.stepSignal?.() ?? request.signal;
|
|
246
|
+
// The registry resolves stored API keys, OAuth, custom headers and endpoints.
|
|
247
|
+
// complete() is stream().result(); streaming only adds observation.
|
|
248
|
+
// pi-ai retries rejected requests before a response starts, honoring Retry-After.
|
|
249
|
+
const requestOptions = {
|
|
250
|
+
signal: step,
|
|
251
|
+
sessionId,
|
|
252
|
+
cacheRetention: "none",
|
|
253
|
+
transport: "sse",
|
|
254
|
+
maxRetries: maxModelRetries,
|
|
255
|
+
maxRetryDelayMs: MAX_MODEL_RETRY_DELAY_MS,
|
|
256
|
+
};
|
|
257
|
+
activity?.modelRequest();
|
|
258
|
+
let response;
|
|
259
|
+
// Without a stream we cannot tell where a request failed, so Braid may retry it too.
|
|
260
|
+
let started = !registry.stream;
|
|
261
|
+
try {
|
|
262
|
+
if (registry.stream) {
|
|
263
|
+
const stream = registry.stream(model, context, requestOptions);
|
|
264
|
+
for await (const event of stream) {
|
|
265
|
+
if (event.type === "start")
|
|
266
|
+
started = true;
|
|
267
|
+
if (event.type !== "done" && event.type !== "error")
|
|
268
|
+
transcripts?.streaming(executionId, event.partial);
|
|
269
|
+
if (event.type === "text_delta")
|
|
270
|
+
activity?.modelStream("text", event.delta);
|
|
271
|
+
else if (event.type === "thinking_delta")
|
|
272
|
+
activity?.modelStream("reasoning", event.delta);
|
|
273
|
+
else if (event.type === "toolcall_start" || event.type === "toolcall_delta") {
|
|
274
|
+
const block = event.partial.content[event.contentIndex];
|
|
275
|
+
activity?.modelStream("tool_call", event.type === "toolcall_delta" ? event.delta : "", block?.type === "toolCall" ? block.name : undefined);
|
|
276
|
+
}
|
|
233
277
|
}
|
|
278
|
+
response = await stream.result();
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
response = await registry.complete(model, context, requestOptions);
|
|
234
282
|
}
|
|
235
|
-
response = await stream.result();
|
|
236
283
|
}
|
|
237
|
-
|
|
238
|
-
|
|
284
|
+
catch (error) {
|
|
285
|
+
if (isStepInterrupted(request, step)) {
|
|
286
|
+
activity?.modelError("Model request interrupted by the node timeout");
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
activity?.modelError(error instanceof Error ? error.message : String(error));
|
|
290
|
+
throw error;
|
|
239
291
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
activity?.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
stopReason
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
activity?.modelError(response.errorMessage || `Pi model ended with '${response.stopReason}'`);
|
|
256
|
-
}
|
|
257
|
-
actualModel = `${response.provider}/${response.responseModel ?? response.model}`;
|
|
258
|
-
const providerContextTokens = response.usage.input +
|
|
259
|
-
response.usage.cacheRead +
|
|
260
|
-
response.usage.cacheWrite;
|
|
261
|
-
if (providerContextTokens)
|
|
262
|
-
reportedContextTokens = providerContextTokens;
|
|
263
|
-
reportProgress({
|
|
264
|
-
nodeId: request.node.id,
|
|
265
|
-
...(request.execution.executionId ? { executionId: request.execution.executionId } : {}),
|
|
266
|
-
contextTokens: reportedContextTokens ?? estimateContextTokens(context),
|
|
267
|
-
contextWindow: model.contextWindow,
|
|
268
|
-
contextSource: reportedContextTokens !== undefined ? "reported" : "estimate",
|
|
269
|
-
toolCalls,
|
|
270
|
-
toolRounds,
|
|
271
|
-
phase: "model",
|
|
272
|
-
});
|
|
273
|
-
onUsage?.(structuredClone(response.usage));
|
|
274
|
-
usage.inputTokens +=
|
|
275
|
-
response.usage.input +
|
|
292
|
+
// A failed attempt stays visible in the session view but never enters the context.
|
|
293
|
+
transcripts?.assistant(executionId, response);
|
|
294
|
+
activity?.modelResponse({
|
|
295
|
+
text: response.content.flatMap(block => block.type === "text" ? [block.text] : []).join("\n\n"),
|
|
296
|
+
reasoning: response.content.flatMap(block => block.type === "thinking" ? [block.thinking] : []).join("\n\n"),
|
|
297
|
+
toolCalls: response.content.flatMap(block => block.type === "toolCall" ? [{ id: block.id, name: block.name }] : []),
|
|
298
|
+
stopReason: response.stopReason,
|
|
299
|
+
inputTokens: response.usage.input + response.usage.cacheRead + response.usage.cacheWrite,
|
|
300
|
+
outputTokens: response.usage.output,
|
|
301
|
+
});
|
|
302
|
+
if (response.stopReason !== "stop" && response.stopReason !== "toolUse") {
|
|
303
|
+
activity?.modelError(response.errorMessage || `Pi model ended with '${response.stopReason}'`);
|
|
304
|
+
}
|
|
305
|
+
actualModel = `${response.provider}/${response.responseModel ?? response.model}`;
|
|
306
|
+
const providerContextTokens = response.usage.input +
|
|
276
307
|
response.usage.cacheRead +
|
|
277
308
|
response.usage.cacheWrite;
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
309
|
+
if (providerContextTokens)
|
|
310
|
+
reportedContextTokens = providerContextTokens;
|
|
311
|
+
reportProgress({
|
|
312
|
+
nodeId: request.node.id,
|
|
313
|
+
...(request.execution.executionId ? { executionId: request.execution.executionId } : {}),
|
|
314
|
+
contextTokens: reportedContextTokens ?? estimateContextTokens(context),
|
|
315
|
+
contextWindow: model.contextWindow,
|
|
316
|
+
contextSource: reportedContextTokens !== undefined ? "reported" : "estimate",
|
|
317
|
+
toolCalls,
|
|
318
|
+
toolRounds,
|
|
319
|
+
phase: "model",
|
|
320
|
+
});
|
|
321
|
+
// Failed attempts are billed too.
|
|
322
|
+
onUsage?.(structuredClone(response.usage));
|
|
323
|
+
usage.inputTokens +=
|
|
324
|
+
response.usage.input +
|
|
325
|
+
response.usage.cacheRead +
|
|
326
|
+
response.usage.cacheWrite;
|
|
327
|
+
usage.outputTokens += response.usage.output;
|
|
328
|
+
request.signal.throwIfAborted();
|
|
329
|
+
// The partial response never enters the context.
|
|
330
|
+
if (isStepInterrupted(request, step))
|
|
331
|
+
continue;
|
|
332
|
+
// A stream that failed after it started is restarted from the same context. Tools run
|
|
333
|
+
// only after a complete response, so nothing from the failed attempt has executed.
|
|
334
|
+
if (response.stopReason === "error" && started && retries < maxModelRetries && isRetryableAssistantError(response)) {
|
|
335
|
+
const delayMs = retryDelayMs({ baseDelayMs: modelRetryDelayMs, maxAgentDelayMs: MAX_MODEL_RETRY_DELAY_MS }, retries + 1);
|
|
336
|
+
const deadline = Math.min(request.deadlines?.node ?? Infinity, request.deadlines?.graph ?? Infinity);
|
|
337
|
+
// Report the provider error rather than a timeout when the wait cannot finish in time.
|
|
338
|
+
if (performance.now() + delayMs < deadline) {
|
|
339
|
+
retries++;
|
|
340
|
+
activity?.modelError(`Retrying model request in ${Math.ceil(delayMs / 1000)}s (${retries}/${maxModelRetries})`);
|
|
341
|
+
try {
|
|
342
|
+
await abortableDelay(delayMs, step);
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
if (!isStepInterrupted(request, step))
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (response.stopReason !== "stop" && response.stopReason !== "toolUse") {
|
|
352
|
+
throw new Error(response.errorMessage ||
|
|
353
|
+
`Pi model ended with '${response.stopReason}'`);
|
|
354
|
+
}
|
|
355
|
+
return response;
|
|
283
356
|
}
|
|
284
|
-
return response;
|
|
285
357
|
};
|
|
286
358
|
const toolResult = (call, content, isError) => ({
|
|
287
359
|
role: "toolResult",
|
|
@@ -291,11 +363,12 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
291
363
|
isError,
|
|
292
364
|
timestamp: Date.now(),
|
|
293
365
|
});
|
|
294
|
-
const executeFileTool = async (call) => {
|
|
366
|
+
const executeFileTool = async (call, signal) => {
|
|
295
367
|
const tool = toolByName.get(call.name);
|
|
296
368
|
if (!tool && !(call.name === "git" && request.git) && !(call.name === "finish_merge" && request.merge)) {
|
|
297
369
|
return toolResult(call, `Tool '${call.name}' is unavailable in this Braid node. Available tools: ${allToolDefinitions.map(tool => tool.name).join(", ")}.`, true);
|
|
298
370
|
}
|
|
371
|
+
let partialOutput = "";
|
|
299
372
|
try {
|
|
300
373
|
request.signal.throwIfAborted();
|
|
301
374
|
if (call.name === "git" && request.git) {
|
|
@@ -309,17 +382,19 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
309
382
|
return toolResult(call, "Merge dispositions recorded. Return your final answer.", false);
|
|
310
383
|
}
|
|
311
384
|
const args = validateToolCall(allToolDefinitions, call);
|
|
312
|
-
const execute = () => tool.execute(call.id, args,
|
|
385
|
+
const execute = () => tool.execute(call.id, args, signal,
|
|
313
386
|
// Bash sends cumulative, throttled output snapshots.
|
|
314
387
|
(partial) => {
|
|
315
|
-
|
|
316
|
-
|
|
388
|
+
partialOutput = partial.content.flatMap(block => block.type === "text" ? [block.text] : []).join("");
|
|
389
|
+
activity?.toolOutput(call.id, partialOutput, "replace");
|
|
317
390
|
transcripts?.toolUpdate(executionId, call.id, { content: partial.content, details: partial.details });
|
|
318
391
|
});
|
|
319
392
|
const result = ["write", "edit", "bash", "powershell"].includes(call.name) && request.withWorkspaceWrite
|
|
320
393
|
? await request.withWorkspaceWrite(execute)
|
|
321
394
|
: await execute();
|
|
322
395
|
request.signal.throwIfAborted();
|
|
396
|
+
if (isStepInterrupted(request, signal))
|
|
397
|
+
return toolResult(call, interruptedToolResult(partialOutput), true);
|
|
323
398
|
return {
|
|
324
399
|
role: "toolResult",
|
|
325
400
|
toolCallId: call.id,
|
|
@@ -332,12 +407,14 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
332
407
|
}
|
|
333
408
|
catch (error) {
|
|
334
409
|
request.signal.throwIfAborted();
|
|
410
|
+
if (isStepInterrupted(request, signal))
|
|
411
|
+
return toolResult(call, interruptedToolResult(partialOutput), true);
|
|
335
412
|
return toolResult(call, error instanceof Error ? error.message : String(error), true);
|
|
336
413
|
}
|
|
337
414
|
};
|
|
338
|
-
const executeCall = async (call) => {
|
|
415
|
+
const executeCall = async (call, signal) => {
|
|
339
416
|
if (call.name !== "decide")
|
|
340
|
-
return executeFileTool(call);
|
|
417
|
+
return executeFileTool(call, signal);
|
|
341
418
|
if (request.node.type !== "decision") {
|
|
342
419
|
return toolResult(call, "decide is available only on decision nodes", true);
|
|
343
420
|
}
|
|
@@ -391,11 +468,19 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
391
468
|
const results = [];
|
|
392
469
|
for (const call of calls) {
|
|
393
470
|
request.signal.throwIfAborted();
|
|
471
|
+
const step = request.stepSignal?.() ?? request.signal;
|
|
472
|
+
// After an interruption the rest of the batch is not run, but every call still gets a result.
|
|
473
|
+
if (isStepInterrupted(request, step)) {
|
|
474
|
+
const result = toolResult(call, notRunToolResult(), true);
|
|
475
|
+
transcripts?.toolResult(executionId, result);
|
|
476
|
+
results.push(result);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
394
479
|
activity?.toolStart(call.id, call.name, call.arguments);
|
|
395
480
|
transcripts?.toolStart(executionId, call.id);
|
|
396
481
|
let result;
|
|
397
482
|
try {
|
|
398
|
-
result = await executeCall(call);
|
|
483
|
+
result = await executeCall(call, step);
|
|
399
484
|
}
|
|
400
485
|
catch (error) {
|
|
401
486
|
activity?.toolEnd(call.id, error instanceof Error ? error.message : String(error), true);
|
|
@@ -421,6 +506,7 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
421
506
|
usage,
|
|
422
507
|
};
|
|
423
508
|
};
|
|
509
|
+
return Object.assign(runner, { pausable: true, interruptible: true });
|
|
424
510
|
}
|
|
425
511
|
/** Includes cache/cost fields for Pi's tool-usage accounting, separate from core token totals. */
|
|
426
512
|
export function sumPiUsage(reports) {
|