@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/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, blocking = false) {
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.node.pauseAfter || state?.error));
156
- if (!this.disposed && !job.blocking && shouldNotify && "nodeId" in event && instance) {
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 && (!job.blocking || job.status === "cancelled")) {
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 { blocking: _blocking, run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, activity: _activity, transcripts: _transcripts, humanInputs: _humanInputs, ...snapshot } = job;
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(jobId, executionIds, expectedRevision) {
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 executions remain, so it cannot progress without resume or cancel.
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
- return async (request) => {
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 complete = async () => {
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
- // The registry resolves stored API keys, OAuth, custom headers and endpoints.
210
- // complete() is stream().result(); streaming only adds observation.
211
- const requestOptions = {
212
- signal: request.signal,
213
- sessionId,
214
- cacheRetention: "none",
215
- transport: "sse",
216
- maxRetries: 0,
217
- };
218
- activity?.modelRequest();
219
- let response;
220
- try {
221
- if (registry.stream) {
222
- const stream = registry.stream(model, context, requestOptions);
223
- for await (const event of stream) {
224
- if (event.type !== "done" && event.type !== "error")
225
- transcripts?.streaming(executionId, event.partial);
226
- if (event.type === "text_delta")
227
- activity?.modelStream("text", event.delta);
228
- else if (event.type === "thinking_delta")
229
- activity?.modelStream("reasoning", event.delta);
230
- else if (event.type === "toolcall_start" || event.type === "toolcall_delta") {
231
- const block = event.partial.content[event.contentIndex];
232
- activity?.modelStream("tool_call", event.type === "toolcall_delta" ? event.delta : "", block?.type === "toolCall" ? block.name : undefined);
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
- else {
238
- response = await registry.complete(model, context, requestOptions);
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
- catch (error) {
242
- activity?.modelError(error instanceof Error ? error.message : String(error));
243
- throw error;
244
- }
245
- transcripts?.assistant(executionId, response);
246
- activity?.modelResponse({
247
- text: response.content.flatMap(block => block.type === "text" ? [block.text] : []).join("\n\n"),
248
- reasoning: response.content.flatMap(block => block.type === "thinking" ? [block.thinking] : []).join("\n\n"),
249
- toolCalls: response.content.flatMap(block => block.type === "toolCall" ? [{ id: block.id, name: block.name }] : []),
250
- stopReason: response.stopReason,
251
- inputTokens: response.usage.input + response.usage.cacheRead + response.usage.cacheWrite,
252
- outputTokens: response.usage.output,
253
- });
254
- if (response.stopReason !== "stop" && response.stopReason !== "toolUse") {
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
- usage.outputTokens += response.usage.output;
279
- request.signal.throwIfAborted();
280
- if (response.stopReason !== "stop" && response.stopReason !== "toolUse") {
281
- throw new Error(response.errorMessage ||
282
- `Pi model ended with '${response.stopReason}'`);
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, request.signal,
385
+ const execute = () => tool.execute(call.id, args, signal,
313
386
  // Bash sends cumulative, throttled output snapshots.
314
387
  (partial) => {
315
- activity?.toolOutput(call.id, partial.content
316
- .flatMap(block => block.type === "text" ? [block.text] : []).join(""), "replace");
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) {