@jxsuite/ai 0.37.2 → 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.
@@ -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 seven members are exported because they are the contract a third-party Studio
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 } }`. */
@@ -199,6 +268,7 @@ export const STREAM_EVENT_TYPES = {
199
268
  TOOL_CALL_START: "tool_call_start",
200
269
  TOOL_CALL_DELTA: "tool_call_delta",
201
270
  TOOL_CALL_END: "tool_call_end",
271
+ USAGE: "usage",
202
272
  DONE: "done",
203
273
  ERROR: "error",
204
274
  } as const;
@@ -332,8 +402,37 @@ export function createOpenAIStreamingClient({
332
402
  const decoder = new TextDecoder();
333
403
  let buffer = "";
334
404
 
335
- /** @type {Map<string, { id: string; name: string; args: string }>} */
336
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
+ }
337
436
 
338
437
  try {
339
438
  while (true) {
@@ -355,12 +454,7 @@ export function createOpenAIStreamingClient({
355
454
 
356
455
  const dataStr = trimmed.slice(6);
357
456
  if (dataStr === "[DONE]") {
358
- // Emit any pending tool call ends before done
359
- for (const tc of pendingToolCalls.values()) {
360
- yield { type: "tool_call_end", id: tc.id };
361
- }
362
- pendingToolCalls.clear();
363
- yield { type: "done", stopReason: "stop" };
457
+ yield* finish();
364
458
  return;
365
459
  }
366
460
 
@@ -371,29 +465,29 @@ export function createOpenAIStreamingClient({
371
465
  continue; // Skip unparseable chunks
372
466
  }
373
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
+
374
471
  const choice = parsed.choices?.[0];
375
472
  if (!choice) {
376
473
  continue;
377
474
  }
378
475
 
379
476
  const { delta } = choice;
380
- if (!delta) {
381
- continue;
382
- }
383
477
 
384
478
  // Text content
385
- if (delta.content) {
479
+ if (delta?.content) {
386
480
  yield { type: "delta", content: delta.content };
387
481
  }
388
482
 
389
483
  // Chain-of-thought, under either of the two names providers give it.
390
- const reasoning = delta.reasoning_content ?? delta.reasoning;
484
+ const reasoning = delta?.reasoning_content ?? delta?.reasoning;
391
485
  if (typeof reasoning === "string" && reasoning) {
392
486
  yield { type: "reasoning", content: reasoning };
393
487
  }
394
488
 
395
489
  // Tool calls
396
- if (delta.tool_calls) {
490
+ if (delta?.tool_calls) {
397
491
  for (const tc of delta.tool_calls) {
398
492
  const existing = pendingToolCalls.get(tc.index);
399
493
 
@@ -423,38 +517,17 @@ export function createOpenAIStreamingClient({
423
517
  }
424
518
  }
425
519
 
426
- // Finish reason
427
- if (choice.finish_reason === "tool_calls") {
428
- // Emit pending tool call ends
429
- for (const tc of pendingToolCalls.values()) {
430
- yield { type: "tool_call_end", id: tc.id };
431
- }
432
- pendingToolCalls.clear();
433
- yield { type: "done", stopReason: "tool_calls" };
434
- return;
435
- }
436
-
437
- if (choice.finish_reason === "stop" || choice.finish_reason === "length") {
438
- // Emit any pending tool call ends
439
- for (const tc of pendingToolCalls.values()) {
440
- yield { type: "tool_call_end", id: tc.id };
441
- }
442
- pendingToolCalls.clear();
443
- yield {
444
- type: "done",
445
- stopReason: choice.finish_reason === "length" ? "length" : "stop",
446
- };
447
- 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;
448
525
  }
449
526
  }
450
527
  }
451
528
 
452
- // Stream ended without explicit finish_reason
453
- for (const tc of pendingToolCalls.values()) {
454
- yield { type: "tool_call_end", id: tc.id };
455
- }
456
- pendingToolCalls.clear();
457
- yield { type: "done", stopReason: "stop" };
529
+ // Stream ended without `[DONE]`
530
+ yield* finish();
458
531
  } catch (error) {
459
532
  void reader.cancel();
460
533
  if ((error as Error).name === "AbortError") {
@@ -513,14 +586,19 @@ export function createAnthropicStreamingClient(
513
586
  * `@jxsuite/server/ai-api`). No API key handling here — the proxy owns provider credentials.
514
587
  *
515
588
  * @param {object} opts
516
- * @param {string} opts.chatUrl - URL to POST `{ messages, tools, systemPrompt, model }` to
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.
517
595
  * @param {string} [opts.model] - Default model if not specified per-request
518
596
  * @param {string} [opts.apiKey] - Optional client-supplied key, sent as the `X-Api-Key` header
519
597
  * @param {string} [opts.baseUrl] - Optional OpenAI-compatible base URL, sent as `X-Api-Base-URL`
520
598
  * @returns {StreamingClient}
521
599
  */
522
600
  export interface ProxyStreamingClientOptions {
523
- chatUrl: string;
601
+ chatUrl: string | (() => string | Promise<string>);
524
602
  model?: string;
525
603
  apiKey?: string | undefined;
526
604
  baseUrl?: string | undefined;
@@ -532,6 +610,9 @@ export function createProxyStreamingClient({
532
610
  apiKey,
533
611
  baseUrl,
534
612
  }: ProxyStreamingClientOptions): StreamingClient {
613
+ /** The URL, once the first stream has asked for it. */
614
+ let resolvedUrl: Promise<string> | null = null;
615
+
535
616
  /**
536
617
  * @param {object[]} messages
537
618
  * @param {object[]} tools
@@ -556,9 +637,27 @@ export function createProxyStreamingClient({
556
637
  headers["X-Api-Base-URL"] = baseUrl;
557
638
  }
558
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
+
559
658
  let response;
560
659
  try {
561
- response = await fetch(chatUrl, {
660
+ response = await fetch(url, {
562
661
  method: "POST",
563
662
  headers,
564
663
  body: JSON.stringify({ messages, tools, systemPrompt, model }),
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
- export interface ToolResult {
13
- success: boolean;
14
- data?: unknown;
15
- error?: string;
16
- summary?: string;
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
- execute: (args: object) => Promise<ToolResult> | ToolResult;
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
- execute: (toolName: string, args: object) => Promise<ToolResult>;
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 {(args: object) => Promise<ToolResult> | ToolResult} opts.execute - The tool
86
- * implementation
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
- execute: (args: object) => Promise<ToolResult> | ToolResult;
130
+ interactive?: boolean;
131
+ execute: (args: object, ctx: ToolContext) => Promise<ToolResult> | ToolResult;
103
132
  }): ToolDefinition {
104
- return { name, description, parameters, strict, llmStrict, execute };
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
  }