@jxsuite/ai 0.39.3 → 0.39.5

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/src/tools.ts CHANGED
@@ -17,12 +17,14 @@
17
17
 
18
18
  import type {
19
19
  AiWrite,
20
+ JsonObject,
20
21
  JsonValue,
21
22
  SessionFacts,
22
23
  ToolContext,
23
24
  ToolResult,
24
25
  WriteLedger,
25
26
  } from "./core-types.ts";
27
+ import type { OpenAITool } from "./messages/types.ts";
26
28
 
27
29
  export type {
28
30
  Actor,
@@ -44,9 +46,21 @@ export interface JSONSchema {
44
46
  type?: string;
45
47
  properties?: Record<string, JSONSchema>;
46
48
  required?: string[];
47
- [key: string]: unknown;
49
+ /** Any other keyword: a JSON value, or a subschema (`items`, `anyOf`) or a map of them. */
50
+ [keyword: string]:
51
+ | JsonValue
52
+ | JSONSchema
53
+ | readonly JSONSchema[]
54
+ | Record<string, JSONSchema>
55
+ | undefined;
48
56
  }
49
57
 
58
+ /**
59
+ * A call's arguments: the JSON object a model sent, as {@link invokeTool} decodes it, or one a host
60
+ * builds in code, which may leave a member `undefined` (the validator reports it as such).
61
+ */
62
+ export type ToolArguments = Readonly<Record<string, JsonValue | undefined>>;
63
+
50
64
  export interface ToolDefinition {
51
65
  name: string;
52
66
  description: string;
@@ -58,19 +72,19 @@ export interface ToolDefinition {
58
72
  * made only such calls does not spend the turn's work budget.
59
73
  */
60
74
  interactive?: boolean;
61
- execute: (args: object, ctx: ToolContext) => Promise<ToolResult> | ToolResult;
75
+ execute: (args: ToolArguments, ctx: ToolContext) => Promise<ToolResult> | ToolResult;
62
76
  }
63
77
 
64
78
  export interface ToolRegistry {
65
79
  register: (tool: ToolDefinition) => void;
66
80
  list: () => ToolDefinition[];
67
81
  listForLLM: () => object[];
68
- validate: (toolName: string, args: object) => { valid: boolean; errors?: string[] };
82
+ validate: (toolName: string, args: ToolArguments) => { valid: boolean; errors?: string[] };
69
83
  /**
70
84
  * Run a tool with the call's context; a registry that wraps another forwards `ctx` unchanged.
71
85
  * Omitted, the call gets a detached context ({@link createToolContext}).
72
86
  */
73
- execute: (toolName: string, args: object, ctx?: ToolContext) => Promise<ToolResult>;
87
+ execute: (toolName: string, args: ToolArguments, ctx?: ToolContext) => Promise<ToolResult>;
74
88
  getDefinition: (toolName: string) => ToolDefinition | undefined;
75
89
  }
76
90
 
@@ -79,11 +93,14 @@ export interface ToolRegistry {
79
93
  /**
80
94
  * Create a successful tool result.
81
95
  *
82
- * @param {unknown} data - Result data
96
+ * @param {ToolResult["data"]} data - Result data
83
97
  * @param {string} [summary] - Human-readable summary
84
98
  * @returns {ToolResult}
85
99
  */
86
- export function toolSuccess(data: unknown, summary?: string): ToolResult {
100
+ export function toolSuccess(data: ToolResult["data"], summary?: string): ToolResult {
101
+ /* SAFETY: the only mismatch is `summary: undefined` against the optional `summary`. An absent
102
+ summary has always been carried as an explicit `undefined` member, which every reader treats
103
+ as absent and JSON drops, so the result keeps the shape this function has always returned. */
87
104
  return { success: true, data, summary } as ToolResult;
88
105
  }
89
106
 
@@ -112,8 +129,8 @@ export function toolError(error: string): ToolResult {
112
129
  * (default false). Only enable if the parameter schema is OpenAI-strict-compliant
113
130
  * (additionalProperties:false everywhere, all properties in `required`, every property typed).
114
131
  * @param {boolean} [opts.interactive] - The call suspends the turn on a person (`ask_user`)
115
- * @param {(args: object, ctx: ToolContext) => Promise<ToolResult> | ToolResult} opts.execute - The
116
- * tool implementation, handed the call's context
132
+ * @param {(args: ToolArguments, ctx: ToolContext) => Promise<ToolResult> | ToolResult} opts.execute -
133
+ * The tool implementation, handed the call's context
117
134
  * @returns {ToolDefinition}
118
135
  */
119
136
  export function createToolDefinition({
@@ -131,17 +148,14 @@ export function createToolDefinition({
131
148
  strict?: boolean;
132
149
  llmStrict?: boolean;
133
150
  interactive?: boolean;
134
- execute: (args: object, ctx: ToolContext) => Promise<ToolResult> | ToolResult;
151
+ execute: (args: ToolArguments, ctx: ToolContext) => Promise<ToolResult> | ToolResult;
135
152
  }): ToolDefinition {
136
- return {
137
- name,
138
- description,
139
- parameters,
140
- strict,
141
- llmStrict,
142
- ...(interactive === undefined ? {} : { interactive }),
143
- execute,
144
- };
153
+ const definition: ToolDefinition = { name, description, parameters, strict, llmStrict, execute };
154
+ // After `execute` rather than before it: a definition serializes without its function either way.
155
+ if (interactive !== undefined) {
156
+ definition.interactive = interactive;
157
+ }
158
+ return definition;
145
159
  }
146
160
 
147
161
  // ─── ToolContext ─────────────────────────────────────────────────────────────
@@ -213,6 +227,13 @@ export function createToolContext(init: Partial<ToolContext> = {}): ToolContext
213
227
  };
214
228
  }
215
229
 
230
+ /** A call's own signal, and the unlinking {@link linkCallSignal} hands back with it. */
231
+ export interface CallSignalLink {
232
+ readonly signal: AbortSignal;
233
+ /** Unlink the call from its turn, once the call settles. */
234
+ release: () => void;
235
+ }
236
+
216
237
  /**
217
238
  * A call's own signal, linked to its turn's: it aborts when the turn does, with the turn's reason,
218
239
  * and {@link release} unlinks it once the call settles, so a Stop later in the turn reaches no call
@@ -222,12 +243,9 @@ export function createToolContext(init: Partial<ToolContext> = {}): ToolContext
222
243
  * webviews and the Worker's ES2023 typing agree.
223
244
  *
224
245
  * @param {AbortSignal} turn
225
- * @returns {{ readonly signal: AbortSignal; release(): void }}
246
+ * @returns {CallSignalLink}
226
247
  */
227
- export function linkCallSignal(turn: AbortSignal): {
228
- readonly signal: AbortSignal;
229
- release: () => void;
230
- } {
248
+ export function linkCallSignal(turn: AbortSignal): CallSignalLink {
231
249
  const call = new AbortController();
232
250
  if (turn.aborted) {
233
251
  call.abort(turn.reason);
@@ -283,20 +301,22 @@ export function createToolRegistry() {
283
301
  * @returns {object[]}
284
302
  */
285
303
  listForLLM() {
286
- return [..._tools.values()].map((t) => ({
287
- type: "function",
288
- function: {
304
+ return [..._tools.values()].map((t): OpenAITool => {
305
+ const fn: OpenAITool["function"] = {
289
306
  name: t.name,
290
307
  description: t.description,
291
308
  parameters: t.parameters,
292
- // OpenAI strict mode is opt-in per tool via `llmStrict`, NOT the `strict` field — that one
293
- // Drives the registry's own argument validation below. They are different concerns:
294
- // OpenAI strict requires every object to set additionalProperties:false and list all
295
- // Properties in `required`, which the Jx tools deliberately don't (e.g. set_property's
296
- // `value` is polymorphic and optional). GPT-5.x rejects the schema otherwise.
297
- ...(t.llmStrict === true ? { strict: true } : {}),
298
- },
299
- }));
309
+ };
310
+ /* OpenAI strict mode is opt-in per tool via `llmStrict`, NOT the `strict` field: that one
311
+ drives the registry's own argument validation below. They are different concerns:
312
+ OpenAI strict requires every object to set additionalProperties:false and list all
313
+ properties in `required`, which the Jx tools deliberately don't (e.g. set_property's
314
+ `value` is polymorphic and optional). GPT-5.x rejects the schema otherwise. */
315
+ if (t.llmStrict === true) {
316
+ fn.strict = true;
317
+ }
318
+ return { type: "function", function: fn };
319
+ });
300
320
  },
301
321
 
302
322
  /**
@@ -317,10 +337,10 @@ export function createToolRegistry() {
317
337
  * layered on with a proper validator library if needed.
318
338
  *
319
339
  * @param {string} toolName
320
- * @param {object} args
340
+ * @param {ToolArguments} args
321
341
  * @returns {{ valid: boolean; errors?: string[] }}
322
342
  */
323
- validate(toolName: string, args: object) {
343
+ validate(toolName: string, args: ToolArguments) {
324
344
  const tool = _tools.get(toolName);
325
345
  if (!tool) {
326
346
  return { valid: false, errors: [`Unknown tool: "${toolName}"`] };
@@ -331,24 +351,23 @@ export function createToolRegistry() {
331
351
  }
332
352
 
333
353
  const schema = tool.parameters;
334
- if (!schema || !schema.properties) {
354
+ if (!schema.properties) {
335
355
  return { valid: true };
336
356
  }
337
357
 
338
358
  const errors: string[] = [];
339
359
  const props = schema.properties;
340
360
  const required = schema.required || [];
341
- const argRecord = args as Record<string, unknown>;
342
361
 
343
362
  // Check required properties
344
363
  for (const key of required) {
345
- if (!(key in args) || argRecord[key] === undefined || argRecord[key] === null) {
364
+ if (!(key in args) || args[key] === undefined || args[key] === null) {
346
365
  errors.push(`Missing required argument: "${key}"`);
347
366
  }
348
367
  }
349
368
 
350
369
  // Check property types (basic)
351
- for (const [key, value] of Object.entries(argRecord)) {
370
+ for (const [key, value] of Object.entries(args)) {
352
371
  const propSchema = props[key];
353
372
  if (!propSchema) {
354
373
  // Unknown property — warn but don't fail (LLM might send extra)
@@ -360,14 +379,14 @@ export function createToolRegistry() {
360
379
  continue;
361
380
  }
362
381
 
363
- const actualType = Array.isArray(value) ? "array" : typeof value;
382
+ const actualType = argumentType(value);
364
383
 
365
384
  // Number / integer type validation with string coercion
366
385
  if (expectedType === "number" || expectedType === "integer") {
367
- if (actualType === "number") {
386
+ if (isOfType(value, "number")) {
368
387
  continue;
369
388
  }
370
- if (actualType === "string") {
389
+ if (isOfType(value, "string")) {
371
390
  if (!Number.isNaN(Number(value))) {
372
391
  continue;
373
392
  }
@@ -396,11 +415,11 @@ export function createToolRegistry() {
396
415
  * Execute a tool by name. Validates arguments before execution.
397
416
  *
398
417
  * @param {string} toolName
399
- * @param {object} args
418
+ * @param {ToolArguments} args
400
419
  * @param {ToolContext} [ctx] - The call's context; omitted, a detached one
401
420
  * @returns {Promise<ToolResult>}
402
421
  */
403
- async execute(toolName: string, args: object, ctx: ToolContext = createToolContext()) {
422
+ async execute(toolName: string, args: ToolArguments, ctx: ToolContext = createToolContext()) {
404
423
  const tool = _tools.get(toolName);
405
424
  if (!tool) {
406
425
  return toolError(`Unknown tool: "${toolName}"`);
@@ -415,7 +434,7 @@ export function createToolRegistry() {
415
434
  try {
416
435
  return await tool.execute(args, ctx);
417
436
  } catch (error) {
418
- return toolError(`Tool "${toolName}" execution error: ${(error as Error).message}`);
437
+ return toolError(`Tool "${toolName}" execution error: ${messageOf(error)}`);
419
438
  }
420
439
  },
421
440
  };
@@ -446,10 +465,81 @@ export interface InvokeOutcome {
446
465
  * @returns {ToolResult}
447
466
  */
448
467
  export function normalizeToolResult(result: ToolResult): ToolResult {
468
+ /* SAFETY: `success`, `error` and `summary` are a boolean and strings, which the round trip keeps
469
+ as they were (an undefined one is dropped, and each is optional but `success`); `data` becomes
470
+ its JSON form, and ToolResult does not constrain `data`. */
449
471
  // oxlint-disable-next-line unicorn/prefer-structured-clone -- the JSON round trip is the point
450
472
  return JSON.parse(JSON.stringify(result)) as ToolResult;
451
473
  }
452
474
 
475
+ /**
476
+ * The `message` of what a tool, a registry or a JSON round trip threw, as the result texts quote
477
+ * it.
478
+ *
479
+ * @param {unknown} cause - What was thrown
480
+ * @returns {string}
481
+ */
482
+ function messageOf(cause: unknown): string {
483
+ /* SAFETY: only `message` is read. Every throw in reach is an Error (JSON.parse's SyntaxError,
484
+ parseArguments' TypeError, a tool's own Error); a tool that throws a non-null non-Error reads
485
+ it as undefined, which the result text has always quoted as "undefined". */
486
+ return (cause as Error).message;
487
+ }
488
+
489
+ /** The types an argument's mismatch sentence names by `typeof`, each with the value it names. */
490
+ interface PrimitiveTypes {
491
+ string: string;
492
+ number: number;
493
+ boolean: boolean;
494
+ undefined: undefined;
495
+ }
496
+
497
+ /** The {@link PrimitiveTypes} names, in the order {@link argumentType} tries them. */
498
+ const PRIMITIVE_TYPES = [
499
+ "string",
500
+ "number",
501
+ "boolean",
502
+ "undefined",
503
+ ] as const satisfies readonly (keyof PrimitiveTypes)[];
504
+
505
+ /**
506
+ * True when an argument is of the named primitive type.
507
+ *
508
+ * @param {JsonValue | undefined} value - An argument, as sent
509
+ * @param {keyof PrimitiveTypes} type - The type to test for
510
+ * @returns {boolean}
511
+ */
512
+ function isOfType<T extends keyof PrimitiveTypes>(
513
+ value: JsonValue | undefined,
514
+ type: T,
515
+ ): value is PrimitiveTypes[T] {
516
+ return typeof value === type;
517
+ }
518
+
519
+ /**
520
+ * True when a JSON value is an object: not `null`, and not an array.
521
+ *
522
+ * @param {JsonValue | undefined} value - Parsed arguments
523
+ * @returns {boolean}
524
+ */
525
+ function isArgumentObject(value: JsonValue | undefined): value is JsonObject {
526
+ return typeof value === "object" && value !== null && !Array.isArray(value);
527
+ }
528
+
529
+ /**
530
+ * The type an argument's mismatch sentence names: its `typeof`, except that an array is `array`.
531
+ * `null` is `object`, as `typeof` has always named it here.
532
+ *
533
+ * @param {JsonValue | undefined} value - An argument, as sent
534
+ * @returns {string}
535
+ */
536
+ function argumentType(value: JsonValue | undefined): string {
537
+ if (Array.isArray(value)) {
538
+ return "array";
539
+ }
540
+ return PRIMITIVE_TYPES.find((type) => isOfType(value, type)) ?? "object";
541
+ }
542
+
453
543
  /**
454
544
  * A call's arguments as the object a tool takes.
455
545
  *
@@ -457,13 +547,15 @@ export function normalizeToolResult(result: ToolResult): ToolResult {
457
547
  * array, a number) is refused with its own sentence rather than reaching the registry, where a
458
548
  * validator reading a property of `null` threw a message about the validator.
459
549
  */
460
- function parseArguments(text: string): object {
550
+ function parseArguments(text: string): ToolArguments {
461
551
  if (!text) {
462
552
  return {};
463
553
  }
464
- const parsed = JSON.parse(text) as unknown;
465
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
466
- const type = parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed;
554
+ /* SAFETY: JSON.parse without a reviver only ever produces null, booleans, numbers, strings,
555
+ arrays and plain objects, which is exactly JsonValue. */
556
+ const parsed = JSON.parse(text) as JsonValue;
557
+ if (!isArgumentObject(parsed)) {
558
+ const type = parsed === null ? "null" : argumentType(parsed);
467
559
  throw new TypeError(`arguments must be a JSON object, got ${type}`);
468
560
  }
469
561
  return parsed;
@@ -496,14 +588,14 @@ export async function invokeTool(
496
588
  executed = true;
497
589
  result = await registry.execute(call.name, args, ctx);
498
590
  } catch (error) {
499
- result = toolError(`Failed to parse arguments: ${(error as Error).message}`);
591
+ result = toolError(`Failed to parse arguments: ${messageOf(error)}`);
500
592
  }
501
593
  try {
502
594
  return { status: "done", result: normalizeToolResult(result), executed };
503
595
  } catch (error) {
504
596
  /* A result JSON cannot carry (a cycle, a BigInt) cannot be sent back to the model either. It
505
597
  used to throw out of the turn; the model is told instead, and the turn goes on. */
506
- const reason = (error as Error).message;
598
+ const reason = messageOf(error);
507
599
  return {
508
600
  status: "done",
509
601
  result: toolError(