@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,377 @@
1
+ /** A thrown value's message, for rejection reasons. */
2
+ function errorMessage(error) {
3
+ return error instanceof Error ? error.message : String(error);
4
+ }
5
+ /**
6
+ * The explicit tool-call protocol.
7
+ *
8
+ * When a model cannot use a native `tool_calls` transport it still has to
9
+ * call tools, and the only reliable way to do that is to say so in a shape
10
+ * that cannot be mistaken for prose. A model emits:
11
+ *
12
+ * ```json
13
+ * {"type": "tool_call", "name": "set_title", "arguments": {"title": "…"}}
14
+ * ```
15
+ *
16
+ * and the answer filter turns that into a real tool call, so the block never
17
+ * reaches the message bubble.
18
+ *
19
+ * A second envelope is accepted, the one this project's own chat product
20
+ * teaches:
21
+ *
22
+ * <tool_calls>
23
+ * {"type": "tool_call", "name": "set_title", "arguments": {"title": "…"}}
24
+ * </tool_calls>
25
+ *
26
+ * It shows up when a request is proxied through an endpoint that injects its
27
+ * own system prompt, so the model was told about *that* protocol instead of
28
+ * this one. A named tag is as unambiguous as a fence, and unlike a call-shaped
29
+ * phrase in prose it can never be an explanation.
30
+ *
31
+ * This is deliberately the *opposite* of sniffing for call-shaped text in
32
+ * prose. `set_title(title: "x")` in a sentence is ambiguous —
33
+ * it might be an explanation — and any heuristic that fires on it will
34
+ * eventually fire on an explanation. A fenced JSON object with an explicit
35
+ * `type` is unambiguous, and a block that is not one is released verbatim, so
36
+ * ordinary JSON examples in answers are unaffected.
37
+ *
38
+ * Only a ```json fence is a candidate. A bare ``` fence is nearly always a
39
+ * real code block, and buffering those would stall the stream of any
40
+ * code-heavy answer waiting for a close that may be far away.
41
+ */
42
+ /** Info strings that mark a block as a tool-call candidate. */
43
+ const OPEN_FENCE = /^```(?:json|jsonc|json5)[ \t]*\r?\n/i;
44
+ const CLOSE_FENCE = /^```[ \t]*\r?(\n|$)/gm;
45
+ /** Every opening fence we would accept, for prefix checks. */
46
+ const OPEN_CANDIDATES = ["```json", "```jsonc", "```json5"];
47
+ const OPEN_ENVELOPE = /^<tool_calls>[ \t]*\r?\n?/i;
48
+ const CLOSE_ENVELOPE = /<\/tool_calls>/gi;
49
+ /** Longest run of the close tag that can sit in the previous buffer. */
50
+ const CLOSE_ENVELOPE_PREFIX = "</tool_calls>".length - 1;
51
+ /** Every opening envelope we would accept, for prefix checks. */
52
+ const OPEN_ENVELOPE_CANDIDATES = ["<tool_calls>"];
53
+ /**
54
+ * Give up on a block that never closes. An unterminated fence must not
55
+ * swallow the rest of the answer, and no legitimate tool call is this long.
56
+ */
57
+ export const MAX_TOOL_BLOCK = 8_000;
58
+ let callSeq = 0;
59
+ /** True when `line` opens a tool-call candidate. */
60
+ export function matchOpenFence(text) {
61
+ const match = OPEN_FENCE.exec(text);
62
+ return match ? { length: match[0].length } : null;
63
+ }
64
+ /**
65
+ * True when `text` could still grow into an opening fence but is not one yet.
66
+ *
67
+ * A fence routinely straddles a delta boundary ("``" + "`json\n"), and
68
+ * deciding per-delta would release the first half as text. This is the hold
69
+ * that makes the protocol recognisable no matter how the stream is chunked.
70
+ */
71
+ export function mayBeOpenFence(text) {
72
+ const lower = text.toLowerCase();
73
+ return OPEN_CANDIDATES.some((candidate) => lower.length <= candidate.length && candidate.startsWith(lower));
74
+ }
75
+ /** True when `line` opens a `<tool_calls>` envelope. */
76
+ export function matchOpenEnvelope(text) {
77
+ const match = OPEN_ENVELOPE.exec(text);
78
+ return match ? { length: match[0].length } : null;
79
+ }
80
+ /**
81
+ * The `<tool_calls>` twin of `mayBeOpenFence`.
82
+ *
83
+ * SSE deltas are small enough that `<tool_` on one and `calls>` on the next is
84
+ * routine, and releasing the first half as text would leave `calls>` orphaned
85
+ * in the answer. Same hold, same reason.
86
+ */
87
+ export function mayBeOpenEnvelope(text) {
88
+ const lower = text.toLowerCase();
89
+ return OPEN_ENVELOPE_CANDIDATES.some((candidate) => lower.length <= candidate.length && candidate.startsWith(lower));
90
+ }
91
+ /**
92
+ * Locate the closing `</tool_calls>`, resuming at `fromIndex`.
93
+ *
94
+ * `fromIndex` is the length of the part already buffered, and the scan starts
95
+ * `CLOSE_ENVELOPE_PREFIX` characters earlier on purpose: a close can straddle
96
+ * the boundary between the buffer and the new delta, and a delta boundary
97
+ * landing mid-tag is the *normal* case for a small-chunked SSE stream. Resuming
98
+ * exactly at `fromIndex` misses it forever, and the block then grows until it
99
+ * is released as text. Resuming from 0 would be correct too, just quadratic on
100
+ * a long unterminated block.
101
+ */
102
+ export function matchCloseEnvelope(text, fromIndex = 0) {
103
+ const re = new RegExp(CLOSE_ENVELOPE.source, CLOSE_ENVELOPE.flags);
104
+ re.lastIndex = Math.max(0, Math.min(fromIndex, text.length) - CLOSE_ENVELOPE_PREFIX);
105
+ const match = re.exec(text);
106
+ if (!match || match.index === undefined)
107
+ return null;
108
+ return { index: match.index, length: match[0].length };
109
+ }
110
+ /**
111
+ * Locate the closing fence.
112
+ *
113
+ * Every fence is scanned, not just the first: the text handed in usually still
114
+ * contains the opening fence, which must be skipped rather than mistaken for
115
+ * the close. Mid-stream a close only counts once its newline has arrived, so a
116
+ * buffer ending in "``" is left pending; at end of stream (`atEnd`) a fence
117
+ * with no trailing newline is final and does count.
118
+ *
119
+ * Callers pass the whole buffered block, not just the newest delta: a fence can
120
+ * straddle the two, and a close hidden in the accumulated half is as real as
121
+ * one in the delta.
122
+ */
123
+ export function matchCloseFence(text, atEnd = false) {
124
+ const re = new RegExp(CLOSE_FENCE.source, CLOSE_FENCE.flags);
125
+ let match;
126
+ while ((match = re.exec(text)) !== null) {
127
+ if (match.index === undefined)
128
+ return null;
129
+ const closed = match[0].endsWith("\n");
130
+ if (closed || atEnd)
131
+ return { index: match.index, length: match[0].length };
132
+ // A fence with no newline yet: keep looking, and do not let a zero-width
133
+ // match spin the loop.
134
+ if (match[0].length === 0)
135
+ re.lastIndex++;
136
+ }
137
+ return null;
138
+ }
139
+ /** The JSON body of a complete block, without its fences. */
140
+ export function blockBody(block) {
141
+ return block.replace(OPEN_FENCE, "").replace(CLOSE_FENCE, "");
142
+ }
143
+ /** Sequence for synthetic call ids, so a rejected block still pairs. */
144
+ let rejectSeq = 0;
145
+ /**
146
+ * Build the rejection record for a block whose JSON parsed but named a tool
147
+ * the request did not offer. The reason quotes the offered names so the model
148
+ * can pick a real one instead of guessing again.
149
+ */
150
+ export function rejectUnknownTool(name, offered) {
151
+ const call = {
152
+ id: `block_rejected_${++rejectSeq}`,
153
+ type: "function",
154
+ function: { name, arguments: "{}" },
155
+ };
156
+ const list = offered.length > 0 ? offered.join(", ") : "none";
157
+ return {
158
+ name,
159
+ call,
160
+ reason: `Rejected: your tool-call block named "${name}", but that tool was not ` +
161
+ `offered on this request (offered: ${list}). Use one of the offered ` +
162
+ `tools, or answer in plain text.`,
163
+ };
164
+ }
165
+ /**
166
+ * Recover the intended tool name from a block whose JSON did not parse.
167
+ *
168
+ * The rejection transcript line reads "✗ <name>" — showing "unknown" when the
169
+ * body plainly says which tool was meant is a worse error message than the
170
+ * model deserves, and a regex is safe here: the name is read out of the raw
171
+ * body only to *display* it, never to execute it, and the value is capped and
172
+ * stripped to what a name can look like.
173
+ */
174
+ function recoverName(body) {
175
+ const match = /"name"\s*:\s*"([^"\\]{1,64})"/.exec(body);
176
+ return match?.[1] ?? "unknown";
177
+ }
178
+ /**
179
+ * Build the rejection record for a block that looked like a tool call but
180
+ * whose arguments were not valid JSON. The common cause is a raw newline or
181
+ * tab inside a string literal — the model formatted the command like shell
182
+ * output instead of escaping it — so the reason names that repair first. A
183
+ * body with no usable "name" gets the name-specific hint instead; the
184
+ * generic newline hint would be pointing at a fault it does not have.
185
+ */
186
+ export function rejectMalformedArguments(detail, rawBody) {
187
+ const name = rawBody !== undefined ? recoverName(rawBody) : "unknown";
188
+ const call = {
189
+ id: `block_rejected_${++rejectSeq}`,
190
+ type: "function",
191
+ function: { name, arguments: "{}" },
192
+ };
193
+ if (name === "unknown") {
194
+ return {
195
+ name,
196
+ call,
197
+ reason: `Rejected: your tool-call block was not valid JSON (${detail}). ` +
198
+ `The block needs {"type": "tool_call", "name": "<offered tool>", "arguments": {…}} — ` +
199
+ `a string "name" naming one of the offered tools. Re-emit it as valid JSON.`,
200
+ };
201
+ }
202
+ return {
203
+ name,
204
+ call,
205
+ reason: `Rejected: your "${name}" tool-call block was not valid JSON (${detail}). ` +
206
+ `Most often a raw newline inside a string value — JSON strings cannot ` +
207
+ `contain literal line breaks; use \\n. Re-emit the block with valid JSON ` +
208
+ `on a single line.`,
209
+ };
210
+ }
211
+ function isPlainObject(value) {
212
+ return typeof value === "object" && value !== null && !Array.isArray(value);
213
+ }
214
+ /**
215
+ * A body that opens with the protocol's own shape. A body that fails to parse
216
+ * is ambiguous — a protocol block the model got wrong, or a JSON code example
217
+ * holding prose — and only this prefix, verifiable by inspection, turns the
218
+ * first into a rejection. Anything else is ordinary text.
219
+ */
220
+ const PROTOCOL_INTENT = /^\s*\{\s*"type"\s*:\s*"tool_call"/;
221
+ /**
222
+ * Parse a complete fenced block into a tool call.
223
+ *
224
+ * Returns null — meaning "this is ordinary answer text" — unless the block is
225
+ * a JSON object that says `type: "tool_call"` and carries a string `name`.
226
+ * The `type` check is what stops every other JSON example in every answer
227
+ * from being treated as protocol.
228
+ *
229
+ * The result says what happened: a `call` when the name is one the request
230
+ * actually offered, or a `rejected` record when it is not — including when
231
+ * the body is protocol-shaped but not valid JSON, the field case being a raw
232
+ * newline inside a string value. Rejected is not the same as text — the model
233
+ * addressed the protocol; the harness answers in the same protocol (see
234
+ * RejectedBlock).
235
+ */
236
+ export function parseToolCallBlock(block, toolNames) {
237
+ if (toolNames.length === 0)
238
+ return null;
239
+ let value;
240
+ try {
241
+ value = JSON.parse(blockBody(block));
242
+ }
243
+ catch (error) {
244
+ if (PROTOCOL_INTENT.test(blockBody(block))) {
245
+ return rejectMalformedArguments(errorMessage(error), blockBody(block));
246
+ }
247
+ return null;
248
+ }
249
+ return toToolCall(value, toolNames);
250
+ }
251
+ /** One protocol object → one call, a rejection, or null when it is not a call. */
252
+ function toToolCall(value, toolNames) {
253
+ if (!isPlainObject(value))
254
+ return null;
255
+ if (value.type !== "tool_call")
256
+ return null;
257
+ if (typeof value.name !== "string") {
258
+ return rejectMalformedArguments('the object has no string "name" field — add "name": "<offered tool>"');
259
+ }
260
+ if (!toolNames.includes(value.name))
261
+ return rejectUnknownTool(value.name, toolNames);
262
+ const args = value.arguments;
263
+ if (args !== undefined && !isPlainObject(args)) {
264
+ return rejectMalformedArguments("\"arguments\" is not a JSON object");
265
+ }
266
+ return {
267
+ id: `block_call_${++callSeq}`,
268
+ type: "function",
269
+ function: {
270
+ name: value.name,
271
+ arguments: JSON.stringify(args ?? {}),
272
+ },
273
+ };
274
+ }
275
+ /**
276
+ * The `<tool_calls>` body: every top-level `{…}` object, in order.
277
+ *
278
+ * Braces inside strings are ignored, so a title containing `}` cannot end the
279
+ * scan early. Anything that does not close is not an object and not a call.
280
+ */
281
+ export function scanJsonObjects(text) {
282
+ const found = [];
283
+ let depth = 0;
284
+ let start = -1;
285
+ let inString = false;
286
+ let escaped = false;
287
+ for (let i = 0; i < text.length; i++) {
288
+ const ch = text[i];
289
+ if (inString) {
290
+ if (escaped)
291
+ escaped = false;
292
+ else if (ch === "\\")
293
+ escaped = true;
294
+ else if (ch === '"')
295
+ inString = false;
296
+ continue;
297
+ }
298
+ if (ch === '"') {
299
+ inString = true;
300
+ continue;
301
+ }
302
+ if (ch === "{") {
303
+ if (depth === 0)
304
+ start = i;
305
+ depth++;
306
+ }
307
+ else if (ch === "}") {
308
+ if (depth === 0)
309
+ continue; // a stray close; not ours
310
+ depth--;
311
+ if (depth === 0 && start >= 0) {
312
+ found.push(text.slice(start, i + 1));
313
+ start = -1;
314
+ }
315
+ }
316
+ }
317
+ return found;
318
+ }
319
+ /**
320
+ * Parse a closed `<tool_calls>` envelope into the calls it carries.
321
+ *
322
+ * All-or-nothing, like the fence: an envelope is protocol only if *every*
323
+ * object in it is a call for a tool this request actually offered. One
324
+ * `set_conversation_title` in a `<tool_calls>` envelope is not a partial tool
325
+ * round to half-execute — it is a different assistant's protocol, and
326
+ * executing half of it would hide the mismatch. The first non-call object in
327
+ * the envelope therefore poisons the whole envelope: every call-shaped object
328
+ * before it becomes a rejection (the model addressed the protocol and is told
329
+ * so next round), and none of them executes.
330
+ */
331
+ export function parseToolCallEnvelope(block, toolNames) {
332
+ if (toolNames.length === 0)
333
+ return { calls: [], rejected: [] };
334
+ const body = block.replace(OPEN_ENVELOPE, "").replace(CLOSE_ENVELOPE, "");
335
+ const candidates = scanJsonObjects(body);
336
+ if (candidates.length === 0)
337
+ return { calls: [], rejected: [] };
338
+ const parsed = [];
339
+ for (const candidate of candidates) {
340
+ let value;
341
+ try {
342
+ value = JSON.parse(candidate);
343
+ }
344
+ catch {
345
+ // A body that is not valid JSON is not protocol at all — release it as
346
+ // text, exactly like a fence whose body does not parse. Nothing in an
347
+ // unparseable body names a tool, so there is nothing to reject either.
348
+ return { calls: [], rejected: [] };
349
+ }
350
+ const call = toToolCall(value, toolNames);
351
+ if (call === null) {
352
+ // An object with the wrong `type` — not protocol. The all-or-nothing
353
+ // rule releases the whole envelope as text.
354
+ return { calls: [], rejected: [] };
355
+ }
356
+ parsed.push("reason" in call ? { kind: "reject", block: call } : { kind: "call", call });
357
+ }
358
+ const poison = parsed.find((item) => item.kind === "reject");
359
+ if (!poison)
360
+ return { calls: parsed.map((item) => item.call), rejected: [] };
361
+ // All-or-nothing: the first refusal poisons every call in the envelope. A
362
+ // well-formed call beside a refused one did not run either, and leaving it
363
+ // silent would hide the refusal — the model would re-emit the whole
364
+ // envelope next round believing half of it succeeded.
365
+ const refused = poison.block;
366
+ const list = toolNames.length > 0 ? toolNames.join(", ") : "none";
367
+ const rejected = parsed.map((item) => item.kind === "reject"
368
+ ? item.block
369
+ : {
370
+ name: item.call.function.name,
371
+ call: item.call,
372
+ reason: `Rejected: the <tool_calls> envelope also carried "${refused.name}", which was not offered ` +
373
+ `(offered: ${list}). The envelope is all-or-nothing, so your "${item.call.function.name}" ` +
374
+ `call did not run either. Use only the offered tools, or answer in plain text.`,
375
+ });
376
+ return { calls: [], rejected };
377
+ }
@@ -0,0 +1,31 @@
1
+ import type { ToolHost } from "./types.js";
2
+ /**
3
+ * Combine tool hosts, so a caller can offer one model several sets of tools.
4
+ *
5
+ * The harness has a single `ToolHost` port, but a real host is often several:
6
+ * the CLI's own file and shell tools, plus whatever an MCP server exposes.
7
+ * These two combinators let those be assembled without any of them knowing
8
+ * about the others. They are pure and transport-neutral — no spawning, no
9
+ * network, no JSON-RPC — so they live beside the port they operate on rather
10
+ * than in any one host's implementation.
11
+ */
12
+ /**
13
+ * A host whose every tool name carries `prefix`, so it cannot collide with
14
+ * another host's.
15
+ *
16
+ * The prefix is stripped again on the way in, so the wrapped host is unchanged
17
+ * and never learns it was namespaced. A call that does not carry the prefix is
18
+ * refused here rather than forwarded: forwarding it would run some other
19
+ * namespace's tool under this one's name.
20
+ */
21
+ export declare function namespacedToolHost(prefix: string, host: ToolHost): ToolHost;
22
+ /**
23
+ * Merge hosts into one. Definitions concatenate in order; a call is routed to
24
+ * the first host that offers a tool of that name.
25
+ *
26
+ * Two hosts offering the same tool name is the caller's problem — namespace one
27
+ * with `namespacedToolHost` first — because taking the first silently makes the
28
+ * second host's tool of that name unreachable, with nothing anywhere to say so.
29
+ */
30
+ export declare function compositeToolHost(hosts: ToolHost[]): ToolHost;
31
+ //# sourceMappingURL=tool-host.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-host.d.ts","sourceRoot":"","sources":["../src/tool-host.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAY,QAAQ,EAAE,MAAM,YAAY,CAAC;AAErD;;;;;;;;;GASG;AAEH;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,GAAG,QAAQ,CAiB3E;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,QAAQ,EAAE,GAAG,QAAQ,CAc7D"}
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Combine tool hosts, so a caller can offer one model several sets of tools.
3
+ *
4
+ * The harness has a single `ToolHost` port, but a real host is often several:
5
+ * the CLI's own file and shell tools, plus whatever an MCP server exposes.
6
+ * These two combinators let those be assembled without any of them knowing
7
+ * about the others. They are pure and transport-neutral — no spawning, no
8
+ * network, no JSON-RPC — so they live beside the port they operate on rather
9
+ * than in any one host's implementation.
10
+ */
11
+ /**
12
+ * A host whose every tool name carries `prefix`, so it cannot collide with
13
+ * another host's.
14
+ *
15
+ * The prefix is stripped again on the way in, so the wrapped host is unchanged
16
+ * and never learns it was namespaced. A call that does not carry the prefix is
17
+ * refused here rather than forwarded: forwarding it would run some other
18
+ * namespace's tool under this one's name.
19
+ */
20
+ export function namespacedToolHost(prefix, host) {
21
+ return {
22
+ definitions: () => host.definitions().map((definition) => ({
23
+ ...definition,
24
+ function: { ...definition.function, name: `${prefix}${definition.function.name}` },
25
+ })),
26
+ async execute(call, signal) {
27
+ if (!call.function.name.startsWith(prefix)) {
28
+ throw new Error(`tool "${call.function.name}" is not in the "${prefix}" namespace`);
29
+ }
30
+ return host.execute({ ...call, function: { ...call.function, name: call.function.name.slice(prefix.length) } }, signal);
31
+ },
32
+ };
33
+ }
34
+ /**
35
+ * Merge hosts into one. Definitions concatenate in order; a call is routed to
36
+ * the first host that offers a tool of that name.
37
+ *
38
+ * Two hosts offering the same tool name is the caller's problem — namespace one
39
+ * with `namespacedToolHost` first — because taking the first silently makes the
40
+ * second host's tool of that name unreachable, with nothing anywhere to say so.
41
+ */
42
+ export function compositeToolHost(hosts) {
43
+ return {
44
+ definitions: () => hosts.flatMap((host) => host.definitions()),
45
+ async execute(call, signal) {
46
+ for (const host of hosts) {
47
+ const offers = host
48
+ .definitions()
49
+ .some((definition) => definition.function.name === call.function.name);
50
+ if (offers)
51
+ return host.execute(call, signal);
52
+ }
53
+ const known = hosts.flatMap((host) => host.definitions().map((d) => d.function.name));
54
+ throw new Error(`unknown tool "${call.function.name}". Available: ${known.join(", ")}`);
55
+ },
56
+ };
57
+ }