@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/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/harness/run.ts
CHANGED
|
@@ -57,18 +57,33 @@ type EventBody = HarnessEvent extends infer E
|
|
|
57
57
|
: never
|
|
58
58
|
: never;
|
|
59
59
|
|
|
60
|
+
/** True when a member name of a usage frame is one of the counts {@link Usage} names. */
|
|
61
|
+
function isUsageField(key: string): key is keyof Usage {
|
|
62
|
+
return (
|
|
63
|
+
key === "inputTokens" ||
|
|
64
|
+
key === "outputTokens" ||
|
|
65
|
+
key === "cachedInputTokens" ||
|
|
66
|
+
key === "reasoningTokens"
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True when a count a backend's usage frame carries is a number, which the frame does not prove. */
|
|
71
|
+
function isCount(value: unknown): value is number {
|
|
72
|
+
return typeof value === "number";
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
/** Two usage counts summed field by field; a field either one reports is reported. */
|
|
61
76
|
function addUsage(total: Usage | null, next: Usage): Usage {
|
|
62
77
|
if (total === null) {
|
|
63
78
|
return { ...next };
|
|
64
79
|
}
|
|
65
|
-
const sum:
|
|
80
|
+
const sum: Usage = { ...total };
|
|
66
81
|
for (const [key, value] of Object.entries(next)) {
|
|
67
|
-
if (
|
|
82
|
+
if (isUsageField(key) && isCount(value)) {
|
|
68
83
|
sum[key] = (sum[key] ?? 0) + value;
|
|
69
84
|
}
|
|
70
85
|
}
|
|
71
|
-
return sum
|
|
86
|
+
return sum;
|
|
72
87
|
}
|
|
73
88
|
|
|
74
89
|
/**
|
|
@@ -103,7 +118,8 @@ function createEventQueue() {
|
|
|
103
118
|
},
|
|
104
119
|
async *read(): AsyncGenerator<HarnessEvent> {
|
|
105
120
|
let index = 0;
|
|
106
|
-
|
|
121
|
+
// `push` and `close` change what this loop reads from outside it, so it exits from within.
|
|
122
|
+
for (;;) {
|
|
107
123
|
if (index < events.length) {
|
|
108
124
|
yield events[index]!;
|
|
109
125
|
index += 1;
|
|
@@ -154,6 +170,9 @@ export function runTurn(input: TurnInput): TurnRun {
|
|
|
154
170
|
};
|
|
155
171
|
}
|
|
156
172
|
const { signal } = controller;
|
|
173
|
+
/* Read through a call: the signal aborts from outside, between awaits, which narrowing on
|
|
174
|
+
`signal.aborted` cannot see. */
|
|
175
|
+
const aborted = (): boolean => signal.aborted;
|
|
157
176
|
const settle = () => {
|
|
158
177
|
unlink();
|
|
159
178
|
release();
|
|
@@ -163,13 +182,10 @@ export function runTurn(input: TurnInput): TurnRun {
|
|
|
163
182
|
const session = input.session ?? createSessionFacts();
|
|
164
183
|
const ledger = input.ledger ?? createLedger();
|
|
165
184
|
const sessionId = input.sessionId === undefined ? session.sessionId : input.sessionId;
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
kind: "assistant",
|
|
169
|
-
|
|
170
|
-
sessionId,
|
|
171
|
-
turnId,
|
|
172
|
-
};
|
|
185
|
+
const actorId = `assistant:${sessionId ?? "local"}:${turnId}`;
|
|
186
|
+
const actor: Actor = input.modelInfo
|
|
187
|
+
? { id: actorId, kind: "assistant", model: input.modelInfo.model, sessionId, turnId }
|
|
188
|
+
: { id: actorId, kind: "assistant", sessionId, turnId };
|
|
173
189
|
|
|
174
190
|
const queue = createEventQueue();
|
|
175
191
|
let seq = 0;
|
|
@@ -181,6 +197,8 @@ export function runTurn(input: TurnInput): TurnRun {
|
|
|
181
197
|
return;
|
|
182
198
|
}
|
|
183
199
|
ended = body.type === "turn_end";
|
|
200
|
+
/* SAFETY: EventBody is HarnessEvent with exactly `v`, `seq` and `turnId` omitted, member by
|
|
201
|
+
member, and this adds those three back, so the result is the member `body` came from. */
|
|
184
202
|
const event = { v: 1, seq, turnId, ...body } as HarnessEvent;
|
|
185
203
|
seq += 1;
|
|
186
204
|
input.onEvent?.(event);
|
|
@@ -320,10 +338,9 @@ export function runTurn(input: TurnInput): TurnRun {
|
|
|
320
338
|
|
|
321
339
|
const truncated = !sawDone;
|
|
322
340
|
if (errorMessage) {
|
|
323
|
-
const error: TurnError =
|
|
324
|
-
message: errorMessage,
|
|
325
|
-
|
|
326
|
-
};
|
|
341
|
+
const error: TurnError = errorCode
|
|
342
|
+
? { message: errorMessage, code: errorCode }
|
|
343
|
+
: { message: errorMessage };
|
|
327
344
|
input.hooks?.onStreamError?.(error);
|
|
328
345
|
emit({ type: "round_end", messageId, stopReason, truncated, dropped: true, error });
|
|
329
346
|
return finish("error", { error, removedMessageId: messageId });
|
|
@@ -331,7 +348,7 @@ export function runTurn(input: TurnInput): TurnRun {
|
|
|
331
348
|
|
|
332
349
|
/* A turn that has drawn nothing by the end of a round with no calls ends here. A stopped
|
|
333
350
|
round is not empty: the author ended it. */
|
|
334
|
-
const stopped = stopReason === "cancelled" ||
|
|
351
|
+
const stopped = stopReason === "cancelled" || aborted();
|
|
335
352
|
const drawn = text !== "" || records.length > 0;
|
|
336
353
|
if (calls.size === 0 && !stopped && !drawnBefore && anchor === null && !drawn) {
|
|
337
354
|
emit({ type: "round_end", messageId, stopReason, truncated, dropped: true });
|
|
@@ -353,13 +370,15 @@ export function runTurn(input: TurnInput): TurnRun {
|
|
|
353
370
|
for (const record of records) {
|
|
354
371
|
blocks.push({ type: "tool_call", ...record });
|
|
355
372
|
}
|
|
356
|
-
|
|
373
|
+
const drawnMessage: ChatMessage = {
|
|
357
374
|
id: messageId,
|
|
358
375
|
role: "assistant",
|
|
359
376
|
blocks,
|
|
360
377
|
timestamp: now(),
|
|
361
|
-
|
|
362
|
-
|
|
378
|
+
};
|
|
379
|
+
appended.push(
|
|
380
|
+
truncated ? { ...drawnMessage, meta: { incomplete: "truncated" } } : drawnMessage,
|
|
381
|
+
);
|
|
363
382
|
anchor = messageId;
|
|
364
383
|
}
|
|
365
384
|
if (calls.size === 0 || stopped) {
|
|
@@ -368,7 +387,7 @@ export function runTurn(input: TurnInput): TurnRun {
|
|
|
368
387
|
|
|
369
388
|
let didWork = false;
|
|
370
389
|
for (const [callId, call] of calls) {
|
|
371
|
-
if (
|
|
390
|
+
if (aborted()) {
|
|
372
391
|
return finish("cancelled");
|
|
373
392
|
}
|
|
374
393
|
emit({
|
|
@@ -437,7 +456,7 @@ export function runTurn(input: TurnInput): TurnRun {
|
|
|
437
456
|
workRounds += 1;
|
|
438
457
|
}
|
|
439
458
|
// A Stop that landed during the round's last call opens no further round.
|
|
440
|
-
if (
|
|
459
|
+
if (aborted()) {
|
|
441
460
|
return finish("cancelled");
|
|
442
461
|
}
|
|
443
462
|
}
|
|
@@ -459,14 +478,14 @@ export function runTurn(input: TurnInput): TurnRun {
|
|
|
459
478
|
}
|
|
460
479
|
|
|
461
480
|
const outcome = loop().finally(settle);
|
|
462
|
-
|
|
463
|
-
|
|
481
|
+
void (async () => {
|
|
482
|
+
try {
|
|
483
|
+
await outcome;
|
|
464
484
|
queue.close();
|
|
465
|
-
}
|
|
466
|
-
(error: unknown) => {
|
|
485
|
+
} catch (error) {
|
|
467
486
|
queue.close({ error });
|
|
468
|
-
}
|
|
469
|
-
);
|
|
487
|
+
}
|
|
488
|
+
})();
|
|
470
489
|
|
|
471
490
|
return {
|
|
472
491
|
turnId,
|
package/src/index.ts
CHANGED
package/src/messages/convert.ts
CHANGED
|
@@ -20,25 +20,42 @@ import type {
|
|
|
20
20
|
WireMessage,
|
|
21
21
|
} from "./types.ts";
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* True when an unchecked value (a v1 field, a `tools` listing's member) is a string. A v1 field
|
|
25
|
+
* that is anything else is absent (see {@link blocksFromFields}).
|
|
26
|
+
*/
|
|
27
|
+
export function isText(value: unknown): value is string {
|
|
28
|
+
return typeof value === "string";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A v1 tool call as a restored session may hold it: never checked, so any member may be missing or
|
|
33
|
+
* hold anything.
|
|
34
|
+
*/
|
|
35
|
+
interface StoredToolCall {
|
|
36
|
+
readonly id?: unknown;
|
|
37
|
+
readonly name?: unknown;
|
|
38
|
+
readonly arguments?: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* True when an unchecked value (a stored tool call, a parsed tool reply, a `tools` listing's
|
|
43
|
+
* member) is an object whose members may be read; null and every primitive are not.
|
|
44
|
+
*/
|
|
45
|
+
export function isMemberBag(value: unknown): value is object {
|
|
46
|
+
return typeof value === "object" && value !== null;
|
|
47
|
+
}
|
|
48
|
+
|
|
23
49
|
/** Whether a tool reply's content is a result that failed. Unparseable content is not. */
|
|
24
50
|
function isErrorContent(content: string): boolean {
|
|
25
51
|
try {
|
|
26
|
-
const parsed = JSON.parse(content)
|
|
27
|
-
return (
|
|
28
|
-
typeof parsed === "object" &&
|
|
29
|
-
parsed !== null &&
|
|
30
|
-
(parsed as { success?: unknown }).success === false
|
|
31
|
-
);
|
|
52
|
+
const parsed: unknown = JSON.parse(content);
|
|
53
|
+
return isMemberBag(parsed) && "success" in parsed && parsed.success === false;
|
|
32
54
|
} catch {
|
|
33
55
|
return false;
|
|
34
56
|
}
|
|
35
57
|
}
|
|
36
58
|
|
|
37
|
-
/** A v1 value read as text: a string is itself, and anything else is absent. */
|
|
38
|
-
function asText(value: unknown): string {
|
|
39
|
-
return typeof value === "string" ? value : "";
|
|
40
|
-
}
|
|
41
|
-
|
|
42
59
|
/**
|
|
43
60
|
* The blocks a message's v1 fields describe, in the order a provider streams them.
|
|
44
61
|
*
|
|
@@ -48,46 +65,81 @@ function asText(value: unknown): string {
|
|
|
48
65
|
*/
|
|
49
66
|
function blocksFromFields(message: LiveMessage): Block[] {
|
|
50
67
|
if (message.role === "tool") {
|
|
51
|
-
const content =
|
|
68
|
+
const content = isText(message.content) ? message.content : "";
|
|
52
69
|
return [
|
|
53
70
|
{
|
|
54
71
|
type: "tool_result",
|
|
55
|
-
callId:
|
|
72
|
+
callId: isText(message.toolCallId) ? message.toolCallId : "",
|
|
56
73
|
isError: isErrorContent(content),
|
|
57
74
|
content,
|
|
58
75
|
},
|
|
59
76
|
];
|
|
60
77
|
}
|
|
61
78
|
const blocks: Block[] = [];
|
|
62
|
-
if (message.role === "assistant" &&
|
|
79
|
+
if (message.role === "assistant" && isText(message.reasoningContent)) {
|
|
63
80
|
blocks.push({ type: "reasoning", text: message.reasoningContent });
|
|
64
81
|
}
|
|
65
|
-
if (
|
|
82
|
+
if (isText(message.content) && message.content) {
|
|
66
83
|
blocks.push({ type: "text", text: message.content });
|
|
67
84
|
}
|
|
68
85
|
if (message.role === "assistant" && Array.isArray(message.toolCalls)) {
|
|
69
|
-
|
|
70
|
-
|
|
86
|
+
// A restored session's calls are unchecked, so each is read as what it may really be.
|
|
87
|
+
const stored: readonly unknown[] = message.toolCalls;
|
|
88
|
+
for (const call of stored) {
|
|
89
|
+
if (!isMemberBag(call)) {
|
|
71
90
|
continue;
|
|
72
91
|
}
|
|
73
|
-
|
|
92
|
+
// Every member of StoredToolCall is optional and `unknown`, so any object is one.
|
|
93
|
+
const { id, name, arguments: args }: StoredToolCall = call;
|
|
74
94
|
blocks.push({
|
|
75
95
|
type: "tool_call",
|
|
76
|
-
id:
|
|
77
|
-
name:
|
|
78
|
-
argumentsText:
|
|
96
|
+
id: isText(id) ? id : "",
|
|
97
|
+
name: isText(name) ? name : "",
|
|
98
|
+
argumentsText: isText(args) ? args : "",
|
|
79
99
|
});
|
|
80
100
|
}
|
|
81
101
|
}
|
|
82
102
|
return blocks;
|
|
83
103
|
}
|
|
84
104
|
|
|
105
|
+
/**
|
|
106
|
+
* A value {@link sameJson} walks: JSON, in which an object member may also be `undefined` (an
|
|
107
|
+
* optional member left unset), which counts as absent.
|
|
108
|
+
*/
|
|
109
|
+
type Walked = null | boolean | number | string | Walked[] | WalkedObject;
|
|
110
|
+
|
|
111
|
+
/** An object {@link sameJson} walks. */
|
|
112
|
+
interface WalkedObject {
|
|
113
|
+
readonly [member: string]: Walked | undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** True when a walked value is an array or an object rather than a primitive or `null`. */
|
|
117
|
+
function isComposite(value: Walked | undefined): value is Walked[] | WalkedObject {
|
|
118
|
+
return typeof value === "object" && value !== null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A block as the JSON {@link sameJson} walks: a shallow copy, which carries every member (an
|
|
123
|
+
* undefined one included) and so compares exactly as the block itself would. A reasoning block's
|
|
124
|
+
* `provenance` is copied too, since it is the one member that is itself an object.
|
|
125
|
+
*
|
|
126
|
+
* @param {Block} block
|
|
127
|
+
* @returns {WalkedObject}
|
|
128
|
+
*/
|
|
129
|
+
function walkedBlock(block: Block): WalkedObject {
|
|
130
|
+
if (block.type !== "reasoning") {
|
|
131
|
+
return { ...block };
|
|
132
|
+
}
|
|
133
|
+
const { provenance, ...rest } = block;
|
|
134
|
+
return provenance === undefined ? rest : { ...rest, provenance: { ...provenance } };
|
|
135
|
+
}
|
|
136
|
+
|
|
85
137
|
/** Whether two JSON values are equal, member order aside. */
|
|
86
|
-
function sameJson(a:
|
|
138
|
+
function sameJson(a: Walked | undefined, b: Walked | undefined): boolean {
|
|
87
139
|
if (Object.is(a, b)) {
|
|
88
140
|
return true;
|
|
89
141
|
}
|
|
90
|
-
if (
|
|
142
|
+
if (!isComposite(a) || !isComposite(b)) {
|
|
91
143
|
return false;
|
|
92
144
|
}
|
|
93
145
|
if (Array.isArray(a) || Array.isArray(b)) {
|
|
@@ -100,10 +152,7 @@ function sameJson(a: unknown, b: unknown): boolean {
|
|
|
100
152
|
}
|
|
101
153
|
const left = Object.entries(a).filter(([, value]) => value !== undefined);
|
|
102
154
|
const right = Object.entries(b).filter(([, value]) => value !== undefined);
|
|
103
|
-
return (
|
|
104
|
-
left.length === right.length &&
|
|
105
|
-
left.every(([key, value]) => sameJson(value, (b as Record<string, unknown>)[key]))
|
|
106
|
-
);
|
|
155
|
+
return left.length === right.length && left.every(([key, value]) => sameJson(value, b[key]));
|
|
107
156
|
}
|
|
108
157
|
|
|
109
158
|
/** The concatenated text of a message's blocks of one kind. */
|
|
@@ -131,6 +180,7 @@ export function isEmptyAssistant(message: ChatMessage | LiveMessage): boolean {
|
|
|
131
180
|
if (message.role !== "assistant") {
|
|
132
181
|
return false;
|
|
133
182
|
}
|
|
183
|
+
// SAFETY: a ChatMessage always carries `blocks`, so a message without them is a LiveMessage.
|
|
134
184
|
const blocks = message.blocks ?? blocksFromFields(message as LiveMessage);
|
|
135
185
|
return !blocks.some(
|
|
136
186
|
(block) => block.type === "tool_call" || (block.type === "text" && block.text !== ""),
|
|
@@ -145,13 +195,12 @@ export function isEmptyAssistant(message: ChatMessage | LiveMessage): boolean {
|
|
|
145
195
|
* @returns {ChatMessage[]}
|
|
146
196
|
*/
|
|
147
197
|
export function toChatMessages(live: readonly LiveMessage[]): ChatMessage[] {
|
|
148
|
-
return live.map((message) =>
|
|
149
|
-
id
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}));
|
|
198
|
+
return live.map((message): ChatMessage => {
|
|
199
|
+
const { id, role, timestamp, meta } = message;
|
|
200
|
+
const blocks = message.blocks ? [...message.blocks] : blocksFromFields(message);
|
|
201
|
+
const chat: ChatMessage = { id, role, blocks, timestamp };
|
|
202
|
+
return meta ? { ...chat, meta } : chat;
|
|
203
|
+
});
|
|
155
204
|
}
|
|
156
205
|
|
|
157
206
|
/**
|
|
@@ -192,7 +241,13 @@ export function toLiveMessages(messages: readonly ChatMessage[]): LiveMessage[]
|
|
|
192
241
|
}));
|
|
193
242
|
}
|
|
194
243
|
}
|
|
195
|
-
|
|
244
|
+
const rebuilt = blocksFromFields(live).map((block) => walkedBlock(block));
|
|
245
|
+
if (
|
|
246
|
+
!sameJson(
|
|
247
|
+
rebuilt,
|
|
248
|
+
message.blocks.map((block) => walkedBlock(block)),
|
|
249
|
+
)
|
|
250
|
+
) {
|
|
196
251
|
live.blocks = [...message.blocks];
|
|
197
252
|
}
|
|
198
253
|
if (message.meta) {
|
|
@@ -210,7 +265,7 @@ export function toLiveMessages(messages: readonly ChatMessage[]): LiveMessage[]
|
|
|
210
265
|
* @returns {WireMessage[]}
|
|
211
266
|
*/
|
|
212
267
|
export function toWireMessages(messages: readonly ChatMessage[]): WireMessage[] {
|
|
213
|
-
return messages
|
|
214
|
-
|
|
215
|
-
|
|
268
|
+
return messages.flatMap((message): WireMessage[] =>
|
|
269
|
+
isEmptyAssistant(message) ? [] : [{ role: message.role, blocks: message.blocks }],
|
|
270
|
+
);
|
|
216
271
|
}
|
package/src/messages/openai.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* @license MIT
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import type { JSONSchema } from "../tools.ts";
|
|
13
|
+
import { isEmptyAssistant, isMemberBag, isText } from "./convert.ts";
|
|
13
14
|
import type {
|
|
14
15
|
ChatMessage,
|
|
15
16
|
OpenAIMessage,
|
|
@@ -113,15 +114,34 @@ export function toOpenAIMessages(messages: readonly ChatMessage[]): OpenAIMessag
|
|
|
113
114
|
* @returns {OpenAITool[]}
|
|
114
115
|
*/
|
|
115
116
|
export function toOpenAITools(tools: readonly ToolSpec[]): OpenAITool[] {
|
|
116
|
-
return tools.map((tool) =>
|
|
117
|
-
|
|
118
|
-
function: {
|
|
117
|
+
return tools.map((tool): OpenAITool => {
|
|
118
|
+
const fn: OpenAITool["function"] = {
|
|
119
119
|
name: tool.name,
|
|
120
120
|
description: tool.description,
|
|
121
121
|
parameters: tool.inputSchema,
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
};
|
|
123
|
+
if (tool.strict === true) {
|
|
124
|
+
fn.strict = true;
|
|
125
|
+
}
|
|
126
|
+
return { type: "function", function: fn };
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A `tools` entry as {@link fromOpenAITools} reads it: any object, whose members are checked before
|
|
132
|
+
* they are used.
|
|
133
|
+
*/
|
|
134
|
+
interface ListedTool {
|
|
135
|
+
readonly type?: unknown;
|
|
136
|
+
readonly function?: unknown;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A `tools` entry's `function` member, read the same way. */
|
|
140
|
+
interface ListedFunction {
|
|
141
|
+
readonly name?: unknown;
|
|
142
|
+
readonly description?: unknown;
|
|
143
|
+
readonly parameters?: unknown;
|
|
144
|
+
readonly strict?: unknown;
|
|
125
145
|
}
|
|
126
146
|
|
|
127
147
|
/**
|
|
@@ -133,28 +153,29 @@ export function toOpenAITools(tools: readonly ToolSpec[]): OpenAITool[] {
|
|
|
133
153
|
* @throws {TypeError} When an entry is not a function tool with a name, a description and a schema
|
|
134
154
|
*/
|
|
135
155
|
export function fromOpenAITools(tools: readonly object[]): ToolSpec[] {
|
|
136
|
-
return tools.map((tool, index) => {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
156
|
+
return tools.map((tool, index): ToolSpec => {
|
|
157
|
+
// Every member of ListedTool and ListedFunction is optional and `unknown`: any object is one.
|
|
158
|
+
const listed: ListedTool = tool;
|
|
159
|
+
const fn: ListedFunction | undefined = isMemberBag(listed.function)
|
|
160
|
+
? listed.function
|
|
161
|
+
: undefined;
|
|
140
162
|
if (
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
fn
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
typeof fn.parameters !== "object" ||
|
|
147
|
-
fn.parameters === null
|
|
163
|
+
listed.type !== "function" ||
|
|
164
|
+
fn === undefined ||
|
|
165
|
+
!isText(fn.name) ||
|
|
166
|
+
!isText(fn.description) ||
|
|
167
|
+
!isMemberBag(fn.parameters)
|
|
148
168
|
) {
|
|
149
169
|
throw new TypeError(
|
|
150
170
|
`tools[${index}] is not an OpenAI function tool with a name, a description and parameters`,
|
|
151
171
|
);
|
|
152
172
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
173
|
+
/* SAFETY: `parameters` was just checked to be a non-null object, and it is kept by reference
|
|
174
|
+
and never read here: it is the schema the listing's registry was given as a JSONSchema. */
|
|
175
|
+
const inputSchema = fn.parameters as Readonly<JSONSchema>;
|
|
176
|
+
if (fn.strict === true) {
|
|
177
|
+
return { name: fn.name, description: fn.description, inputSchema, strict: true };
|
|
178
|
+
}
|
|
179
|
+
return { name: fn.name, description: fn.description, inputSchema };
|
|
159
180
|
});
|
|
160
181
|
}
|
package/src/messages/types.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import type { JsonValue, ToolResult } from "../core-types.ts";
|
|
20
|
+
import type { JSONSchema } from "../tools.ts";
|
|
20
21
|
|
|
21
22
|
export type { JsonArray, JsonObject, JsonValue } from "../core-types.ts";
|
|
22
23
|
|
|
@@ -167,7 +168,7 @@ export interface ToolSpec {
|
|
|
167
168
|
readonly title?: string;
|
|
168
169
|
readonly description: string;
|
|
169
170
|
/** JSON Schema for the arguments, by reference: a host may build it with live getters. */
|
|
170
|
-
readonly inputSchema: Readonly<
|
|
171
|
+
readonly inputSchema: Readonly<JSONSchema>;
|
|
171
172
|
/** OpenAI's strict mode, opted into per tool. */
|
|
172
173
|
readonly strict?: boolean;
|
|
173
174
|
}
|
|
@@ -196,7 +197,7 @@ export interface OpenAITool {
|
|
|
196
197
|
function: {
|
|
197
198
|
name: string;
|
|
198
199
|
description: string;
|
|
199
|
-
parameters: Readonly<
|
|
200
|
+
parameters: Readonly<JSONSchema>;
|
|
200
201
|
strict?: true;
|
|
201
202
|
};
|
|
202
203
|
}
|