@actor0/harness 0.0.0-stage → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,750 @@
1
+ import { createAnswerFilter } from "./answer-filter.js";
2
+ import { ModelTransportError } from "./model-client.js";
3
+ import { formatReasoningSummary } from "./reasoning-summary.js";
4
+ export const DEFAULT_HARNESS_CONFIG = {
5
+ maxRetries: 3,
6
+ initialBackoffMs: 1_000,
7
+ maxBackoffMs: 8_000,
8
+ idleTimeoutMs: 60_000,
9
+ maxToolRounds: Number.POSITIVE_INFINITY,
10
+ maxConsecutiveToolErrors: 5,
11
+ recoverEmptyAnswer: true,
12
+ recoverTruncatedAnswer: true,
13
+ };
14
+ /** Marker appended to an answer a cancel cut short. */
15
+ export const INTERRUPT_MARKER = "[interrupted]";
16
+ /**
17
+ * `text` with the interrupt marker appended, or the marker alone when the
18
+ * cancel landed before anything streamed. The marker is what tells a resumed
19
+ * session that the text above it is a fragment the user stopped, not a
20
+ * completed answer the model should stand behind.
21
+ */
22
+ export function withInterruptMarker(text) {
23
+ const trimmedEnd = text.trimEnd();
24
+ return trimmedEnd ? `${trimmedEnd}\n\n${INTERRUPT_MARKER}` : INTERRUPT_MARKER;
25
+ }
26
+ /** Marker appended to an answer a failed turn cut short. */
27
+ export const FAILED_MARKER = "[failed]";
28
+ /**
29
+ * `text` with the failure marker appended, or the marker alone when
30
+ * the failure landed before anything streamed. The marker is what
31
+ * tells a resumed session that the text above it is a fragment of a
32
+ * failed attempt, not a completed answer the model stands behind.
33
+ */
34
+ export function withFailedMarker(text) {
35
+ const trimmedEnd = text.trimEnd();
36
+ return trimmedEnd ? `${trimmedEnd}\n\n${FAILED_MARKER}` : FAILED_MARKER;
37
+ }
38
+ /** Marker appended to an answer the endpoint cut off at its output cap. */
39
+ export const TRUNCATED_MARKER = "[cut off at the output limit]";
40
+ /**
41
+ * `text` with the truncation marker appended, or the marker alone when
42
+ * the answer never started.
43
+ *
44
+ * The third of the three markers a terminal state leaves in the
45
+ * transcript, and the only one whose host flag was optional: a cancel and a
46
+ * failure each had to hand the transcript back with the marker on it, while
47
+ * a cut-off answer was saved as the plain words the model had written so
48
+ * far. It is not a completed reply and the transcript is what the next turn
49
+ * reads first, so the marker goes here rather than in whichever host
50
+ * remembered to render `RunResult.truncated`.
51
+ */
52
+ export function withTruncationMarker(text) {
53
+ const trimmedEnd = text.trimEnd();
54
+ return trimmedEnd ? `${trimmedEnd}\n\n${TRUNCATED_MARKER}` : TRUNCATED_MARKER;
55
+ }
56
+ /**
57
+ * What a tool call that a cancel cut short is answered with.
58
+ *
59
+ * It is the model's half of the record: the call happened, the command may
60
+ * have done anything, and nobody knows what it produced. Exported because
61
+ * the same sentence is now also the event the host shows the user, and a
62
+ * host that renders its own copy must not be able to word it differently
63
+ * from the one the next turn's model reads.
64
+ */
65
+ export const CANCELLED_TOOL_RESULT = "[cancelled] The turn was interrupted around this call, so its result is unknown. Run it again if you still need it.";
66
+ /**
67
+ * Thrown when the caller aborted a turn. Carries the transcript as the turn
68
+ * left it — the input, every finished round, answers for calls the cancel cut
69
+ * short, and the partial answer marked interrupted — so a host can persist an
70
+ * exchange the user watched happen. Without it, a cancel rewinds the
71
+ * conversation to before the turn: the work vanishes from the saved session
72
+ * and a resume replays it from zero.
73
+ *
74
+ * The transcript is API-valid by construction (see `abortTurn`), so a host can
75
+ * save it as-is and the next request will be accepted.
76
+ */
77
+ export class AbortedTurnError extends Error {
78
+ messages;
79
+ partialText;
80
+ constructor(messages,
81
+ /** What the cancelled attempt had streamed, without the marker. */
82
+ partialText) {
83
+ super("The operation was aborted");
84
+ this.messages = messages;
85
+ this.partialText = partialText;
86
+ this.name = "AbortedTurnError";
87
+ }
88
+ }
89
+ /**
90
+ * Thrown when a turn failed for any reason other than a cancel: the
91
+ * endpoint refused the request, the stream died mid-answer, a retry
92
+ * budget ran out. Carries the transcript as the turn left it — the
93
+ * input, every finished round, and the partial answer marked failed
94
+ * — so a host can persist an exchange whose later rounds really
95
+ * happened. Without it, a mid-turn failure rewinds the conversation
96
+ * to before the turn: tool calls that ran and files that changed
97
+ * vanish from the saved session, and a resume replays them from zero
98
+ * as though they never happened. Claude Code yields the error into
99
+ * the conversation instead of rewinding, and so does this.
100
+ *
101
+ * The transcript is API-valid by construction (see `failedTurn`), so
102
+ * a host can save it as-is and the next request will be accepted.
103
+ */
104
+ export class FailedTurnError extends Error {
105
+ messages;
106
+ partialText;
107
+ constructor(messages,
108
+ /** What the failed attempt had streamed, without the marker. */
109
+ partialText, cause) {
110
+ super(cause === undefined ? "The turn failed" : errorMessage(cause), { cause });
111
+ this.messages = messages;
112
+ this.partialText = partialText;
113
+ this.name = "FailedTurnError";
114
+ }
115
+ }
116
+ export async function runModelRound(model, messages, tools, signal, observer, config = DEFAULT_HARNESS_CONFIG) {
117
+ let lastError;
118
+ for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
119
+ assertNotAborted(signal);
120
+ if (attempt > 0) {
121
+ const delayMs = retryDelay(attempt, config, lastError);
122
+ if (delayMs > 0) {
123
+ const note = isServerDirected(lastError) ? "Rate limited — " : "";
124
+ await emit(observer, { type: "status", status: `${note}retrying in ${waitLabel(delayMs)}…`, source: "note" });
125
+ await sleep(delayMs, signal);
126
+ }
127
+ }
128
+ try {
129
+ return await runModelAttempt(model, messages, tools, signal, observer, config);
130
+ }
131
+ catch (error) {
132
+ if (signal.aborted)
133
+ throw error;
134
+ lastError = error;
135
+ if (!isRetriable(error) || attempt === config.maxRetries)
136
+ throw error;
137
+ }
138
+ }
139
+ throw lastError ?? new ModelTransportError("model request failed after retries", true);
140
+ }
141
+ async function runModelAttempt(model, messages, tools, outerSignal, observer, config) {
142
+ const controller = new AbortController();
143
+ const abortOuter = () => controller.abort(outerSignal.reason);
144
+ outerSignal.addEventListener("abort", abortOuter, { once: true });
145
+ if (outerSignal.aborted)
146
+ controller.abort(outerSignal.reason);
147
+ // The filter also runs the tool-call protocol: a fenced ```json block
148
+ // naming one of the request's tools becomes a real call instead of text.
149
+ const filter = createAnswerFilter(tools.map((tool) => tool.function.name));
150
+ let attemptText = "";
151
+ let reasoningText = "";
152
+ const toolCalls = [];
153
+ const rejectedBlocks = [];
154
+ const usage = [];
155
+ let sawDone = false;
156
+ let truncationReason;
157
+ let iterator;
158
+ const emitFiltered = async (delta) => {
159
+ const filtered = filter.push(delta);
160
+ for (const status of filtered.statuses) {
161
+ await emit(observer, { type: "status", status, source: "marker" });
162
+ }
163
+ if (filtered.plan)
164
+ await emit(observer, { type: "plan", plan: filtered.plan });
165
+ for (const rejection of filtered.rejected) {
166
+ rejectedBlocks.push(rejection);
167
+ await emit(observer, { type: "tool_rejected", rejection });
168
+ }
169
+ for (const call of filtered.toolCalls) {
170
+ toolCalls.push(call);
171
+ await emit(observer, { type: "tool_call", tool_calls: [call] });
172
+ }
173
+ if (filtered.text) {
174
+ attemptText += filtered.text;
175
+ await emit(observer, { type: "token", delta: filtered.text });
176
+ }
177
+ };
178
+ try {
179
+ // The attempt boundary, told to the observer before anything streams: a
180
+ // host that keeps render state can snapshot here and discard exactly what
181
+ // this attempt produced if the attempt dies and is retried. Without it, a
182
+ // `reset` cannot say how far to rewind — text is named by `attemptText`,
183
+ // but reasoning flushed on a tool_call or plan, statuses, and usage
184
+ // summaries all streamed before the failure too.
185
+ await emit(observer, { type: "attempt_start" });
186
+ iterator = model.stream(messages, tools, controller.signal)[Symbol.asyncIterator]();
187
+ while (true) {
188
+ const next = await withTimeout(iterator.next(), config.idleTimeoutMs, controller, `${config.idleTimeoutMs / 1_000}s idle timeout`);
189
+ if (next.done)
190
+ break;
191
+ const event = next.value;
192
+ if (event.type === "token") {
193
+ await emitFiltered(event.delta);
194
+ }
195
+ else if (event.type === "reasoning") {
196
+ reasoningText = `${reasoningText}${event.delta}`.slice(-4_000);
197
+ await emit(observer, { type: "reasoning", delta: event.delta });
198
+ }
199
+ else if (event.type === "tool_call") {
200
+ toolCalls.push(...event.tool_calls);
201
+ await emit(observer, { type: "tool_call", tool_calls: event.tool_calls });
202
+ }
203
+ else if (event.type === "usage") {
204
+ usage.push(event.usage);
205
+ await emit(observer, { type: "usage", usage: event.usage });
206
+ }
207
+ else if (event.type === "status") {
208
+ await emit(observer, { type: "status", status: event.note, source: "note" });
209
+ }
210
+ else if (event.type === "provider") {
211
+ await emit(observer, { type: "provider", name: event.name });
212
+ }
213
+ else if (event.type === "done") {
214
+ sawDone = true;
215
+ if (event.truncated && truncationReason === undefined)
216
+ truncationReason = event.reason ?? "length";
217
+ }
218
+ }
219
+ const tail = filter.flush();
220
+ for (const status of tail.statuses)
221
+ await emit(observer, { type: "status", status, source: "marker" });
222
+ if (tail.plan)
223
+ await emit(observer, { type: "plan", plan: tail.plan });
224
+ for (const rejection of tail.rejected) {
225
+ rejectedBlocks.push(rejection);
226
+ await emit(observer, { type: "tool_rejected", rejection });
227
+ }
228
+ for (const call of tail.toolCalls) {
229
+ toolCalls.push(call);
230
+ await emit(observer, { type: "tool_call", tool_calls: [call] });
231
+ }
232
+ if (tail.text) {
233
+ attemptText += tail.text;
234
+ await emit(observer, { type: "token", delta: tail.text });
235
+ }
236
+ const reasoning = formatReasoningSummary(reasoningText);
237
+ // A cut-off answer is not a finished one, whatever the transport said
238
+ // about ending the stream. `[DONE]` describes the connection;
239
+ // `finish_reason` describes the answer, and only the second one is about
240
+ // whether the model got to the end of what it was writing.
241
+ const truncated = truncationReason !== undefined;
242
+ if (!sawDone && attemptText.trim()) {
243
+ await emit(observer, { type: "partial", text: attemptText });
244
+ }
245
+ return {
246
+ text: attemptText,
247
+ toolCalls,
248
+ rejectedBlocks,
249
+ complete: sawDone && !truncated,
250
+ truncated,
251
+ truncationReason,
252
+ reasoning,
253
+ usage,
254
+ };
255
+ }
256
+ catch (error) {
257
+ // The attempt's streamed text dies here — the round loop above cannot
258
+ // see it — so it rides on the error to whichever end consumes it:
259
+ // `abortTurn` for a cancel, `failedTurn` for any other failure.
260
+ // Without this, the text the user watched stream would be the one
261
+ // thing the saved session loses.
262
+ error.attemptText = attemptText;
263
+ if (outerSignal.aborted)
264
+ throw error;
265
+ if (attemptText && isRetriable(error))
266
+ await emit(observer, { type: "reset", attemptText });
267
+ if (isRetriable(error))
268
+ throw error;
269
+ if (error instanceof ModelTransportError)
270
+ throw error;
271
+ // The wrapper is a new object, so the fragment has to be moved onto
272
+ // it — or the text is lost even though the harness carries it out.
273
+ const wrapped = new ModelTransportError(errorMessage(error), false);
274
+ wrapped.attemptText = attemptText;
275
+ throw wrapped;
276
+ }
277
+ finally {
278
+ outerSignal.removeEventListener("abort", abortOuter);
279
+ if (iterator?.return)
280
+ await iterator.return();
281
+ }
282
+ }
283
+ function buildAudit(rounds, complete, toolCalls, usage) {
284
+ let promptTokens = 0;
285
+ let completionTokens = 0;
286
+ let totalTokens = 0;
287
+ for (const entry of usage) {
288
+ promptTokens += entry.prompt_tokens ?? 0;
289
+ completionTokens += entry.completion_tokens ?? 0;
290
+ totalTokens += entry.total_tokens ?? 0;
291
+ }
292
+ const toolCallCount = Object.values(toolCalls).reduce((sum, n) => sum + n, 0);
293
+ return {
294
+ rounds,
295
+ complete,
296
+ toolCalls: { ...toolCalls },
297
+ toolCallCount,
298
+ promptTokens,
299
+ completionTokens,
300
+ totalTokens,
301
+ };
302
+ }
303
+ export async function runAgentTurn(options) {
304
+ const config = options.config ?? DEFAULT_HARNESS_CONFIG;
305
+ const messages = [...options.messages, { role: "user", content: options.input }];
306
+ const tools = options.toolHost?.definitions() ?? [];
307
+ const allUsage = [];
308
+ const toolCounts = {};
309
+ let recoveredEmpty = false;
310
+ let recoveredTruncation = false;
311
+ let finalReasoning = { title: "", summary: "" };
312
+ // Unbounded by default: the only reason to stop a turn is a sustained run of
313
+ // failures, which stops to *ask* rather than to give up.
314
+ let consecutiveToolErrors = 0;
315
+ for (let round = 1;; round++) {
316
+ let roundText = "";
317
+ if (options.signal.aborted)
318
+ abortTurn(messages, "", []);
319
+ let result;
320
+ try {
321
+ result = await runModelRound(options.model, messages, tools, options.signal, options.observer, config);
322
+ }
323
+ catch (error) {
324
+ // An abort can surface from anywhere inside the round — the pre-flight
325
+ // check, the backoff sleep, the stream itself. It still has to leave
326
+ // through `abortTurn`, or the caller loses the transcript below.
327
+ if (options.signal.aborted)
328
+ abortTurn(messages, attemptTextOf(error), []);
329
+ // So does every other failure. The rounds behind this one are real
330
+ // work that really happened; `failedTurn` keeps them and the
331
+ // streamed fragment instead of rewinding the session to the
332
+ // question the turn started from.
333
+ failedTurn(messages, attemptTextOf(error), error);
334
+ }
335
+ roundText = result.text;
336
+ allUsage.push(...result.usage);
337
+ finalReasoning = result.reasoning;
338
+ // A refused protocol block still gets answered. The synthetic call plus
339
+ // the error tool result go into the transcript ahead of any real calls,
340
+ // so the next round opens with the model being told its block was wrong
341
+ // and why — the same way a failing tool is reported — instead of learning
342
+ // nothing and re-emitting the same block (or worse, the user reading the
343
+ // raw JSON). No tool host runs for these; the result is authored here.
344
+ for (const rejection of result.rejectedBlocks) {
345
+ messages.push({
346
+ role: "assistant",
347
+ content: "",
348
+ tool_calls: [rejection.call],
349
+ });
350
+ messages.push({
351
+ role: "tool",
352
+ content: rejection.reason,
353
+ tool_call_id: rejection.call.id,
354
+ name: rejection.call.function.name,
355
+ });
356
+ }
357
+ // A round that only refused a block continues: its answer is the error
358
+ // tool result the harness authored, so the loop runs another round with
359
+ // that result in front of the model. Treating it as complete here would
360
+ // strand the rejection — the model never gets to react. The round budget
361
+ // still applies: stop and report rather than loop on a model that keeps
362
+ // re-emitting the same refused block.
363
+ if (result.toolCalls.length === 0 && result.rejectedBlocks.length > 0) {
364
+ if (Number.isFinite(config.maxToolRounds) && round >= config.maxToolRounds) {
365
+ await stopForUser(options.observer, messages, "round_limit", `stopped after ${config.maxToolRounds} tool rounds`);
366
+ return {
367
+ messages,
368
+ text: result.text,
369
+ complete: result.complete,
370
+ rounds: round,
371
+ usage: allUsage,
372
+ audit: buildAudit(round, result.complete, toolCounts, allUsage),
373
+ reasoning: finalReasoning,
374
+ blocked: true,
375
+ };
376
+ }
377
+ continue;
378
+ }
379
+ if (result.toolCalls.length > 0) {
380
+ messages.push({
381
+ role: "assistant",
382
+ content: result.text,
383
+ tool_calls: result.toolCalls,
384
+ });
385
+ let failedThisRound = 0;
386
+ for (let index = 0; index < result.toolCalls.length; index += 1) {
387
+ const call = result.toolCalls[index];
388
+ toolCounts[call.function.name] = (toolCounts[call.function.name] ?? 0) + 1;
389
+ await emit(options.observer, { type: "tool_start", call });
390
+ try {
391
+ const output = await options.toolHost?.execute(call, options.signal);
392
+ if (output === undefined)
393
+ throw new Error("tool host returned no result");
394
+ messages.push({
395
+ role: "tool",
396
+ content: output,
397
+ tool_call_id: call.id,
398
+ name: call.function.name,
399
+ });
400
+ await emit(options.observer, { type: "tool_result", call, output });
401
+ }
402
+ catch (error) {
403
+ if (options.signal.aborted) {
404
+ // This call's answer is unknown and the rest never started; say
405
+ // so, or the saved transcript dangles and the resume is rejected.
406
+ //
407
+ // And say it *out loud*, which is the half that was missing. The
408
+ // sentence went only into the transcript, so a host that reflects
409
+ // events saw a spinner for a command the user had just killed and
410
+ // then a transcript claiming the turn did nothing at all — while
411
+ // the next turn's model was handed the call and its `[cancelled]`
412
+ // answer. One surface described a turn in which nothing happened
413
+ // and the other one in which a command ran. Only the in-flight
414
+ // call gets an event: the calls after it never started, and a row
415
+ // for those would report work the user never saw begin.
416
+ await emit(options.observer, {
417
+ type: "tool_result",
418
+ call,
419
+ output: CANCELLED_TOOL_RESULT,
420
+ error: CANCELLED_TOOL_RESULT,
421
+ });
422
+ abortTurn(messages, "", [call, ...result.toolCalls.slice(index + 1)]);
423
+ }
424
+ failedThisRound += 1;
425
+ const output = `Tool failed: ${errorMessage(error)}`;
426
+ messages.push({
427
+ role: "tool",
428
+ content: output,
429
+ tool_call_id: call.id,
430
+ name: call.function.name,
431
+ });
432
+ await emit(options.observer, { type: "tool_result", call, output, error: output });
433
+ }
434
+ }
435
+ // Only a round where *everything* failed counts. One flaky tool in a
436
+ // round of four is not a reason to interrupt a long research turn.
437
+ consecutiveToolErrors = failedThisRound === result.toolCalls.length
438
+ ? consecutiveToolErrors + 1
439
+ : 0;
440
+ if (consecutiveToolErrors >= config.maxConsecutiveToolErrors) {
441
+ await stopForUser(options.observer, messages, "tool_errors", `${consecutiveToolErrors} rounds of tool calls failed in a row`);
442
+ return {
443
+ messages,
444
+ text: result.text,
445
+ complete: result.complete,
446
+ rounds: round,
447
+ usage: allUsage,
448
+ audit: buildAudit(round, result.complete, toolCounts, allUsage),
449
+ reasoning: finalReasoning,
450
+ blocked: true,
451
+ };
452
+ }
453
+ if (options.policy?.shouldStopAfterTools?.({ calls: result.toolCalls, text: roundText, round })) {
454
+ return {
455
+ messages,
456
+ text: result.text,
457
+ complete: result.complete,
458
+ rounds: round,
459
+ usage: allUsage,
460
+ audit: buildAudit(round, result.complete, toolCounts, allUsage),
461
+ reasoning: finalReasoning,
462
+ };
463
+ }
464
+ // The round budget is a *stop*, not a failure. Throwing here threw away
465
+ // a turn that had already produced files and findings because the model
466
+ // kept going — which is the case the budget exists for. Stop the way the
467
+ // error streak stops: report it, keep what was done, let the host say so
468
+ // in its own words.
469
+ if (Number.isFinite(config.maxToolRounds) && round >= config.maxToolRounds) {
470
+ await stopForUser(options.observer, messages, "round_limit", `stopped after ${config.maxToolRounds} tool rounds`);
471
+ return {
472
+ messages,
473
+ text: result.text,
474
+ complete: result.complete,
475
+ rounds: round,
476
+ usage: allUsage,
477
+ audit: buildAudit(round, result.complete, toolCounts, allUsage),
478
+ reasoning: finalReasoning,
479
+ blocked: true,
480
+ };
481
+ }
482
+ continue;
483
+ }
484
+ // A round that produced an answer is a healthy round — clear the streak.
485
+ consecutiveToolErrors = 0;
486
+ // The endpoint stopped writing mid-sentence. This is the one condition
487
+ // that is not the model's mistake and not a tool's: the answer exists,
488
+ // it just did not fit. Ask for the rest of it, with what was written
489
+ // already in front of the model so it continues rather than restarts.
490
+ // Once only, and only for an answer that was actually cut — a model that
491
+ // is too long for the cap is too long for it twice.
492
+ if (result.truncated && config.recoverTruncatedAnswer && !recoveredTruncation && result.text.trim()) {
493
+ recoveredTruncation = true;
494
+ messages.push({ role: "assistant", content: result.text });
495
+ await emit(options.observer, {
496
+ type: "status",
497
+ status: "Answer cut off at the model\u2019s output limit \u2014 continuing\u2026",
498
+ source: "note",
499
+ });
500
+ messages.push({
501
+ role: "user",
502
+ content: options.policy?.truncationContinuationMessage ??
503
+ "(Your previous reply was cut off at the output limit. Continue it from exactly where it stopped. Do not repeat any text you have already written and do not start over.)",
504
+ });
505
+ continue;
506
+ }
507
+ // A round that only refused a block is not an empty round: the model said
508
+ // something (the harness answered it with the error result above), and
509
+ // stacking "your previous reply was empty" on top of that rejection
510
+ // teaches the model two contradictory things at once.
511
+ if (!result.text.trim() && result.rejectedBlocks.length === 0 && config.recoverEmptyAnswer && !recoveredEmpty) {
512
+ recoveredEmpty = true;
513
+ await emit(options.observer, {
514
+ type: "status",
515
+ status: "Empty response — trying again…",
516
+ source: "note",
517
+ });
518
+ messages.push({
519
+ role: "user",
520
+ content: options.policy?.emptyContinuationMessage ??
521
+ "(Your previous reply was empty. Please respond now to the user's last message.)",
522
+ });
523
+ continue;
524
+ }
525
+ await emit(options.observer, {
526
+ type: "done",
527
+ text: result.text,
528
+ complete: result.complete,
529
+ ...(result.truncated ? { truncated: true, reason: result.truncationReason } : {}),
530
+ });
531
+ // What is stored is what the next turn's model reads, so the mark that
532
+ // this answer was cut off lives here and not only on the flag above. The
533
+ // host still says it to the user — a transcript is not on screen.
534
+ messages.push({
535
+ role: "assistant",
536
+ content: result.truncated ? withTruncationMarker(result.text) : result.text,
537
+ });
538
+ return {
539
+ messages,
540
+ text: result.text,
541
+ complete: result.complete,
542
+ rounds: round,
543
+ usage: allUsage,
544
+ audit: buildAudit(round, result.complete, toolCounts, allUsage),
545
+ reasoning: finalReasoning,
546
+ // Only the round that ends the turn decides this: an earlier truncated
547
+ // round that the continuation finished is not a truncated turn.
548
+ ...(result.truncated ? { truncated: true, truncationReason: result.truncationReason } : {}),
549
+ };
550
+ }
551
+ throw new Error("unreachable: the tool-round loop always returns");
552
+ }
553
+ /**
554
+ * Stop the turn and say so — to the user, and to the model.
555
+ *
556
+ * A stop is the one moment the host speaks to the user and the model hears
557
+ * nothing. The UI says “stopped: tool calls kept failing — check the paths and try again”,
558
+ * the user answers that question, and the answer arrives as an ordinary user
559
+ * message with no question anywhere before it. Measured: after a five-round
560
+ * tool-failure stop the transcript ended on a tool result and said nothing
561
+ * about stopping, so the next turn opened with a reply to a prompt the model
562
+ * had never seen.
563
+ *
564
+ * The last sentence of the note is the load-bearing one. It is the problem
565
+ * Claude Code writes into the system prompt for its companion sprite — a second
566
+ * speaker sharing the channel — and the fix is the same: tell the model what the
567
+ * other party knows instead of leaving it to infer that from the shape of the
568
+ * reply. It deliberately does not claim the user's next message is about the
569
+ * stop, because it may not be.
570
+ */
571
+ function stopNote(reason, message) {
572
+ const cause = reason === "round_limit"
573
+ ? `This turn stopped at its tool-round budget (${message}).`
574
+ : `This turn stopped because ${message}.`;
575
+ return (`(${cause} The user has been told and asked how to proceed. They have not ` +
576
+ `seen a message from you about the stop, so do not assume they know why.)`);
577
+ }
578
+ async function stopForUser(observer, messages, reason, message) {
579
+ await emit(observer, { type: "needs_user", reason, message });
580
+ messages.push({ role: "user", content: stopNote(reason, message) });
581
+ }
582
+ async function emit(observer, event) {
583
+ await observer?.event?.(event);
584
+ }
585
+ async function withTimeout(promise, timeoutMs, controller, message) {
586
+ let timer;
587
+ try {
588
+ return await Promise.race([
589
+ promise,
590
+ new Promise((_, reject) => {
591
+ timer = setTimeout(() => {
592
+ controller.abort();
593
+ reject(new ModelTransportError(message, true));
594
+ }, timeoutMs);
595
+ }),
596
+ ]);
597
+ }
598
+ finally {
599
+ if (timer)
600
+ clearTimeout(timer);
601
+ }
602
+ }
603
+ function sleep(ms, signal) {
604
+ return new Promise((resolve, reject) => {
605
+ if (signal.aborted)
606
+ return reject(abortError());
607
+ const timer = setTimeout(() => {
608
+ signal.removeEventListener("abort", onAbort);
609
+ resolve();
610
+ }, ms);
611
+ const onAbort = () => {
612
+ clearTimeout(timer);
613
+ reject(abortError());
614
+ };
615
+ signal.addEventListener("abort", onAbort, { once: true });
616
+ });
617
+ }
618
+ function assertNotAborted(signal) {
619
+ if (signal.aborted)
620
+ throw abortError();
621
+ }
622
+ function abortError() {
623
+ return new DOMException("The operation was aborted", "AbortError");
624
+ }
625
+ function isRetriable(error) {
626
+ return error instanceof ModelTransportError && error.retriable;
627
+ }
628
+ /** Did the server tell us when to come back? */
629
+ function isServerDirected(error) {
630
+ return error instanceof ModelTransportError && error.retryAfterMs !== undefined;
631
+ }
632
+ /** The wait a rate limit asked for, if the error came from one. */
633
+ function retryAfterOf(error) {
634
+ return error instanceof ModelTransportError ? error.retryAfterMs : undefined;
635
+ }
636
+ /**
637
+ * A duration as it should be read.
638
+ *
639
+ * Rounding a sub-second wait to "0s" tells the user nothing is happening,
640
+ * which is the one thing they are watching for when the turn goes quiet.
641
+ */
642
+ function waitLabel(delayMs) {
643
+ return delayMs >= 1_000 ? `${Math.round(delayMs / 1_000)}s` : `${Math.round(delayMs)}ms`;
644
+ }
645
+ /**
646
+ * Jitter added to every wait, as a fraction of the delay itself.
647
+ *
648
+ * Without it, every client that hit a rate limit in the same second comes back
649
+ * in the same second, and the limit that just rejected them rejects them
650
+ * again. It is added and never subtracted: a wait shorter than the backoff it
651
+ * is jittering is not a backoff.
652
+ */
653
+ export const RETRY_JITTER_RATIO = 0.25;
654
+ /**
655
+ * How long to wait before the next attempt.
656
+ *
657
+ * Two inputs, and the second can only make the wait longer:
658
+ *
659
+ * - exponential backoff, capped, which is what a dropped connection or a
660
+ * 5xx wants;
661
+ * - `Retry-After`, which is the server stating a rate limit window. A 429
662
+ * that says "come back in 30" and is retried after one, two and four
663
+ * seconds has honoured nothing: every attempt lands inside the window and
664
+ * the turn fails having spent seven seconds confirming what it was told.
665
+ *
666
+ * A wait beyond `MAX_HONOURED_RETRY_AFTER_MS` never reaches here — the
667
+ * transport has already made that failure non-retriable rather than have the
668
+ * turn vanish for a number of minutes a user is waiting on.
669
+ *
670
+ * Exported so the arithmetic can be tested without sleeping through it.
671
+ */
672
+ export function retryDelay(attempt, config, error) {
673
+ const backoff = Math.min(config.initialBackoffMs * 2 ** (attempt - 1), config.maxBackoffMs);
674
+ const base = Math.max(backoff, retryAfterOf(error) ?? 0);
675
+ return Math.round(base + Math.random() * RETRY_JITTER_RATIO * base);
676
+ }
677
+ function errorMessage(error) {
678
+ return error instanceof Error ? error.message : String(error);
679
+ }
680
+ /** The streamed text an aborted attempt carried out on its error, if any. */
681
+ function attemptTextOf(error) {
682
+ const text = error?.attemptText;
683
+ return typeof text === "string" ? text : "";
684
+ }
685
+ /**
686
+ * Leave a cancelled turn the way the user watched it, not the way the code
687
+ * unwound.
688
+ *
689
+ * Every call in `calls` gets an answer: a result unknown. That covers both
690
+ * the call a cancel cut short and the ones that never started — "result
691
+ * unknown" is the honest answer to "did it finish?", and an invented result
692
+ * (an empty string, the failure text) would teach the model something false
693
+ * about the machine. On the next round the model can re-run what it needs.
694
+ *
695
+ * `partialText` is appended as the final assistant message with an interrupt
696
+ * marker, so a resume reads the cut text as a fragment the user stopped
697
+ * rather than a completed answer. Claude Code leaves the same shape behind —
698
+ * a partial assistant message and "[Request interrupted by user]" — with the
699
+ * marker as its own pseudo-message; here it rides on the message, because
700
+ * `ChatMessage` has no place for a separate notice and one shape keeps every
701
+ * consumer uniform. Pass an empty string when the streamed text is already in
702
+ * the transcript (on the tool-call assistant message), so it is not stored
703
+ * twice.
704
+ *
705
+ * The result is API-valid by construction: no dangling call, no unanswered
706
+ * `tool_calls` — the shapes a provider rejects the whole request over.
707
+ */
708
+ function abortTurn(messages, partialText, unanswered) {
709
+ const transcript = [...messages];
710
+ for (const call of unanswered) {
711
+ transcript.push({ role: "assistant", content: "", tool_calls: [call] });
712
+ transcript.push({
713
+ role: "tool",
714
+ content: CANCELLED_TOOL_RESULT,
715
+ tool_call_id: call.id,
716
+ name: call.function.name,
717
+ });
718
+ }
719
+ transcript.push({ role: "assistant", content: withInterruptMarker(partialText) });
720
+ throw new AbortedTurnError(transcript, partialText);
721
+ }
722
+ /**
723
+ * Leave a failed turn the way the user watched it, not the way the
724
+ * code unwound.
725
+ *
726
+ * A turn that fails mid-flight has usually already done real work:
727
+ * tool calls that ran, files that changed, results that came back.
728
+ * Throwing the raw error instead would rewind the session to before
729
+ * the turn — the user's question included, if the failure came
730
+ * before the first round finished — and a restart would replay work
731
+ * that already happened. Claude Code yields the error into the
732
+ * conversation instead, and so does this: the partial answer is
733
+ * appended with a failure marker and the whole transcript rides out
734
+ * on the error, so a host can save exactly what happened.
735
+ *
736
+ * Unlike a cancel, nothing was interrupted mid-flight, so there are no
737
+ * unanswered calls to answer: the round loop catches only model
738
+ * failures, and a tool failure is recorded per-call as it happens.
739
+ *
740
+ * The result is API-valid by construction: no dangling call, no
741
+ * unanswered `tool_calls` — the shapes a provider rejects the whole
742
+ * request over.
743
+ */
744
+ function failedTurn(messages, partialText, cause) {
745
+ const transcript = [
746
+ ...messages,
747
+ { role: "assistant", content: withFailedMarker(partialText) },
748
+ ];
749
+ throw new FailedTurnError(transcript, partialText, cause);
750
+ }