@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.
- package/dist/answer-filter.d.ts +36 -0
- package/dist/answer-filter.d.ts.map +1 -0
- package/dist/answer-filter.js +308 -0
- package/dist/harness.d.ts +154 -0
- package/dist/harness.d.ts.map +1 -0
- package/dist/harness.js +750 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/model-client.d.ts +165 -0
- package/dist/model-client.d.ts.map +1 -0
- package/dist/model-client.js +654 -0
- package/dist/plan-prefix.d.ts +8 -0
- package/dist/plan-prefix.d.ts.map +1 -0
- package/dist/plan-prefix.js +59 -0
- package/dist/protocol.d.ts +21 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +70 -0
- package/dist/reasoning-summary.d.ts +15 -0
- package/dist/reasoning-summary.d.ts.map +1 -0
- package/dist/reasoning-summary.js +48 -0
- package/dist/tool-call-block.d.ts +141 -0
- package/dist/tool-call-block.d.ts.map +1 -0
- package/dist/tool-call-block.js +377 -0
- package/dist/tool-host.d.ts +31 -0
- package/dist/tool-host.d.ts.map +1 -0
- package/dist/tool-host.js +57 -0
- package/dist/types.d.ts +241 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/package.json +30 -6
- package/README.md +0 -3
package/dist/harness.js
ADDED
|
@@ -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
|
+
}
|