@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,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
|
+
}
|