@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
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type RejectedBlock } from "./tool-call-block.js";
|
|
2
|
+
import type { PlanPrefix, ToolCall } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Status text kept from one line.
|
|
5
|
+
*
|
|
6
|
+
* A line that opens with the marker is a status line, and its text runs to
|
|
7
|
+
* the end of that line. Past this many characters it stops being a status and
|
|
8
|
+
* becomes the answer again, which is what keeps a runaway line from taking the
|
|
9
|
+
* rest of the response with it.
|
|
10
|
+
*/
|
|
11
|
+
export declare const MAX_STATUS_CHARS = 80;
|
|
12
|
+
export type FilteredChunk = {
|
|
13
|
+
text: string;
|
|
14
|
+
statuses: string[];
|
|
15
|
+
plan: PlanPrefix | null;
|
|
16
|
+
/** Tool calls the model emitted as protocol blocks (see tool-call-block.ts). */
|
|
17
|
+
toolCalls: ToolCall[];
|
|
18
|
+
/**
|
|
19
|
+
* Blocks the protocol refused — a call for an unoffered tool, or arguments
|
|
20
|
+
* that were not valid JSON. Each becomes a synthetic call plus an error
|
|
21
|
+
* tool result, so the model is told next round that the call was wrong and
|
|
22
|
+
* why; nothing here is ever released into the answer text.
|
|
23
|
+
*/
|
|
24
|
+
rejected: RejectedBlock[];
|
|
25
|
+
};
|
|
26
|
+
export type AnswerFilter = {
|
|
27
|
+
push(delta: string): FilteredChunk;
|
|
28
|
+
flush(): FilteredChunk;
|
|
29
|
+
reset(): void;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* @param toolNames names from the request's tool list. A block naming
|
|
33
|
+
* anything else is not a call and is shown as ordinary text.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createAnswerFilter(toolNames?: string[]): AnswerFilter;
|
|
36
|
+
//# sourceMappingURL=answer-filter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"answer-filter.d.ts","sourceRoot":"","sources":["../src/answer-filter.ts"],"names":[],"mappings":"AACA,OAAO,EAWL,KAAK,aAAa,EACnB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAMvD;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,KAAK,CAAC;AA+EnC,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,IAAI,EAAE,UAAU,GAAG,IAAI,CAAC;IACxB,gFAAgF;IAChF,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB;;;;;OAKG;IACH,QAAQ,EAAE,aAAa,EAAE,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,CAAC;IACnC,KAAK,IAAI,aAAa,CAAC;IACvB,KAAK,IAAI,IAAI,CAAC;CACf,CAAC;AAEF;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,GAAE,MAAM,EAAO,GAAG,YAAY,CAkPzE"}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { extractPlanPrefix, needsMorePlanData } from "./plan-prefix.js";
|
|
2
|
+
import { MAX_TOOL_BLOCK, matchCloseEnvelope, matchCloseFence, matchOpenEnvelope, matchOpenFence, mayBeOpenEnvelope, mayBeOpenFence, parseToolCallBlock, parseToolCallEnvelope, } from "./tool-call-block.js";
|
|
3
|
+
/** Incremental answer filtering for streamed model output. */
|
|
4
|
+
const MAX_STATUS_HOLD = 48;
|
|
5
|
+
const MAYBE_STATUS_PREFIX = /^(?:[ \t]*|[ \t]*\[[^\x5d\n]{0,40})$/;
|
|
6
|
+
/**
|
|
7
|
+
* Status text kept from one line.
|
|
8
|
+
*
|
|
9
|
+
* A line that opens with the marker is a status line, and its text runs to
|
|
10
|
+
* the end of that line. Past this many characters it stops being a status and
|
|
11
|
+
* becomes the answer again, which is what keeps a runaway line from taking the
|
|
12
|
+
* rest of the response with it.
|
|
13
|
+
*/
|
|
14
|
+
export const MAX_STATUS_CHARS = 80;
|
|
15
|
+
/**
|
|
16
|
+
* Status markers.
|
|
17
|
+
*
|
|
18
|
+
* Only the bullet form is recognised: `[·] Searching the web`. That is the
|
|
19
|
+
* shape the system prompt specifies, and it is the only one that can be told
|
|
20
|
+
* apart from content by inspection rather than by guesswork.
|
|
21
|
+
*
|
|
22
|
+
* The bare bracket form, `[Searching the web]`, used to be accepted too. It is
|
|
23
|
+
* not any more, because it is genuinely indistinguishable from the link text a
|
|
24
|
+
* model writes: asked for a landing page, it emitted `[Get Started for Free]`
|
|
25
|
+
* and `[**Start Building**]`, and both were silently deleted from the answer
|
|
26
|
+
* along with the call to action. Nothing else in prose looks like `[·]`.
|
|
27
|
+
*
|
|
28
|
+
* The asymmetry is deliberate. Missing a status leaves a visible `[·] thing`
|
|
29
|
+
* line in the answer, which is ugly. Eating a link label is silent content
|
|
30
|
+
* loss. The ugly failure is the better one.
|
|
31
|
+
*
|
|
32
|
+
* One known blind spot, left alone on purpose: a marker-shaped line inside an
|
|
33
|
+
* ordinary (non-protocol) fenced code block is still eaten as a status. The
|
|
34
|
+
* filter has no notion of a code fence — only a ```json fence is a block it
|
|
35
|
+
* tracks, because buffering a bare fence would stall any code-heavy answer
|
|
36
|
+
* waiting for a close that may never come. Adding fence state to get this
|
|
37
|
+
* right means implementing CommonMark's opening and closing rules for a line
|
|
38
|
+
* shape that essentially never occurs, and getting them subtly wrong stalls
|
|
39
|
+
* the stream. Recorded rather than fixed.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* A marker owns its line when a newline follows it immediately, which is what
|
|
43
|
+
* lets the trailing newline be swallowed so markers do not each prepend a
|
|
44
|
+
* blank line to the answer.
|
|
45
|
+
*/
|
|
46
|
+
const ownsLine = (text, length) => text.indexOf("\n", length) === length;
|
|
47
|
+
/** The marker itself, with the indentation and the space that follow it. */
|
|
48
|
+
const STATUS_MARKER = /^[ \t]*\[\u00b7\][ \t]*/;
|
|
49
|
+
function matchStatusLine(text, atEnd) {
|
|
50
|
+
const marker = STATUS_MARKER.exec(text);
|
|
51
|
+
if (!marker)
|
|
52
|
+
return null;
|
|
53
|
+
const rest = text.slice(marker[0].length);
|
|
54
|
+
const text_ = (value) => value.replace(/^\u00b7+\s*/, "").trim().slice(0, MAX_STATUS_CHARS);
|
|
55
|
+
const newline = rest.indexOf("\n");
|
|
56
|
+
if (newline !== -1) {
|
|
57
|
+
// A line past the cap stops being a status halfway: the rest of it is
|
|
58
|
+
// content, and the newline that ends it is content's, not the status's.
|
|
59
|
+
const capped = newline > MAX_STATUS_CHARS;
|
|
60
|
+
return {
|
|
61
|
+
text: text_(rest.slice(0, newline)),
|
|
62
|
+
end: marker[0].length + (capped ? MAX_STATUS_CHARS : newline + 1),
|
|
63
|
+
complete: true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (!atEnd && rest.length <= MAX_STATUS_CHARS)
|
|
67
|
+
return { text: "", end: 0, complete: false };
|
|
68
|
+
return { text: text_(rest), end: marker[0].length + MAX_STATUS_CHARS, complete: true };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* @param toolNames names from the request's tool list. A block naming
|
|
72
|
+
* anything else is not a call and is shown as ordinary text.
|
|
73
|
+
*/
|
|
74
|
+
export function createAnswerFilter(toolNames = []) {
|
|
75
|
+
let held = "";
|
|
76
|
+
let atLineStart = true;
|
|
77
|
+
let eatNewline = false;
|
|
78
|
+
let planDone = false;
|
|
79
|
+
/**
|
|
80
|
+
* Non-null while inside a protocol block — a ```json fence or a
|
|
81
|
+
* `<tool_calls>` envelope. Everything from the opening delimiter is
|
|
82
|
+
* accumulated here and released in one go, so a block split across arbitrary
|
|
83
|
+
* deltas is never half-shown, and the block's own text is only released if it
|
|
84
|
+
* turns out not to be a tool call.
|
|
85
|
+
*/
|
|
86
|
+
let block = null;
|
|
87
|
+
let blockKind = "fence";
|
|
88
|
+
/**
|
|
89
|
+
* Where the block's closer is.
|
|
90
|
+
*
|
|
91
|
+
* `pending` is the whole block so far, not just the newest delta: a closing
|
|
92
|
+
* delimiter can straddle a push boundary, and one that lands half in the
|
|
93
|
+
* buffer and half in the delta is the normal case for a small-chunked SSE
|
|
94
|
+
* stream. An envelope resumes its scan where the last one stopped, because
|
|
95
|
+
* the accumulated half has already been searched and had no close in it.
|
|
96
|
+
*/
|
|
97
|
+
const findClose = (pending, atEnd) => blockKind === "envelope"
|
|
98
|
+
? matchCloseEnvelope(pending, block?.length ?? 0)
|
|
99
|
+
: matchCloseFence(pending, atEnd);
|
|
100
|
+
/** The calls and rejections a closed block carries. */
|
|
101
|
+
const parseClosed = (finished) => {
|
|
102
|
+
if (blockKind === "envelope")
|
|
103
|
+
return parseToolCallEnvelope(finished, toolNames);
|
|
104
|
+
const result = parseToolCallBlock(finished, toolNames);
|
|
105
|
+
if (result === null)
|
|
106
|
+
return { calls: [], rejected: [] };
|
|
107
|
+
if ("reason" in result)
|
|
108
|
+
return { calls: [], rejected: [result] };
|
|
109
|
+
return { calls: [result], rejected: [] };
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* The one place text is turned into visible answer, statuses and tool calls.
|
|
113
|
+
*
|
|
114
|
+
* push() and flush() both go through here rather than keeping parallel
|
|
115
|
+
* copies of the rules: a fence parked in `held` by the plan-prefix logic is
|
|
116
|
+
* still in the buffer when the stream ends, and a flush path that did not
|
|
117
|
+
* re-run the loop would show it as text instead of a tool call.
|
|
118
|
+
*
|
|
119
|
+
* @param atEnd end of stream — a closing fence is final even without its
|
|
120
|
+
* trailing newline, and a block that never closed is released as text.
|
|
121
|
+
*/
|
|
122
|
+
const run = (delta, atEnd) => {
|
|
123
|
+
let output = "";
|
|
124
|
+
const statuses = [];
|
|
125
|
+
const toolCalls = [];
|
|
126
|
+
const rejected = [];
|
|
127
|
+
let plan = null;
|
|
128
|
+
let buffer = held + delta;
|
|
129
|
+
held = "";
|
|
130
|
+
if (!planDone) {
|
|
131
|
+
const extracted = extractPlanPrefix(buffer);
|
|
132
|
+
if (extracted.plan) {
|
|
133
|
+
planDone = true;
|
|
134
|
+
plan = extracted.plan;
|
|
135
|
+
buffer = extracted.rest;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
while (buffer.length > 0) {
|
|
139
|
+
if (eatNewline) {
|
|
140
|
+
eatNewline = false;
|
|
141
|
+
if (buffer.startsWith("\r\n"))
|
|
142
|
+
buffer = buffer.slice(2);
|
|
143
|
+
else if (buffer.startsWith("\n"))
|
|
144
|
+
buffer = buffer.slice(1);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
// Inside a candidate block: nothing may be shown until it closes.
|
|
148
|
+
if (block !== null) {
|
|
149
|
+
const pending = block + buffer;
|
|
150
|
+
const close = findClose(pending, atEnd);
|
|
151
|
+
if (!close) {
|
|
152
|
+
block = pending;
|
|
153
|
+
buffer = "";
|
|
154
|
+
if (block.length > MAX_TOOL_BLOCK) {
|
|
155
|
+
// Unterminated: give up rather than swallow the rest of the answer.
|
|
156
|
+
output += block;
|
|
157
|
+
block = null;
|
|
158
|
+
atLineStart = false;
|
|
159
|
+
}
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const finished = pending.slice(0, close.index + close.length);
|
|
163
|
+
buffer = pending.slice(close.index + close.length);
|
|
164
|
+
block = null;
|
|
165
|
+
const parsed = parseClosed(finished);
|
|
166
|
+
if (parsed.rejected.length > 0) {
|
|
167
|
+
rejected.push(...parsed.rejected);
|
|
168
|
+
// The block owned its line, so drop the newline that ends it.
|
|
169
|
+
eatNewline = true;
|
|
170
|
+
atLineStart = true;
|
|
171
|
+
}
|
|
172
|
+
else if (parsed.calls.length > 0) {
|
|
173
|
+
toolCalls.push(...parsed.calls);
|
|
174
|
+
// The block owned its line, so drop the newline that ends it.
|
|
175
|
+
//
|
|
176
|
+
// Unconditionally, and that is the point: the newline ending a
|
|
177
|
+
// block is very often the next delta to arrive, and deciding from
|
|
178
|
+
// a buffer that does not hold it yet is how a blank line appeared in
|
|
179
|
+
// the answer only when the stream happened to be finely chunked. The
|
|
180
|
+
// flag is cleared by the next character whatever it is, so setting
|
|
181
|
+
// it for a block followed by anything else costs nothing.
|
|
182
|
+
eatNewline = true;
|
|
183
|
+
atLineStart = true;
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
output += finished;
|
|
187
|
+
atLineStart = ownsLine(finished, finished.length);
|
|
188
|
+
}
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (atLineStart) {
|
|
192
|
+
const fence = matchOpenFence(buffer);
|
|
193
|
+
const envelope = fence ? null : matchOpenEnvelope(buffer);
|
|
194
|
+
const open = fence ?? envelope;
|
|
195
|
+
if (open) {
|
|
196
|
+
blockKind = fence ? "fence" : "envelope";
|
|
197
|
+
block = buffer.slice(0, open.length);
|
|
198
|
+
buffer = buffer.slice(open.length);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
// A delimiter split across deltas ("``" then "`json\n", or "<tool_"
|
|
202
|
+
// then "calls>"): hold the half rather than releasing it as text. A
|
|
203
|
+
// handful of characters, released on the next push once this is
|
|
204
|
+
// clearly not a tool block.
|
|
205
|
+
if (mayBeOpenFence(buffer) || mayBeOpenEnvelope(buffer)) {
|
|
206
|
+
held = buffer;
|
|
207
|
+
buffer = "";
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (!atLineStart) {
|
|
212
|
+
const newline = buffer.indexOf("\n");
|
|
213
|
+
if (newline === -1) {
|
|
214
|
+
output += buffer;
|
|
215
|
+
buffer = "";
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
output += buffer.slice(0, newline + 1);
|
|
219
|
+
buffer = buffer.slice(newline + 1);
|
|
220
|
+
atLineStart = true;
|
|
221
|
+
}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const status = matchStatusLine(buffer, atEnd);
|
|
225
|
+
if (status) {
|
|
226
|
+
if (!status.complete) {
|
|
227
|
+
// More of the line may still be coming. Held for exactly as long as
|
|
228
|
+
// it takes to find out, and no longer: a fragment that is already
|
|
229
|
+
// over the cap stops being a status and falls through to the answer.
|
|
230
|
+
held = buffer;
|
|
231
|
+
buffer = "";
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (status.text)
|
|
235
|
+
statuses.push(status.text);
|
|
236
|
+
buffer = buffer.slice(status.end);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
// The two "wait and see" holds are only legitimate mid-stream. At the
|
|
240
|
+
// end there is no more input coming, so holding would strand the text
|
|
241
|
+
// forever — a plan-shaped prefix must not swallow a tool-call block that
|
|
242
|
+
// follows it.
|
|
243
|
+
if (!atEnd) {
|
|
244
|
+
if (buffer.length < MAX_STATUS_HOLD && MAYBE_STATUS_PREFIX.test(buffer)) {
|
|
245
|
+
held = buffer;
|
|
246
|
+
buffer = "";
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (!planDone && output.length === 0 && !plan && needsMorePlanData(buffer)) {
|
|
250
|
+
held = buffer;
|
|
251
|
+
buffer = "";
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
atLineStart = false;
|
|
256
|
+
}
|
|
257
|
+
// A block still open when the buffer runs dry is only decidable at the end
|
|
258
|
+
// of the stream: its closing fence may be the last thing that arrives, or
|
|
259
|
+
// it may never come. Left alone it would be dropped — neither shown nor
|
|
260
|
+
// run — so it is resolved here.
|
|
261
|
+
if (atEnd && block !== null) {
|
|
262
|
+
const close = findClose(block, true);
|
|
263
|
+
const finished = close ? block.slice(0, close.index + close.length) : block;
|
|
264
|
+
const rest = close ? block.slice(close.index + close.length) : "";
|
|
265
|
+
block = null;
|
|
266
|
+
// Never execute a block that never closed: whatever it parses as, it is
|
|
267
|
+
// the model showing the user JSON, not a protocol message.
|
|
268
|
+
const parsed = close ? parseClosed(finished) : { calls: [], rejected: [] };
|
|
269
|
+
if (parsed.rejected.length > 0) {
|
|
270
|
+
rejected.push(...parsed.rejected);
|
|
271
|
+
// The block is the protocol, not content: it must not also be shown.
|
|
272
|
+
output += rest;
|
|
273
|
+
}
|
|
274
|
+
else if (parsed.calls.length > 0) {
|
|
275
|
+
toolCalls.push(...parsed.calls);
|
|
276
|
+
output += rest;
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
output += finished + rest;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return { text: output, statuses, plan, toolCalls, rejected };
|
|
283
|
+
};
|
|
284
|
+
return {
|
|
285
|
+
push(delta) {
|
|
286
|
+
return run(delta, false);
|
|
287
|
+
},
|
|
288
|
+
flush() {
|
|
289
|
+
atLineStart = true;
|
|
290
|
+
eatNewline = false;
|
|
291
|
+
const result = run("", true);
|
|
292
|
+
// `run` may have parked a final fragment (a half fence, a status
|
|
293
|
+
// prefix). Nothing more is coming, so release it as answer text.
|
|
294
|
+
if (held) {
|
|
295
|
+
result.text += held;
|
|
296
|
+
held = "";
|
|
297
|
+
}
|
|
298
|
+
return result;
|
|
299
|
+
},
|
|
300
|
+
reset() {
|
|
301
|
+
held = "";
|
|
302
|
+
atLineStart = true;
|
|
303
|
+
eatNewline = false;
|
|
304
|
+
planDone = false;
|
|
305
|
+
block = null;
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { ChatMessage, HarnessObserver, ModelClient, RoundResult, RunResult, ToolHost, ToolRoundPolicy } from "./types.js";
|
|
2
|
+
export type HarnessConfig = {
|
|
3
|
+
maxRetries: number;
|
|
4
|
+
initialBackoffMs: number;
|
|
5
|
+
maxBackoffMs: number;
|
|
6
|
+
idleTimeoutMs: number;
|
|
7
|
+
/**
|
|
8
|
+
* Hard cap on tool rounds. Defaults to unbounded — a turn that needs to
|
|
9
|
+
* research for hours should be allowed to. A caller that pays per round
|
|
10
|
+
* should set it: reaching it ends the turn with `needs_user` and everything
|
|
11
|
+
* the rounds already produced, never a thrown error.
|
|
12
|
+
*/
|
|
13
|
+
maxToolRounds: number;
|
|
14
|
+
/**
|
|
15
|
+
* Consecutive rounds in which *every* tool call failed. Reaching this stops
|
|
16
|
+
* the turn and reports `needs_user` rather than throwing, so the caller can
|
|
17
|
+
* ask whether to continue. A single bad call is normal and never counts.
|
|
18
|
+
*/
|
|
19
|
+
maxConsecutiveToolErrors: number;
|
|
20
|
+
recoverEmptyAnswer: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Whether one continuation is attempted when the endpoint cuts an answer
|
|
23
|
+
* off at its output cap. Once, not repeatedly: a model that is too long
|
|
24
|
+
* for the cap is too long for it twice, and the second attempt costs a
|
|
25
|
+
* full request to learn the same thing.
|
|
26
|
+
*/
|
|
27
|
+
recoverTruncatedAnswer: boolean;
|
|
28
|
+
};
|
|
29
|
+
export declare const DEFAULT_HARNESS_CONFIG: HarnessConfig;
|
|
30
|
+
/** Marker appended to an answer a cancel cut short. */
|
|
31
|
+
export declare const INTERRUPT_MARKER = "[interrupted]";
|
|
32
|
+
/**
|
|
33
|
+
* `text` with the interrupt marker appended, or the marker alone when the
|
|
34
|
+
* cancel landed before anything streamed. The marker is what tells a resumed
|
|
35
|
+
* session that the text above it is a fragment the user stopped, not a
|
|
36
|
+
* completed answer the model should stand behind.
|
|
37
|
+
*/
|
|
38
|
+
export declare function withInterruptMarker(text: string): string;
|
|
39
|
+
/** Marker appended to an answer a failed turn cut short. */
|
|
40
|
+
export declare const FAILED_MARKER = "[failed]";
|
|
41
|
+
/**
|
|
42
|
+
* `text` with the failure marker appended, or the marker alone when
|
|
43
|
+
* the failure landed before anything streamed. The marker is what
|
|
44
|
+
* tells a resumed session that the text above it is a fragment of a
|
|
45
|
+
* failed attempt, not a completed answer the model stands behind.
|
|
46
|
+
*/
|
|
47
|
+
export declare function withFailedMarker(text: string): string;
|
|
48
|
+
/** Marker appended to an answer the endpoint cut off at its output cap. */
|
|
49
|
+
export declare const TRUNCATED_MARKER = "[cut off at the output limit]";
|
|
50
|
+
/**
|
|
51
|
+
* `text` with the truncation marker appended, or the marker alone when
|
|
52
|
+
* the answer never started.
|
|
53
|
+
*
|
|
54
|
+
* The third of the three markers a terminal state leaves in the
|
|
55
|
+
* transcript, and the only one whose host flag was optional: a cancel and a
|
|
56
|
+
* failure each had to hand the transcript back with the marker on it, while
|
|
57
|
+
* a cut-off answer was saved as the plain words the model had written so
|
|
58
|
+
* far. It is not a completed reply and the transcript is what the next turn
|
|
59
|
+
* reads first, so the marker goes here rather than in whichever host
|
|
60
|
+
* remembered to render `RunResult.truncated`.
|
|
61
|
+
*/
|
|
62
|
+
export declare function withTruncationMarker(text: string): string;
|
|
63
|
+
/**
|
|
64
|
+
* What a tool call that a cancel cut short is answered with.
|
|
65
|
+
*
|
|
66
|
+
* It is the model's half of the record: the call happened, the command may
|
|
67
|
+
* have done anything, and nobody knows what it produced. Exported because
|
|
68
|
+
* the same sentence is now also the event the host shows the user, and a
|
|
69
|
+
* host that renders its own copy must not be able to word it differently
|
|
70
|
+
* from the one the next turn's model reads.
|
|
71
|
+
*/
|
|
72
|
+
export declare 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.";
|
|
73
|
+
/**
|
|
74
|
+
* Thrown when the caller aborted a turn. Carries the transcript as the turn
|
|
75
|
+
* left it — the input, every finished round, answers for calls the cancel cut
|
|
76
|
+
* short, and the partial answer marked interrupted — so a host can persist an
|
|
77
|
+
* exchange the user watched happen. Without it, a cancel rewinds the
|
|
78
|
+
* conversation to before the turn: the work vanishes from the saved session
|
|
79
|
+
* and a resume replays it from zero.
|
|
80
|
+
*
|
|
81
|
+
* The transcript is API-valid by construction (see `abortTurn`), so a host can
|
|
82
|
+
* save it as-is and the next request will be accepted.
|
|
83
|
+
*/
|
|
84
|
+
export declare class AbortedTurnError extends Error {
|
|
85
|
+
readonly messages: ChatMessage[];
|
|
86
|
+
/** What the cancelled attempt had streamed, without the marker. */
|
|
87
|
+
readonly partialText: string;
|
|
88
|
+
constructor(messages: ChatMessage[],
|
|
89
|
+
/** What the cancelled attempt had streamed, without the marker. */
|
|
90
|
+
partialText: string);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Thrown when a turn failed for any reason other than a cancel: the
|
|
94
|
+
* endpoint refused the request, the stream died mid-answer, a retry
|
|
95
|
+
* budget ran out. Carries the transcript as the turn left it — the
|
|
96
|
+
* input, every finished round, and the partial answer marked failed
|
|
97
|
+
* — so a host can persist an exchange whose later rounds really
|
|
98
|
+
* happened. Without it, a mid-turn failure rewinds the conversation
|
|
99
|
+
* to before the turn: tool calls that ran and files that changed
|
|
100
|
+
* vanish from the saved session, and a resume replays them from zero
|
|
101
|
+
* as though they never happened. Claude Code yields the error into
|
|
102
|
+
* the conversation instead of rewinding, and so does this.
|
|
103
|
+
*
|
|
104
|
+
* The transcript is API-valid by construction (see `failedTurn`), so
|
|
105
|
+
* a host can save it as-is and the next request will be accepted.
|
|
106
|
+
*/
|
|
107
|
+
export declare class FailedTurnError extends Error {
|
|
108
|
+
readonly messages: ChatMessage[];
|
|
109
|
+
/** What the failed attempt had streamed, without the marker. */
|
|
110
|
+
readonly partialText: string;
|
|
111
|
+
constructor(messages: ChatMessage[],
|
|
112
|
+
/** What the failed attempt had streamed, without the marker. */
|
|
113
|
+
partialText: string, cause?: unknown);
|
|
114
|
+
}
|
|
115
|
+
export declare function runModelRound(model: ModelClient, messages: ChatMessage[], tools: Parameters<ModelClient["stream"]>[1], signal: AbortSignal, observer?: HarnessObserver, config?: HarnessConfig): Promise<RoundResult>;
|
|
116
|
+
export declare function runAgentTurn(options: {
|
|
117
|
+
model: ModelClient;
|
|
118
|
+
messages: ChatMessage[];
|
|
119
|
+
input: string;
|
|
120
|
+
toolHost?: ToolHost;
|
|
121
|
+
signal: AbortSignal;
|
|
122
|
+
observer?: HarnessObserver;
|
|
123
|
+
config?: HarnessConfig;
|
|
124
|
+
policy?: ToolRoundPolicy;
|
|
125
|
+
}): Promise<RunResult>;
|
|
126
|
+
/**
|
|
127
|
+
* Jitter added to every wait, as a fraction of the delay itself.
|
|
128
|
+
*
|
|
129
|
+
* Without it, every client that hit a rate limit in the same second comes back
|
|
130
|
+
* in the same second, and the limit that just rejected them rejects them
|
|
131
|
+
* again. It is added and never subtracted: a wait shorter than the backoff it
|
|
132
|
+
* is jittering is not a backoff.
|
|
133
|
+
*/
|
|
134
|
+
export declare const RETRY_JITTER_RATIO = 0.25;
|
|
135
|
+
/**
|
|
136
|
+
* How long to wait before the next attempt.
|
|
137
|
+
*
|
|
138
|
+
* Two inputs, and the second can only make the wait longer:
|
|
139
|
+
*
|
|
140
|
+
* - exponential backoff, capped, which is what a dropped connection or a
|
|
141
|
+
* 5xx wants;
|
|
142
|
+
* - `Retry-After`, which is the server stating a rate limit window. A 429
|
|
143
|
+
* that says "come back in 30" and is retried after one, two and four
|
|
144
|
+
* seconds has honoured nothing: every attempt lands inside the window and
|
|
145
|
+
* the turn fails having spent seven seconds confirming what it was told.
|
|
146
|
+
*
|
|
147
|
+
* A wait beyond `MAX_HONOURED_RETRY_AFTER_MS` never reaches here — the
|
|
148
|
+
* transport has already made that failure non-retriable rather than have the
|
|
149
|
+
* turn vanish for a number of minutes a user is waiting on.
|
|
150
|
+
*
|
|
151
|
+
* Exported so the arithmetic can be tested without sleeping through it.
|
|
152
|
+
*/
|
|
153
|
+
export declare function retryDelay(attempt: number, config: HarnessConfig, error?: unknown): number;
|
|
154
|
+
//# sourceMappingURL=harness.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"harness.d.ts","sourceRoot":"","sources":["../src/harness.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,WAAW,EACX,eAAe,EACf,WAAW,EAGX,WAAW,EACX,SAAS,EAET,QAAQ,EACR,eAAe,EAGhB,MAAM,YAAY,CAAC;AAEpB,MAAM,MAAM,aAAa,GAAG;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,aAAa,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,wBAAwB,EAAE,MAAM,CAAC;IACjC,kBAAkB,EAAE,OAAO,CAAC;IAC5B;;;;;OAKG;IACH,sBAAsB,EAAE,OAAO,CAAC;CACjC,CAAC;AAEF,eAAO,MAAM,sBAAsB,EAAE,aASpC,CAAC;AAEF,uDAAuD;AACvD,eAAO,MAAM,gBAAgB,kBAAkB,CAAC;AAEhD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAGxD;AAED,4DAA4D;AAC5D,eAAO,MAAM,aAAa,aAAa,CAAC;AAExC;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAGrD;AAED,2EAA2E;AAC3E,eAAO,MAAM,gBAAgB,kCAAkC,CAAC;AAEhE;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAGzD;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,qBAAqB,wHACqF,CAAC;AAExH;;;;;;;;;;GAUG;AACH,qBAAa,gBAAiB,SAAQ,KAAK;IAEvC,QAAQ,CAAC,QAAQ,EAAE,WAAW,EAAE;IAChC,mEAAmE;IACnE,QAAQ,CAAC,WAAW,EAAE,MAAM;gBAFnB,QAAQ,EAAE,WAAW,EAAE;IAChC,mEAAmE;IAC1D,WAAW,EAAE,MAAM;CAK/B;AAED;;;;;;;;;;;;;;GAcG;AACH,qBAAa,eAAgB,SAAQ,KAAK;IAEtC,QAAQ,CAAC,QAAQ,EAAE,WAAW,EAAE;IAChC,gEAAgE;IAChE,QAAQ,CAAC,WAAW,EAAE,MAAM;gBAFnB,QAAQ,EAAE,WAAW,EAAE;IAChC,gEAAgE;IACvD,WAAW,EAAE,MAAM,EAC5B,KAAK,CAAC,EAAE,OAAO;CAKlB;AAED,wBAAsB,aAAa,CACjC,KAAK,EAAE,WAAW,EAClB,QAAQ,EAAE,WAAW,EAAE,EACvB,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,EAC3C,MAAM,EAAE,WAAW,EACnB,QAAQ,CAAC,EAAE,eAAe,EAC1B,MAAM,GAAE,aAAsC,GAC7C,OAAO,CAAC,WAAW,CAAC,CAwBtB;AA0KD,wBAAsB,YAAY,CAAC,OAAO,EAAE;IAC1C,KAAK,EAAE,WAAW,CAAC;IACnB,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,MAAM,EAAE,WAAW,CAAC;IACpB,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B,GAAG,OAAO,CAAC,SAAS,CAAC,CAqRrB;AAqHD;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,KAAK,CAAC,EAAE,OAAO,GAAG,MAAM,CAI1F"}
|