@jxsuite/ai 0.39.3 → 0.39.4
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 +7 -7
- package/package.json +5 -5
- package/src/chat-state.ts +24 -19
- package/src/gateway/chat.ts +17 -1
- package/src/gateway/index.ts +1 -1
- package/src/gateway/normalize.ts +54 -53
- package/src/gateway/problem.ts +33 -21
- package/src/gateway/sse.ts +2 -2
- package/src/gateway/upstream-error.ts +50 -18
- package/src/harness/index.ts +1 -1
- package/src/harness/policy.ts +1 -1
- package/src/harness/run.ts +46 -27
- package/src/index.ts +1 -0
- package/src/messages/convert.ts +93 -38
- package/src/messages/openai.ts +45 -24
- package/src/messages/types.ts +3 -2
- package/src/streaming-client.ts +181 -111
- package/src/tools.ts +144 -52
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|
|
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:
|
|
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:
|
|
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:
|
|
151
|
+
execute: (args: ToolArguments, ctx: ToolContext) => Promise<ToolResult> | ToolResult;
|
|
135
152
|
}): ToolDefinition {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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 {
|
|
340
|
+
* @param {ToolArguments} args
|
|
321
341
|
* @returns {{ valid: boolean; errors?: string[] }}
|
|
322
342
|
*/
|
|
323
|
-
validate(toolName: string, args:
|
|
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
|
|
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) ||
|
|
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(
|
|
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 =
|
|
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 (
|
|
386
|
+
if (isOfType(value, "number")) {
|
|
368
387
|
continue;
|
|
369
388
|
}
|
|
370
|
-
if (
|
|
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 {
|
|
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:
|
|
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
|
|
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):
|
|
550
|
+
function parseArguments(text: string): ToolArguments {
|
|
461
551
|
if (!text) {
|
|
462
552
|
return {};
|
|
463
553
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
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
|
|
598
|
+
const reason = messageOf(error);
|
|
507
599
|
return {
|
|
508
600
|
status: "done",
|
|
509
601
|
result: toolError(
|