@jxsuite/ai 0.37.1 → 0.38.0
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/README.md +46 -13
- package/package.json +6 -4
- package/src/chat-state.ts +100 -10
- package/src/core-types.ts +97 -0
- package/src/gateway/chat.ts +217 -0
- package/src/gateway/index.ts +26 -0
- package/src/gateway/models.ts +22 -0
- package/src/gateway/normalize.ts +302 -0
- package/src/gateway/problem.ts +80 -0
- package/src/gateway/sse.ts +42 -0
- package/src/gateway/types.ts +80 -0
- package/src/gateway/upstream-error.ts +54 -0
- package/src/index.ts +3 -0
- package/src/streaming-client.ts +190 -57
- package/src/tools.ts +153 -14
package/src/streaming-client.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* and concrete implementations for OpenAI and Anthropic. Designed upfront so switching
|
|
6
6
|
* providers is a new implementation, not a refactor.
|
|
7
7
|
*
|
|
8
|
-
* The union and its
|
|
8
|
+
* The union and its eight members are exported because they are the contract a third-party Studio
|
|
9
9
|
* backend implements for the `ai/chat` route, not an internal detail — see the docs page below.
|
|
10
10
|
*
|
|
11
11
|
* @license MIT
|
|
@@ -21,6 +21,7 @@ export type StreamEvent =
|
|
|
21
21
|
| StreamToolCallStartEvent
|
|
22
22
|
| StreamToolCallDeltaEvent
|
|
23
23
|
| StreamToolCallEndEvent
|
|
24
|
+
| StreamUsageEvent
|
|
24
25
|
| StreamDoneEvent
|
|
25
26
|
| StreamErrorEvent;
|
|
26
27
|
|
|
@@ -61,6 +62,27 @@ export interface StreamToolCallEndEvent {
|
|
|
61
62
|
id: string;
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
/**
|
|
66
|
+
* What the provider counted for the request that produced this stream, when it reported a count.
|
|
67
|
+
*
|
|
68
|
+
* Emitted at most once, immediately BEFORE `done`, because a reader stops at `done`. It is optional
|
|
69
|
+
* and additive: a backend with no figure sends none, and a reader that predates it ignores it. It
|
|
70
|
+
* exists because every budget the client keeps was an estimate of four characters per token, and
|
|
71
|
+
* the provider's own count, which the client was asking for all along (`include_usage`), was
|
|
72
|
+
* discarded before it arrived.
|
|
73
|
+
*/
|
|
74
|
+
export interface StreamUsageEvent {
|
|
75
|
+
type: "usage";
|
|
76
|
+
/** Tokens the provider read: system prompt, tool schemas and the whole history. */
|
|
77
|
+
inputTokens: number;
|
|
78
|
+
/** Tokens the provider generated, reasoning included. */
|
|
79
|
+
outputTokens: number;
|
|
80
|
+
/** The part of `inputTokens` served from a prompt cache, when the provider says. */
|
|
81
|
+
cachedInputTokens?: number;
|
|
82
|
+
/** The part of `outputTokens` spent on chain-of-thought, when the provider says. */
|
|
83
|
+
reasoningTokens?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
export interface StreamDoneEvent {
|
|
65
87
|
type: "done";
|
|
66
88
|
stopReason: string;
|
|
@@ -131,6 +153,53 @@ interface OpenAIStreamChunk {
|
|
|
131
153
|
};
|
|
132
154
|
finish_reason?: string | null;
|
|
133
155
|
}[];
|
|
156
|
+
/** The count `stream_options.include_usage` asks for — normally a final chunk with no choices. */
|
|
157
|
+
usage?: OpenAIUsage | null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** OpenAI's usage object, as chat-completions streams it. */
|
|
161
|
+
export interface OpenAIUsage {
|
|
162
|
+
prompt_tokens?: number;
|
|
163
|
+
completion_tokens?: number;
|
|
164
|
+
prompt_tokens_details?: { cached_tokens?: number } | null;
|
|
165
|
+
completion_tokens_details?: { reasoning_tokens?: number } | null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Normalize OpenAI's usage object into a {@link StreamUsageEvent}, or null when it carries no count.
|
|
170
|
+
* The two detail figures are included only when the provider sent them: absent means "not said",
|
|
171
|
+
* which is not the same as zero.
|
|
172
|
+
*/
|
|
173
|
+
export function usageEventFromOpenAI(usage?: OpenAIUsage | null): StreamUsageEvent | null {
|
|
174
|
+
if (!usage || typeof usage.prompt_tokens !== "number") {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const event: StreamUsageEvent = {
|
|
178
|
+
type: "usage",
|
|
179
|
+
inputTokens: usage.prompt_tokens,
|
|
180
|
+
outputTokens: usage.completion_tokens ?? 0,
|
|
181
|
+
};
|
|
182
|
+
const cached = usage.prompt_tokens_details?.cached_tokens;
|
|
183
|
+
if (typeof cached === "number") {
|
|
184
|
+
event.cachedInputTokens = cached;
|
|
185
|
+
}
|
|
186
|
+
const reasoning = usage.completion_tokens_details?.reasoning_tokens;
|
|
187
|
+
if (typeof reasoning === "number") {
|
|
188
|
+
event.reasoningTokens = reasoning;
|
|
189
|
+
}
|
|
190
|
+
return event;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The stop reasons the client reports, from an OpenAI `finish_reason`. Anything else (a content
|
|
195
|
+
* filter, a provider's own spelling) is not a stop the loop acts on, so it maps to null and the
|
|
196
|
+
* stream keeps reading.
|
|
197
|
+
*/
|
|
198
|
+
function stopReasonFromOpenAI(reason: string | null | undefined): string | null {
|
|
199
|
+
if (reason === "tool_calls" || reason === "length" || reason === "stop") {
|
|
200
|
+
return reason;
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
134
203
|
}
|
|
135
204
|
|
|
136
205
|
/** Error response body — the proxy's flat `{ error: "..." }` or OpenAI's `{ error: { message } }`. */
|
|
@@ -144,6 +213,45 @@ interface ErrorResponseBody {
|
|
|
144
213
|
* reading a sentence and deciding what it meant.
|
|
145
214
|
*/
|
|
146
215
|
code?: string;
|
|
216
|
+
/**
|
|
217
|
+
* An array-shaped envelope, as Cloudflare's own REST API uses for a BYOK base URL pointed
|
|
218
|
+
* directly at it: `{ success: false, errors: [{ code, message }] }`, with no top-level `error`
|
|
219
|
+
* key at all. Reachable only when `baseUrl` bypasses the Studio proxy and talks to such a
|
|
220
|
+
* provider directly — the proxy itself normalizes this shape before it ever reaches a client.
|
|
221
|
+
*/
|
|
222
|
+
errors?: { code?: number; message?: string }[];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Extract a human-readable message from an error body, falling back to the raw body (or `fallback`
|
|
227
|
+
* when the body is empty).
|
|
228
|
+
*/
|
|
229
|
+
function extractErrorMessage(rawBody: string, fallback: string): string {
|
|
230
|
+
if (!rawBody) {
|
|
231
|
+
return fallback;
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
const { error, errors } = JSON.parse(rawBody) as ErrorResponseBody;
|
|
235
|
+
if (typeof error === "string") {
|
|
236
|
+
return error;
|
|
237
|
+
}
|
|
238
|
+
if (error) {
|
|
239
|
+
const { message } = error;
|
|
240
|
+
if (message) {
|
|
241
|
+
return message;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const [first] = errors ?? [];
|
|
245
|
+
if (first) {
|
|
246
|
+
const { message } = first;
|
|
247
|
+
if (message) {
|
|
248
|
+
return message;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
/* Not JSON — use the raw body. */
|
|
253
|
+
}
|
|
254
|
+
return rawBody;
|
|
147
255
|
}
|
|
148
256
|
|
|
149
257
|
// ─── Type definitions ────────────────────────────────────────────────────────
|
|
@@ -160,6 +268,7 @@ export const STREAM_EVENT_TYPES = {
|
|
|
160
268
|
TOOL_CALL_START: "tool_call_start",
|
|
161
269
|
TOOL_CALL_DELTA: "tool_call_delta",
|
|
162
270
|
TOOL_CALL_END: "tool_call_end",
|
|
271
|
+
USAGE: "usage",
|
|
163
272
|
DONE: "done",
|
|
164
273
|
ERROR: "error",
|
|
165
274
|
} as const;
|
|
@@ -275,9 +384,10 @@ export function createOpenAIStreamingClient({
|
|
|
275
384
|
} catch {
|
|
276
385
|
/* Ignore */
|
|
277
386
|
}
|
|
387
|
+
const message = extractErrorMessage(errorBody, response.statusText);
|
|
278
388
|
yield {
|
|
279
389
|
type: "error",
|
|
280
|
-
message: `API error ${response.status}: ${
|
|
390
|
+
message: `API error ${response.status}: ${message}`,
|
|
281
391
|
code: String(response.status),
|
|
282
392
|
};
|
|
283
393
|
return;
|
|
@@ -292,8 +402,37 @@ export function createOpenAIStreamingClient({
|
|
|
292
402
|
const decoder = new TextDecoder();
|
|
293
403
|
let buffer = "";
|
|
294
404
|
|
|
295
|
-
/** @type {Map<string, { id: string; name: string; args: string }>} */
|
|
296
405
|
const pendingToolCalls = new Map<number, { id: string; name: string; args: string }>();
|
|
406
|
+
/* The finish reason is REMEMBERED rather than acted on. With `include_usage`, the count arrives
|
|
407
|
+
in a chunk of its own after the one carrying `finish_reason`, so returning on the finish
|
|
408
|
+
dropped the count on every request. The stream ends at `[DONE]` or when the body closes. */
|
|
409
|
+
let stopReason: string | null = null;
|
|
410
|
+
let usage: StreamUsageEvent | null = null;
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Close the tool calls still open. Idempotent, so a finish chunk and `[DONE]` can both call it.
|
|
414
|
+
*
|
|
415
|
+
* @yields {StreamEvent} A `tool_call_end` per open call.
|
|
416
|
+
*/
|
|
417
|
+
function* closePendingToolCalls(): Generator<StreamEvent> {
|
|
418
|
+
for (const tc of pendingToolCalls.values()) {
|
|
419
|
+
yield { type: "tool_call_end", id: tc.id };
|
|
420
|
+
}
|
|
421
|
+
pendingToolCalls.clear();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The stream's last frames: any open calls, the count when there is one, then `done`.
|
|
426
|
+
*
|
|
427
|
+
* @yields {StreamEvent} The closing frames, in order.
|
|
428
|
+
*/
|
|
429
|
+
function* finish(): Generator<StreamEvent> {
|
|
430
|
+
yield* closePendingToolCalls();
|
|
431
|
+
if (usage) {
|
|
432
|
+
yield usage;
|
|
433
|
+
}
|
|
434
|
+
yield { type: "done", stopReason: stopReason ?? "stop" };
|
|
435
|
+
}
|
|
297
436
|
|
|
298
437
|
try {
|
|
299
438
|
while (true) {
|
|
@@ -315,12 +454,7 @@ export function createOpenAIStreamingClient({
|
|
|
315
454
|
|
|
316
455
|
const dataStr = trimmed.slice(6);
|
|
317
456
|
if (dataStr === "[DONE]") {
|
|
318
|
-
|
|
319
|
-
for (const tc of pendingToolCalls.values()) {
|
|
320
|
-
yield { type: "tool_call_end", id: tc.id };
|
|
321
|
-
}
|
|
322
|
-
pendingToolCalls.clear();
|
|
323
|
-
yield { type: "done", stopReason: "stop" };
|
|
457
|
+
yield* finish();
|
|
324
458
|
return;
|
|
325
459
|
}
|
|
326
460
|
|
|
@@ -331,29 +465,29 @@ export function createOpenAIStreamingClient({
|
|
|
331
465
|
continue; // Skip unparseable chunks
|
|
332
466
|
}
|
|
333
467
|
|
|
468
|
+
// Usually a final chunk with no choices, but a provider may put it beside the finish.
|
|
469
|
+
usage = usageEventFromOpenAI(parsed.usage) ?? usage;
|
|
470
|
+
|
|
334
471
|
const choice = parsed.choices?.[0];
|
|
335
472
|
if (!choice) {
|
|
336
473
|
continue;
|
|
337
474
|
}
|
|
338
475
|
|
|
339
476
|
const { delta } = choice;
|
|
340
|
-
if (!delta) {
|
|
341
|
-
continue;
|
|
342
|
-
}
|
|
343
477
|
|
|
344
478
|
// Text content
|
|
345
|
-
if (delta
|
|
479
|
+
if (delta?.content) {
|
|
346
480
|
yield { type: "delta", content: delta.content };
|
|
347
481
|
}
|
|
348
482
|
|
|
349
483
|
// Chain-of-thought, under either of the two names providers give it.
|
|
350
|
-
const reasoning = delta
|
|
484
|
+
const reasoning = delta?.reasoning_content ?? delta?.reasoning;
|
|
351
485
|
if (typeof reasoning === "string" && reasoning) {
|
|
352
486
|
yield { type: "reasoning", content: reasoning };
|
|
353
487
|
}
|
|
354
488
|
|
|
355
489
|
// Tool calls
|
|
356
|
-
if (delta
|
|
490
|
+
if (delta?.tool_calls) {
|
|
357
491
|
for (const tc of delta.tool_calls) {
|
|
358
492
|
const existing = pendingToolCalls.get(tc.index);
|
|
359
493
|
|
|
@@ -383,38 +517,17 @@ export function createOpenAIStreamingClient({
|
|
|
383
517
|
}
|
|
384
518
|
}
|
|
385
519
|
|
|
386
|
-
// Finish reason
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
}
|
|
392
|
-
pendingToolCalls.clear();
|
|
393
|
-
yield { type: "done", stopReason: "tool_calls" };
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
if (choice.finish_reason === "stop" || choice.finish_reason === "length") {
|
|
398
|
-
// Emit any pending tool call ends
|
|
399
|
-
for (const tc of pendingToolCalls.values()) {
|
|
400
|
-
yield { type: "tool_call_end", id: tc.id };
|
|
401
|
-
}
|
|
402
|
-
pendingToolCalls.clear();
|
|
403
|
-
yield {
|
|
404
|
-
type: "done",
|
|
405
|
-
stopReason: choice.finish_reason === "length" ? "length" : "stop",
|
|
406
|
-
};
|
|
407
|
-
return;
|
|
520
|
+
// Finish reason: close the calls now, report the reason at the end of the stream.
|
|
521
|
+
const finished = stopReasonFromOpenAI(choice.finish_reason);
|
|
522
|
+
if (finished) {
|
|
523
|
+
yield* closePendingToolCalls();
|
|
524
|
+
stopReason = finished;
|
|
408
525
|
}
|
|
409
526
|
}
|
|
410
527
|
}
|
|
411
528
|
|
|
412
|
-
// Stream ended without
|
|
413
|
-
|
|
414
|
-
yield { type: "tool_call_end", id: tc.id };
|
|
415
|
-
}
|
|
416
|
-
pendingToolCalls.clear();
|
|
417
|
-
yield { type: "done", stopReason: "stop" };
|
|
529
|
+
// Stream ended without `[DONE]`
|
|
530
|
+
yield* finish();
|
|
418
531
|
} catch (error) {
|
|
419
532
|
void reader.cancel();
|
|
420
533
|
if ((error as Error).name === "AbortError") {
|
|
@@ -473,14 +586,19 @@ export function createAnthropicStreamingClient(
|
|
|
473
586
|
* `@jxsuite/server/ai-api`). No API key handling here — the proxy owns provider credentials.
|
|
474
587
|
*
|
|
475
588
|
* @param {object} opts
|
|
476
|
-
* @param {string} opts.chatUrl - URL to POST `{ messages, tools,
|
|
589
|
+
* @param {string | (() => string | Promise<string>)} opts.chatUrl - URL to POST `{ messages, tools,
|
|
590
|
+
* systemPrompt, model }` to, or a function that answers it. A function is called once, inside the
|
|
591
|
+
* first stream, so a caller can hand over its turn's signal before it waits on anything: a Stop
|
|
592
|
+
* during a lookup over IPC then takes effect once it answers, and the stream ends cancelled with
|
|
593
|
+
* nothing sent. The outcome is kept for the client's later streams. A rejection propagates out of
|
|
594
|
+
* the stream, unless the signal was aborted by then, which ends it cancelled.
|
|
477
595
|
* @param {string} [opts.model] - Default model if not specified per-request
|
|
478
596
|
* @param {string} [opts.apiKey] - Optional client-supplied key, sent as the `X-Api-Key` header
|
|
479
597
|
* @param {string} [opts.baseUrl] - Optional OpenAI-compatible base URL, sent as `X-Api-Base-URL`
|
|
480
598
|
* @returns {StreamingClient}
|
|
481
599
|
*/
|
|
482
600
|
export interface ProxyStreamingClientOptions {
|
|
483
|
-
chatUrl: string;
|
|
601
|
+
chatUrl: string | (() => string | Promise<string>);
|
|
484
602
|
model?: string;
|
|
485
603
|
apiKey?: string | undefined;
|
|
486
604
|
baseUrl?: string | undefined;
|
|
@@ -492,6 +610,9 @@ export function createProxyStreamingClient({
|
|
|
492
610
|
apiKey,
|
|
493
611
|
baseUrl,
|
|
494
612
|
}: ProxyStreamingClientOptions): StreamingClient {
|
|
613
|
+
/** The URL, once the first stream has asked for it. */
|
|
614
|
+
let resolvedUrl: Promise<string> | null = null;
|
|
615
|
+
|
|
495
616
|
/**
|
|
496
617
|
* @param {object[]} messages
|
|
497
618
|
* @param {object[]} tools
|
|
@@ -516,9 +637,27 @@ export function createProxyStreamingClient({
|
|
|
516
637
|
headers["X-Api-Base-URL"] = baseUrl;
|
|
517
638
|
}
|
|
518
639
|
|
|
640
|
+
resolvedUrl ??= Promise.resolve(typeof chatUrl === "function" ? chatUrl() : chatUrl);
|
|
641
|
+
let url: string;
|
|
642
|
+
try {
|
|
643
|
+
url = await resolvedUrl;
|
|
644
|
+
} catch (error) {
|
|
645
|
+
// A lookup that failed after the turn was stopped is part of the stop, not an error.
|
|
646
|
+
if (signal?.aborted) {
|
|
647
|
+
yield { type: "done", stopReason: "cancelled" };
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
// Stopped while the URL was being resolved: nothing has been sent, and nothing will be.
|
|
653
|
+
if (signal?.aborted) {
|
|
654
|
+
yield { type: "done", stopReason: "cancelled" };
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
519
658
|
let response;
|
|
520
659
|
try {
|
|
521
|
-
response = await fetch(
|
|
660
|
+
response = await fetch(url, {
|
|
522
661
|
method: "POST",
|
|
523
662
|
headers,
|
|
524
663
|
body: JSON.stringify({ messages, tools, systemPrompt, model }),
|
|
@@ -543,23 +682,17 @@ export function createProxyStreamingClient({
|
|
|
543
682
|
} catch {
|
|
544
683
|
/* Ignore */
|
|
545
684
|
}
|
|
546
|
-
//
|
|
547
|
-
|
|
548
|
-
let cleanMessage = errorBody || response.statusText;
|
|
685
|
+
// Extract a clean message from the JSON error body — see extractErrorMessage.
|
|
686
|
+
const cleanMessage = extractErrorMessage(errorBody, response.statusText);
|
|
549
687
|
/* The body's machine code beats the status. `code` was `String(response.status)`
|
|
550
688
|
unconditionally, so a proxy answering 401 `{ code: "cf_reconnect_required" }` reached the
|
|
551
689
|
client as `"401"` — indistinguishable from a bad BYOK key, and the one reading that would
|
|
552
690
|
have put a Reconnect button on screen was thrown away at the only place it arrived. */
|
|
553
691
|
let machineCode = "";
|
|
554
692
|
try {
|
|
555
|
-
const
|
|
556
|
-
if (typeof
|
|
557
|
-
|
|
558
|
-
} else if (parsed.error?.message) {
|
|
559
|
-
cleanMessage = parsed.error.message;
|
|
560
|
-
}
|
|
561
|
-
if (typeof parsed.code === "string" && parsed.code) {
|
|
562
|
-
machineCode = parsed.code;
|
|
693
|
+
const { code } = JSON.parse(errorBody) as ErrorResponseBody;
|
|
694
|
+
if (typeof code === "string" && code) {
|
|
695
|
+
machineCode = code;
|
|
563
696
|
}
|
|
564
697
|
} catch {
|
|
565
698
|
/* Not JSON — use the raw body. */
|
package/src/tools.ts
CHANGED
|
@@ -5,16 +5,33 @@
|
|
|
5
5
|
* can call. Tools have JSON Schema parameter definitions and execute functions.
|
|
6
6
|
* The registry validates arguments before execution.
|
|
7
7
|
*
|
|
8
|
+
* Every call carries a {@link ToolContext}: its own signal, its call id, who it acts for, the turn's
|
|
9
|
+
* write ledger and the session's facts (specs/ai.md §3.7). A caller that has none gets a detached
|
|
10
|
+
* one, so a tool run outside a turn (a test, a command) records into nothing and cannot be stopped.
|
|
11
|
+
*
|
|
12
|
+
* @docs extending/embedding/assistant-harness
|
|
8
13
|
* @license MIT
|
|
9
14
|
* @module @jxsuite/ai/tools
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
import type {
|
|
18
|
+
AiWrite,
|
|
19
|
+
JsonValue,
|
|
20
|
+
SessionFacts,
|
|
21
|
+
ToolContext,
|
|
22
|
+
ToolResult,
|
|
23
|
+
WriteLedger,
|
|
24
|
+
} from "./core-types.ts";
|
|
25
|
+
|
|
26
|
+
export type {
|
|
27
|
+
Actor,
|
|
28
|
+
AiWrite,
|
|
29
|
+
JsonValue,
|
|
30
|
+
SessionFacts,
|
|
31
|
+
ToolContext,
|
|
32
|
+
ToolResult,
|
|
33
|
+
WriteLedger,
|
|
34
|
+
} from "./core-types.ts";
|
|
18
35
|
|
|
19
36
|
/**
|
|
20
37
|
* JSON Schema fragment describing a tool's parameters. Only the subset the registry's lightweight
|
|
@@ -33,7 +50,12 @@ export interface ToolDefinition {
|
|
|
33
50
|
parameters: JSONSchema;
|
|
34
51
|
strict: boolean;
|
|
35
52
|
llmStrict: boolean;
|
|
36
|
-
|
|
53
|
+
/**
|
|
54
|
+
* The call suspends the turn on a person rather than doing work (`ask_user`), so a round that
|
|
55
|
+
* made only such calls does not spend the turn's work budget.
|
|
56
|
+
*/
|
|
57
|
+
interactive?: boolean;
|
|
58
|
+
execute: (args: object, ctx: ToolContext) => Promise<ToolResult> | ToolResult;
|
|
37
59
|
}
|
|
38
60
|
|
|
39
61
|
export interface ToolRegistry {
|
|
@@ -41,7 +63,11 @@ export interface ToolRegistry {
|
|
|
41
63
|
list: () => ToolDefinition[];
|
|
42
64
|
listForLLM: () => object[];
|
|
43
65
|
validate: (toolName: string, args: object) => { valid: boolean; errors?: string[] };
|
|
44
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Run a tool with the call's context; a registry that wraps another forwards `ctx` unchanged.
|
|
68
|
+
* Omitted, the call gets a detached context ({@link createToolContext}).
|
|
69
|
+
*/
|
|
70
|
+
execute: (toolName: string, args: object, ctx?: ToolContext) => Promise<ToolResult>;
|
|
45
71
|
getDefinition: (toolName: string) => ToolDefinition | undefined;
|
|
46
72
|
}
|
|
47
73
|
|
|
@@ -82,8 +108,9 @@ export function toolError(error: string): ToolResult {
|
|
|
82
108
|
* @param {boolean} [opts.llmStrict] - Whether to send OpenAI `strict: true` in the function schema
|
|
83
109
|
* (default false). Only enable if the parameter schema is OpenAI-strict-compliant
|
|
84
110
|
* (additionalProperties:false everywhere, all properties in `required`, every property typed).
|
|
85
|
-
* @param {
|
|
86
|
-
*
|
|
111
|
+
* @param {boolean} [opts.interactive] - The call suspends the turn on a person (`ask_user`)
|
|
112
|
+
* @param {(args: object, ctx: ToolContext) => Promise<ToolResult> | ToolResult} opts.execute - The
|
|
113
|
+
* tool implementation, handed the call's context
|
|
87
114
|
* @returns {ToolDefinition}
|
|
88
115
|
*/
|
|
89
116
|
export function createToolDefinition({
|
|
@@ -92,6 +119,7 @@ export function createToolDefinition({
|
|
|
92
119
|
parameters,
|
|
93
120
|
strict = true,
|
|
94
121
|
llmStrict = false,
|
|
122
|
+
interactive,
|
|
95
123
|
execute,
|
|
96
124
|
}: {
|
|
97
125
|
name: string;
|
|
@@ -99,9 +127,119 @@ export function createToolDefinition({
|
|
|
99
127
|
parameters: JSONSchema;
|
|
100
128
|
strict?: boolean;
|
|
101
129
|
llmStrict?: boolean;
|
|
102
|
-
|
|
130
|
+
interactive?: boolean;
|
|
131
|
+
execute: (args: object, ctx: ToolContext) => Promise<ToolResult> | ToolResult;
|
|
103
132
|
}): ToolDefinition {
|
|
104
|
-
return {
|
|
133
|
+
return {
|
|
134
|
+
name,
|
|
135
|
+
description,
|
|
136
|
+
parameters,
|
|
137
|
+
strict,
|
|
138
|
+
llmStrict,
|
|
139
|
+
...(interactive === undefined ? {} : { interactive }),
|
|
140
|
+
execute,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── ToolContext ─────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A write ledger: the writes one turn's tools record, in order.
|
|
148
|
+
*
|
|
149
|
+
* @param {(write: AiWrite) => void} [onRecord] - Told of each write as it is recorded
|
|
150
|
+
* @returns {WriteLedger}
|
|
151
|
+
*/
|
|
152
|
+
export function createLedger(onRecord?: (write: AiWrite) => void): WriteLedger {
|
|
153
|
+
const writes: AiWrite[] = [];
|
|
154
|
+
return {
|
|
155
|
+
record(write: AiWrite) {
|
|
156
|
+
writes.push(write);
|
|
157
|
+
onRecord?.(write);
|
|
158
|
+
},
|
|
159
|
+
writes,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Per-session facts: JSON a tool remembers across one conversation's turns.
|
|
165
|
+
*
|
|
166
|
+
* @param {string | null} [sessionId]
|
|
167
|
+
* @param {Readonly<Record<string, JsonValue>>} [initial] - Facts to start from (a restored session)
|
|
168
|
+
* @returns {SessionFacts & { toJSON: () => Record<string, JsonValue> }}
|
|
169
|
+
*/
|
|
170
|
+
export function createSessionFacts(
|
|
171
|
+
sessionId: string | null = null,
|
|
172
|
+
initial: Readonly<Record<string, JsonValue>> = {},
|
|
173
|
+
): SessionFacts & { toJSON: () => Record<string, JsonValue> } {
|
|
174
|
+
const facts = new Map<string, JsonValue>(Object.entries(initial));
|
|
175
|
+
return {
|
|
176
|
+
sessionId,
|
|
177
|
+
get: (key: string) => facts.get(key),
|
|
178
|
+
set: (key: string, value: JsonValue) => {
|
|
179
|
+
facts.set(key, value);
|
|
180
|
+
},
|
|
181
|
+
toJSON: () => Object.fromEntries(facts),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A signal that never aborts: the one a detached call gets. */
|
|
186
|
+
const NEVER = new AbortController().signal;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A tool call's context. Every field not given is detached: a signal that never aborts, an empty
|
|
190
|
+
* call id, a fresh ledger nothing files, empty session facts and a progress sink that drops
|
|
191
|
+
* everything. That is what a call made outside any turn gets.
|
|
192
|
+
*
|
|
193
|
+
* @param {Partial<ToolContext>} [init]
|
|
194
|
+
* @returns {ToolContext}
|
|
195
|
+
*/
|
|
196
|
+
export function createToolContext(init: Partial<ToolContext> = {}): ToolContext {
|
|
197
|
+
const session = init.session ?? createSessionFacts();
|
|
198
|
+
return {
|
|
199
|
+
actor: init.actor ?? {
|
|
200
|
+
id: `assistant:${session.sessionId ?? "local"}:`,
|
|
201
|
+
kind: "assistant",
|
|
202
|
+
sessionId: session.sessionId,
|
|
203
|
+
turnId: "",
|
|
204
|
+
},
|
|
205
|
+
callId: init.callId ?? "",
|
|
206
|
+
ledger: init.ledger ?? createLedger(),
|
|
207
|
+
progress: init.progress ?? (() => {}),
|
|
208
|
+
session,
|
|
209
|
+
signal: init.signal ?? NEVER,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A call's own signal, linked to its turn's: it aborts when the turn does, with the turn's reason,
|
|
215
|
+
* and {@link release} unlinks it once the call settles, so a Stop later in the turn reaches no call
|
|
216
|
+
* that has already finished.
|
|
217
|
+
*
|
|
218
|
+
* Linked by hand, with one `{ once: true }` listener, rather than with `AbortSignal.any`, so page
|
|
219
|
+
* webviews and the Worker's ES2023 typing agree.
|
|
220
|
+
*
|
|
221
|
+
* @param {AbortSignal} turn
|
|
222
|
+
* @returns {{ readonly signal: AbortSignal; release(): void }}
|
|
223
|
+
*/
|
|
224
|
+
export function linkCallSignal(turn: AbortSignal): {
|
|
225
|
+
readonly signal: AbortSignal;
|
|
226
|
+
release: () => void;
|
|
227
|
+
} {
|
|
228
|
+
const call = new AbortController();
|
|
229
|
+
if (turn.aborted) {
|
|
230
|
+
call.abort(turn.reason);
|
|
231
|
+
return { release: () => {}, signal: call.signal };
|
|
232
|
+
}
|
|
233
|
+
const onAbort = () => {
|
|
234
|
+
call.abort(turn.reason);
|
|
235
|
+
};
|
|
236
|
+
turn.addEventListener("abort", onAbort, { once: true });
|
|
237
|
+
return {
|
|
238
|
+
release: () => {
|
|
239
|
+
turn.removeEventListener("abort", onAbort);
|
|
240
|
+
},
|
|
241
|
+
signal: call.signal,
|
|
242
|
+
};
|
|
105
243
|
}
|
|
106
244
|
|
|
107
245
|
// ─── ToolRegistry ────────────────────────────────────────────────────────────
|
|
@@ -256,9 +394,10 @@ export function createToolRegistry() {
|
|
|
256
394
|
*
|
|
257
395
|
* @param {string} toolName
|
|
258
396
|
* @param {object} args
|
|
397
|
+
* @param {ToolContext} [ctx] - The call's context; omitted, a detached one
|
|
259
398
|
* @returns {Promise<ToolResult>}
|
|
260
399
|
*/
|
|
261
|
-
async execute(toolName: string, args: object) {
|
|
400
|
+
async execute(toolName: string, args: object, ctx: ToolContext = createToolContext()) {
|
|
262
401
|
const tool = _tools.get(toolName);
|
|
263
402
|
if (!tool) {
|
|
264
403
|
return toolError(`Unknown tool: "${toolName}"`);
|
|
@@ -271,7 +410,7 @@ export function createToolRegistry() {
|
|
|
271
410
|
}
|
|
272
411
|
|
|
273
412
|
try {
|
|
274
|
-
return await tool.execute(args);
|
|
413
|
+
return await tool.execute(args, ctx);
|
|
275
414
|
} catch (error) {
|
|
276
415
|
return toolError(`Tool "${toolName}" execution error: ${(error as Error).message}`);
|
|
277
416
|
}
|