assistant-stream 0.2.47 → 0.3.1
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/dist/core/converters/index.d.ts +2 -0
- package/dist/core/converters/index.d.ts.map +1 -0
- package/dist/core/converters/index.js +2 -0
- package/dist/core/converters/index.js.map +1 -0
- package/dist/core/converters/toGenericMessages.d.ts +71 -0
- package/dist/core/converters/toGenericMessages.d.ts.map +1 -0
- package/dist/core/converters/toGenericMessages.js +155 -0
- package/dist/core/converters/toGenericMessages.js.map +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/tool/index.d.ts +1 -0
- package/dist/core/tool/index.d.ts.map +1 -1
- package/dist/core/tool/index.js +1 -0
- package/dist/core/tool/index.js.map +1 -1
- package/dist/core/tool/schema-utils.d.ts +32 -0
- package/dist/core/tool/schema-utils.d.ts.map +1 -0
- package/dist/core/tool/schema-utils.js +67 -0
- package/dist/core/tool/schema-utils.js.map +1 -0
- package/package.json +2 -2
- package/src/core/converters/index.ts +12 -0
- package/src/core/converters/toGenericMessages.test.ts +721 -0
- package/src/core/converters/toGenericMessages.ts +264 -0
- package/src/core/index.ts +2 -0
- package/src/core/tool/index.ts +6 -0
- package/src/core/tool/schema-utils.test.ts +382 -0
- package/src/core/tool/schema-utils.ts +112 -0
- package/src/core/tool/toolResultStream.test.ts +1 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic message types for framework-agnostic LLM message interchange.
|
|
3
|
+
* These types represent a common format that can be converted to/from
|
|
4
|
+
* various LLM provider formats (AI SDK, LangChain, etc.).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type GenericTextPart = {
|
|
8
|
+
type: "text";
|
|
9
|
+
text: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type GenericFilePart = {
|
|
13
|
+
type: "file";
|
|
14
|
+
data: string | URL;
|
|
15
|
+
mediaType: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type GenericToolCallPart = {
|
|
19
|
+
type: "tool-call";
|
|
20
|
+
toolCallId: string;
|
|
21
|
+
toolName: string;
|
|
22
|
+
args: Record<string, unknown>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type GenericToolResultPart = {
|
|
26
|
+
type: "tool-result";
|
|
27
|
+
toolCallId: string;
|
|
28
|
+
toolName: string;
|
|
29
|
+
result: unknown;
|
|
30
|
+
isError?: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type GenericSystemMessage = {
|
|
34
|
+
role: "system";
|
|
35
|
+
content: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type GenericUserMessage = {
|
|
39
|
+
role: "user";
|
|
40
|
+
content: (GenericTextPart | GenericFilePart)[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type GenericAssistantMessage = {
|
|
44
|
+
role: "assistant";
|
|
45
|
+
content: (GenericTextPart | GenericToolCallPart)[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type GenericToolMessage = {
|
|
49
|
+
role: "tool";
|
|
50
|
+
content: GenericToolResultPart[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type GenericMessage =
|
|
54
|
+
| GenericSystemMessage
|
|
55
|
+
| GenericUserMessage
|
|
56
|
+
| GenericAssistantMessage
|
|
57
|
+
| GenericToolMessage;
|
|
58
|
+
|
|
59
|
+
type MessagePartLike = {
|
|
60
|
+
type: string;
|
|
61
|
+
text?: string;
|
|
62
|
+
image?: string;
|
|
63
|
+
data?: string;
|
|
64
|
+
mimeType?: string;
|
|
65
|
+
toolCallId?: string;
|
|
66
|
+
toolName?: string;
|
|
67
|
+
args?: Record<string, unknown>;
|
|
68
|
+
result?: unknown;
|
|
69
|
+
isError?: boolean;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type AttachmentLike = {
|
|
73
|
+
content: readonly MessagePartLike[];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type ThreadMessageLike = {
|
|
77
|
+
role: "system" | "user" | "assistant";
|
|
78
|
+
content: readonly MessagePartLike[];
|
|
79
|
+
attachments?: readonly AttachmentLike[];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const IMAGE_MEDIA_TYPES: Record<string, string> = {
|
|
83
|
+
jpg: "image/jpeg",
|
|
84
|
+
jpeg: "image/jpeg",
|
|
85
|
+
png: "image/png",
|
|
86
|
+
gif: "image/gif",
|
|
87
|
+
webp: "image/webp",
|
|
88
|
+
svg: "image/svg+xml",
|
|
89
|
+
avif: "image/avif",
|
|
90
|
+
bmp: "image/bmp",
|
|
91
|
+
ico: "image/x-icon",
|
|
92
|
+
tiff: "image/tiff",
|
|
93
|
+
tif: "image/tiff",
|
|
94
|
+
heic: "image/heic",
|
|
95
|
+
heif: "image/heif",
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
function inferImageMediaType(url: string): string {
|
|
99
|
+
// Handle data URLs: data:[<mediatype>][;base64],<data>
|
|
100
|
+
if (url.startsWith("data:")) {
|
|
101
|
+
const match = url.match(/^data:([^;,]+)/);
|
|
102
|
+
if (match?.[1]) return match[1];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Extract extension from URL path, ignoring query string and hash
|
|
106
|
+
const [pathWithoutParams = ""] = url.split(/[?#]/);
|
|
107
|
+
const ext = pathWithoutParams.split(".").pop()?.toLowerCase() ?? "";
|
|
108
|
+
return IMAGE_MEDIA_TYPES[ext] ?? "image/png";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function toUrlOrString(value: string): string | URL {
|
|
112
|
+
try {
|
|
113
|
+
return new URL(value);
|
|
114
|
+
} catch {
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type ToolCallAccumulator = {
|
|
120
|
+
textParts: (GenericTextPart | GenericToolCallPart)[];
|
|
121
|
+
toolResults: GenericToolResultPart[];
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
function processToolCall(
|
|
125
|
+
part: MessagePartLike,
|
|
126
|
+
accumulator: ToolCallAccumulator,
|
|
127
|
+
): boolean {
|
|
128
|
+
if (!part.toolCallId || !part.toolName) return false;
|
|
129
|
+
|
|
130
|
+
accumulator.textParts.push({
|
|
131
|
+
type: "tool-call",
|
|
132
|
+
toolCallId: part.toolCallId,
|
|
133
|
+
toolName: part.toolName,
|
|
134
|
+
args: part.args ?? {},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (part.result !== undefined) {
|
|
138
|
+
const toolResult: GenericToolResultPart = {
|
|
139
|
+
type: "tool-result",
|
|
140
|
+
toolCallId: part.toolCallId,
|
|
141
|
+
toolName: part.toolName,
|
|
142
|
+
result: part.result,
|
|
143
|
+
};
|
|
144
|
+
if (part.isError) {
|
|
145
|
+
toolResult.isError = true;
|
|
146
|
+
}
|
|
147
|
+
accumulator.toolResults.push(toolResult);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function flushAccumulator(
|
|
154
|
+
accumulator: ToolCallAccumulator,
|
|
155
|
+
result: GenericMessage[],
|
|
156
|
+
): void {
|
|
157
|
+
if (accumulator.textParts.length > 0) {
|
|
158
|
+
result.push({ role: "assistant", content: accumulator.textParts });
|
|
159
|
+
accumulator.textParts = [];
|
|
160
|
+
}
|
|
161
|
+
if (accumulator.toolResults.length > 0) {
|
|
162
|
+
result.push({ role: "tool", content: accumulator.toolResults });
|
|
163
|
+
accumulator.toolResults = [];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function convertSystemMessage(
|
|
168
|
+
message: ThreadMessageLike,
|
|
169
|
+
result: GenericMessage[],
|
|
170
|
+
): void {
|
|
171
|
+
const textPart = message.content.find((p) => p.type === "text");
|
|
172
|
+
if (textPart?.text) {
|
|
173
|
+
result.push({ role: "system", content: textPart.text });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function convertUserMessage(
|
|
178
|
+
message: ThreadMessageLike,
|
|
179
|
+
result: GenericMessage[],
|
|
180
|
+
): void {
|
|
181
|
+
const attachments = message.attachments ?? [];
|
|
182
|
+
const allContent = [
|
|
183
|
+
...message.content,
|
|
184
|
+
...attachments.flatMap((a) => a.content),
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
const content: (GenericTextPart | GenericFilePart)[] = [];
|
|
188
|
+
|
|
189
|
+
for (const part of allContent) {
|
|
190
|
+
if (part.type === "text" && part.text) {
|
|
191
|
+
content.push({ type: "text", text: part.text });
|
|
192
|
+
} else if (part.type === "image" && part.image) {
|
|
193
|
+
content.push({
|
|
194
|
+
type: "file",
|
|
195
|
+
data: toUrlOrString(part.image),
|
|
196
|
+
mediaType: inferImageMediaType(part.image),
|
|
197
|
+
});
|
|
198
|
+
} else if (part.type === "file" && part.data && part.mimeType) {
|
|
199
|
+
content.push({
|
|
200
|
+
type: "file",
|
|
201
|
+
data: toUrlOrString(part.data),
|
|
202
|
+
mediaType: part.mimeType,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (content.length > 0) {
|
|
208
|
+
result.push({ role: "user", content });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function convertAssistantMessage(
|
|
213
|
+
message: ThreadMessageLike,
|
|
214
|
+
result: GenericMessage[],
|
|
215
|
+
): void {
|
|
216
|
+
const accumulator: ToolCallAccumulator = {
|
|
217
|
+
textParts: [],
|
|
218
|
+
toolResults: [],
|
|
219
|
+
};
|
|
220
|
+
let hasPendingToolResults = false;
|
|
221
|
+
|
|
222
|
+
for (const part of message.content) {
|
|
223
|
+
if (part.type === "text" && part.text) {
|
|
224
|
+
// Flush pending tool results before adding more text
|
|
225
|
+
if (hasPendingToolResults) {
|
|
226
|
+
flushAccumulator(accumulator, result);
|
|
227
|
+
hasPendingToolResults = false;
|
|
228
|
+
}
|
|
229
|
+
accumulator.textParts.push({ type: "text", text: part.text });
|
|
230
|
+
} else if (part.type === "tool-call") {
|
|
231
|
+
if (processToolCall(part, accumulator)) {
|
|
232
|
+
hasPendingToolResults = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
flushAccumulator(accumulator, result);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Converts thread messages to generic LLM messages.
|
|
242
|
+
* This format can then be easily converted to provider-specific formats.
|
|
243
|
+
*/
|
|
244
|
+
export function toGenericMessages(
|
|
245
|
+
messages: readonly ThreadMessageLike[],
|
|
246
|
+
): GenericMessage[] {
|
|
247
|
+
const result: GenericMessage[] = [];
|
|
248
|
+
|
|
249
|
+
for (const message of messages) {
|
|
250
|
+
switch (message.role) {
|
|
251
|
+
case "system":
|
|
252
|
+
convertSystemMessage(message, result);
|
|
253
|
+
break;
|
|
254
|
+
case "user":
|
|
255
|
+
convertUserMessage(message, result);
|
|
256
|
+
break;
|
|
257
|
+
case "assistant":
|
|
258
|
+
convertAssistantMessage(message, result);
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return result;
|
|
264
|
+
}
|
package/src/core/index.ts
CHANGED
package/src/core/tool/index.ts
CHANGED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { toJSONSchema, toToolsJSONSchema } from "./schema-utils";
|
|
3
|
+
import type { Tool } from "./tool-types";
|
|
4
|
+
|
|
5
|
+
describe("toJSONSchema", () => {
|
|
6
|
+
it("converts StandardSchemaV1 with ~standard.toJSONSchema", () => {
|
|
7
|
+
const mockStandardSchema = {
|
|
8
|
+
"~standard": {
|
|
9
|
+
version: 1,
|
|
10
|
+
vendor: "test",
|
|
11
|
+
validate: () => ({ value: {} }),
|
|
12
|
+
toJSONSchema: () => ({
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: { name: { type: "string" } },
|
|
15
|
+
}),
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const result = toJSONSchema(mockStandardSchema);
|
|
20
|
+
expect(result).toEqual({
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: { name: { type: "string" } },
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("converts object with toJSONSchema() method", () => {
|
|
27
|
+
const schemaWithMethod = {
|
|
28
|
+
toJSONSchema: () => ({
|
|
29
|
+
type: "object",
|
|
30
|
+
properties: { age: { type: "number" } },
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const result = toJSONSchema(schemaWithMethod as never);
|
|
35
|
+
expect(result).toEqual({
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: { age: { type: "number" } },
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("converts object with toJSON() method", () => {
|
|
42
|
+
const schemaWithToJSON = {
|
|
43
|
+
toJSON: () => ({
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: { active: { type: "boolean" } },
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const result = toJSONSchema(schemaWithToJSON as never);
|
|
50
|
+
expect(result).toEqual({
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: { active: { type: "boolean" } },
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("passes through plain JSONSchema7", () => {
|
|
57
|
+
const plainSchema = {
|
|
58
|
+
type: "object" as const,
|
|
59
|
+
properties: {
|
|
60
|
+
email: { type: "string" as const, format: "email" },
|
|
61
|
+
},
|
|
62
|
+
required: ["email"],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const result = toJSONSchema(plainSchema);
|
|
66
|
+
expect(result).toEqual(plainSchema);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("prioritizes StandardSchema over toJSONSchema method", () => {
|
|
70
|
+
const mixedSchema = {
|
|
71
|
+
"~standard": {
|
|
72
|
+
version: 1,
|
|
73
|
+
vendor: "test",
|
|
74
|
+
validate: () => ({ value: {} }),
|
|
75
|
+
toJSONSchema: () => ({ type: "string", description: "from standard" }),
|
|
76
|
+
},
|
|
77
|
+
toJSONSchema: () => ({ type: "number", description: "from method" }),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const result = toJSONSchema(mixedSchema);
|
|
81
|
+
expect(result).toEqual({ type: "string", description: "from standard" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("prioritizes toJSONSchema over toJSON method", () => {
|
|
85
|
+
const mixedSchema = {
|
|
86
|
+
toJSONSchema: () => ({
|
|
87
|
+
type: "string",
|
|
88
|
+
description: "from toJSONSchema",
|
|
89
|
+
}),
|
|
90
|
+
toJSON: () => ({ type: "number", description: "from toJSON" }),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const result = toJSONSchema(mixedSchema as never);
|
|
94
|
+
expect(result).toEqual({
|
|
95
|
+
type: "string",
|
|
96
|
+
description: "from toJSONSchema",
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("falls back to plain schema when StandardSchema has no toJSONSchema", () => {
|
|
101
|
+
const schemaWithoutMethod = {
|
|
102
|
+
"~standard": {
|
|
103
|
+
version: 1,
|
|
104
|
+
vendor: "test",
|
|
105
|
+
validate: () => ({ value: {} }),
|
|
106
|
+
// no toJSONSchema method
|
|
107
|
+
},
|
|
108
|
+
type: "object" as const,
|
|
109
|
+
properties: {},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const result = toJSONSchema(schemaWithoutMethod);
|
|
113
|
+
// Should return the object as-is since ~standard.toJSONSchema is not a function
|
|
114
|
+
expect(result).toEqual(schemaWithoutMethod);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("toToolsJSONSchema", () => {
|
|
119
|
+
describe("filtering", () => {
|
|
120
|
+
it("excludes disabled tools by default", () => {
|
|
121
|
+
const tools: Record<string, Tool> = {
|
|
122
|
+
enabledTool: {
|
|
123
|
+
description: "Enabled tool",
|
|
124
|
+
parameters: { type: "object", properties: {} },
|
|
125
|
+
},
|
|
126
|
+
disabledTool: {
|
|
127
|
+
disabled: true,
|
|
128
|
+
description: "Disabled tool",
|
|
129
|
+
parameters: { type: "object", properties: {} },
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const result = toToolsJSONSchema(tools);
|
|
134
|
+
expect(result).toHaveProperty("enabledTool");
|
|
135
|
+
expect(result).not.toHaveProperty("disabledTool");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("excludes backend tools by default", () => {
|
|
139
|
+
const tools: Record<string, Tool> = {
|
|
140
|
+
frontendTool: {
|
|
141
|
+
type: "frontend",
|
|
142
|
+
description: "Frontend tool",
|
|
143
|
+
parameters: { type: "object", properties: {} },
|
|
144
|
+
},
|
|
145
|
+
backendTool: {
|
|
146
|
+
type: "backend",
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const result = toToolsJSONSchema(tools);
|
|
151
|
+
expect(result).toHaveProperty("frontendTool");
|
|
152
|
+
expect(result).not.toHaveProperty("backendTool");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("includes frontend tools", () => {
|
|
156
|
+
const tools: Record<string, Tool> = {
|
|
157
|
+
myTool: {
|
|
158
|
+
type: "frontend",
|
|
159
|
+
description: "A frontend tool",
|
|
160
|
+
parameters: { type: "object", properties: { x: { type: "number" } } },
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const result = toToolsJSONSchema(tools);
|
|
165
|
+
expect(result).toEqual({
|
|
166
|
+
myTool: {
|
|
167
|
+
description: "A frontend tool",
|
|
168
|
+
parameters: { type: "object", properties: { x: { type: "number" } } },
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("includes human tools", () => {
|
|
174
|
+
const tools: Record<string, Tool> = {
|
|
175
|
+
humanTool: {
|
|
176
|
+
type: "human",
|
|
177
|
+
description: "A human tool",
|
|
178
|
+
parameters: { type: "object", properties: {} },
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const result = toToolsJSONSchema(tools);
|
|
183
|
+
expect(result).toHaveProperty("humanTool");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("excludes tools without parameters", () => {
|
|
187
|
+
const tools: Record<string, Tool> = {
|
|
188
|
+
withParams: {
|
|
189
|
+
description: "With params",
|
|
190
|
+
parameters: { type: "object", properties: {} },
|
|
191
|
+
},
|
|
192
|
+
withoutParams: {
|
|
193
|
+
type: "backend",
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const result = toToolsJSONSchema(tools);
|
|
198
|
+
expect(result).toHaveProperty("withParams");
|
|
199
|
+
expect(result).not.toHaveProperty("withoutParams");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("respects custom filter function", () => {
|
|
203
|
+
const tools: Record<string, Tool> = {
|
|
204
|
+
tool_a: {
|
|
205
|
+
disabled: true,
|
|
206
|
+
parameters: { type: "object", properties: {} },
|
|
207
|
+
},
|
|
208
|
+
tool_b: {
|
|
209
|
+
type: "backend",
|
|
210
|
+
},
|
|
211
|
+
tool_c: {
|
|
212
|
+
parameters: { type: "object", properties: {} },
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Custom filter that includes all tools regardless of disabled/backend
|
|
217
|
+
const result = toToolsJSONSchema(tools, {
|
|
218
|
+
filter: () => true,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// tool_a and tool_c have parameters, tool_b does not
|
|
222
|
+
expect(result).toHaveProperty("tool_a");
|
|
223
|
+
expect(result).not.toHaveProperty("tool_b"); // still excluded due to no parameters
|
|
224
|
+
expect(result).toHaveProperty("tool_c");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("custom filter receives name and tool", () => {
|
|
228
|
+
const tools: Record<string, Tool> = {
|
|
229
|
+
prefixed_tool: {
|
|
230
|
+
description: "Should include",
|
|
231
|
+
parameters: { type: "object", properties: {} },
|
|
232
|
+
},
|
|
233
|
+
other_tool: {
|
|
234
|
+
description: "Should exclude",
|
|
235
|
+
parameters: { type: "object", properties: {} },
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const result = toToolsJSONSchema(tools, {
|
|
240
|
+
filter: (name, tool) =>
|
|
241
|
+
name.startsWith("prefixed_") && tool.description !== undefined,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(result).toHaveProperty("prefixed_tool");
|
|
245
|
+
expect(result).not.toHaveProperty("other_tool");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("output format", () => {
|
|
250
|
+
it("includes description when present", () => {
|
|
251
|
+
const tools: Record<string, Tool> = {
|
|
252
|
+
myTool: {
|
|
253
|
+
description: "This is my tool",
|
|
254
|
+
parameters: { type: "object", properties: {} },
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const result = toToolsJSONSchema(tools);
|
|
259
|
+
expect(result.myTool).toEqual({
|
|
260
|
+
description: "This is my tool",
|
|
261
|
+
parameters: { type: "object", properties: {} },
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("omits description when absent", () => {
|
|
266
|
+
const tools: Record<string, Tool> = {
|
|
267
|
+
myTool: {
|
|
268
|
+
parameters: { type: "object", properties: {} },
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const result = toToolsJSONSchema(tools);
|
|
273
|
+
expect(result.myTool).toEqual({
|
|
274
|
+
parameters: { type: "object", properties: {} },
|
|
275
|
+
});
|
|
276
|
+
expect(result.myTool).not.toHaveProperty("description");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("omits description when empty string", () => {
|
|
280
|
+
const tools: Record<string, Tool> = {
|
|
281
|
+
myTool: {
|
|
282
|
+
description: "",
|
|
283
|
+
parameters: { type: "object", properties: {} },
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const result = toToolsJSONSchema(tools);
|
|
288
|
+
expect(result.myTool).not.toHaveProperty("description");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("converts parameters via toJSONSchema", () => {
|
|
292
|
+
const mockStandardSchema = {
|
|
293
|
+
"~standard": {
|
|
294
|
+
version: 1,
|
|
295
|
+
vendor: "test",
|
|
296
|
+
validate: () => ({ value: {} }),
|
|
297
|
+
toJSONSchema: () => ({
|
|
298
|
+
type: "object",
|
|
299
|
+
properties: { converted: { type: "boolean" } },
|
|
300
|
+
}),
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const tools: Record<string, Tool> = {
|
|
305
|
+
myTool: {
|
|
306
|
+
description: "Test",
|
|
307
|
+
parameters: mockStandardSchema,
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const result = toToolsJSONSchema(tools);
|
|
312
|
+
expect(result.myTool!.parameters).toEqual({
|
|
313
|
+
type: "object",
|
|
314
|
+
properties: { converted: { type: "boolean" } },
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe("edge cases", () => {
|
|
320
|
+
it("returns empty object for undefined tools", () => {
|
|
321
|
+
const result = toToolsJSONSchema(undefined);
|
|
322
|
+
expect(result).toEqual({});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("returns empty object for empty tools", () => {
|
|
326
|
+
const result = toToolsJSONSchema({});
|
|
327
|
+
expect(result).toEqual({});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("returns empty object when all tools are filtered out", () => {
|
|
331
|
+
const tools: Record<string, Tool> = {
|
|
332
|
+
disabled1: {
|
|
333
|
+
disabled: true,
|
|
334
|
+
parameters: { type: "object", properties: {} },
|
|
335
|
+
},
|
|
336
|
+
disabled2: {
|
|
337
|
+
disabled: true,
|
|
338
|
+
parameters: { type: "object", properties: {} },
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const result = toToolsJSONSchema(tools);
|
|
343
|
+
expect(result).toEqual({});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("handles tools with undefined type (defaults to frontend behavior)", () => {
|
|
347
|
+
const tools: Record<string, Tool> = {
|
|
348
|
+
myTool: {
|
|
349
|
+
// no type specified
|
|
350
|
+
description: "Tool without type",
|
|
351
|
+
parameters: { type: "object", properties: {} },
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const result = toToolsJSONSchema(tools);
|
|
356
|
+
expect(result).toHaveProperty("myTool");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("handles multiple tools correctly", () => {
|
|
360
|
+
const tools: Record<string, Tool> = {
|
|
361
|
+
tool_1: {
|
|
362
|
+
description: "First tool",
|
|
363
|
+
parameters: { type: "object", properties: { a: { type: "string" } } },
|
|
364
|
+
},
|
|
365
|
+
tool_2: {
|
|
366
|
+
description: "Second tool",
|
|
367
|
+
parameters: { type: "object", properties: { b: { type: "number" } } },
|
|
368
|
+
},
|
|
369
|
+
tool_3: {
|
|
370
|
+
disabled: true,
|
|
371
|
+
parameters: { type: "object", properties: {} },
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const result = toToolsJSONSchema(tools);
|
|
376
|
+
expect(Object.keys(result)).toHaveLength(2);
|
|
377
|
+
expect(result).toHaveProperty("tool_1");
|
|
378
|
+
expect(result).toHaveProperty("tool_2");
|
|
379
|
+
expect(result).not.toHaveProperty("tool_3");
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
});
|