@zmdb/ai 1.0.0-beta.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.
Files changed (53) hide show
  1. package/LICENSE +10 -0
  2. package/README.md +56 -0
  3. package/dist/chat/index.d.ts +81 -0
  4. package/dist/chat/index.d.ts.map +1 -0
  5. package/dist/chat/index.js +86 -0
  6. package/dist/chat/index.js.map +1 -0
  7. package/dist/compiler.d.ts +3 -0
  8. package/dist/compiler.d.ts.map +1 -0
  9. package/dist/compiler.js +2 -0
  10. package/dist/compiler.js.map +1 -0
  11. package/dist/http/caller.d.ts +3 -0
  12. package/dist/http/caller.d.ts.map +1 -0
  13. package/dist/http/caller.js +128 -0
  14. package/dist/http/caller.js.map +1 -0
  15. package/dist/http/generate.d.ts +7 -0
  16. package/dist/http/generate.d.ts.map +1 -0
  17. package/dist/http/generate.js +205 -0
  18. package/dist/http/generate.js.map +1 -0
  19. package/dist/http/index.d.ts +5 -0
  20. package/dist/http/index.d.ts.map +1 -0
  21. package/dist/http/index.js +6 -0
  22. package/dist/http/index.js.map +1 -0
  23. package/dist/http/parse.d.ts +11 -0
  24. package/dist/http/parse.d.ts.map +1 -0
  25. package/dist/http/parse.js +398 -0
  26. package/dist/http/parse.js.map +1 -0
  27. package/dist/http/types.d.ts +59 -0
  28. package/dist/http/types.d.ts.map +1 -0
  29. package/dist/http/types.js +20 -0
  30. package/dist/http/types.js.map +1 -0
  31. package/dist/index.d.ts +12 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +33 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/providers.d.ts +85 -0
  36. package/dist/providers.d.ts.map +1 -0
  37. package/dist/providers.js +252 -0
  38. package/dist/providers.js.map +1 -0
  39. package/dist/tool-runtime.d.ts +36 -0
  40. package/dist/tool-runtime.d.ts.map +1 -0
  41. package/dist/tool-runtime.js +55 -0
  42. package/dist/tool-runtime.js.map +1 -0
  43. package/package.json +66 -0
  44. package/src/chat/index.ts +202 -0
  45. package/src/compiler.ts +2 -0
  46. package/src/http/caller.ts +132 -0
  47. package/src/http/generate.ts +218 -0
  48. package/src/http/index.ts +16 -0
  49. package/src/http/parse.ts +666 -0
  50. package/src/http/types.ts +83 -0
  51. package/src/index.ts +40 -0
  52. package/src/providers.ts +443 -0
  53. package/src/tool-runtime.ts +91 -0
@@ -0,0 +1,83 @@
1
+ // Public contracts shared by the @zmdb/ai compiler and HTTP entry points.
2
+ import type { ToolSpec } from '../index.js';
3
+
4
+ export type ToolProvider = 'openai' | 'openai-strict' | 'anthropic' | 'gemini' | 'json-schema';
5
+
6
+ export interface ToolSpecRefusal {
7
+ readonly provider: ToolProvider;
8
+ readonly path: string;
9
+ readonly construct: string;
10
+ readonly reason: string;
11
+ readonly suggestion: string;
12
+ }
13
+
14
+ export class ToolSpecRefusalError extends Error {
15
+ readonly refusal: ToolSpecRefusal;
16
+
17
+ constructor(refusal: ToolSpecRefusal) {
18
+ super(
19
+ `Tool input ${refusal.path || '<root>'} cannot become a ${refusal.provider} tool: ` +
20
+ `${refusal.reason} (${refusal.construct}). ${refusal.suggestion}`,
21
+ );
22
+ this.name = 'ToolSpecRefusalError';
23
+ this.refusal = refusal;
24
+ }
25
+ }
26
+
27
+ export interface OpenApiOperationIdentity {
28
+ readonly method: string;
29
+ readonly path: string;
30
+ readonly operationId: string;
31
+ }
32
+
33
+ export interface OpenApiToolsOptions {
34
+ readonly provider?: ToolProvider;
35
+ readonly include?: (operation: OpenApiOperationIdentity) => boolean;
36
+ }
37
+
38
+ export interface OpenApiToolRequest {
39
+ readonly method: string;
40
+ readonly path: string;
41
+ readonly pathParameters: readonly string[];
42
+ readonly queryParameters: readonly string[];
43
+ readonly bodyParameters: readonly string[];
44
+ readonly hasBody: boolean;
45
+ }
46
+
47
+ /**
48
+ * One checked-in generated tool. `validate` is intentionally a normal
49
+ * `assert<T>` call in generated source: the existing AOT transform compiles it
50
+ * from TypeScript's IR, so this path does not grow a second validator engine.
51
+ */
52
+ export interface OpenApiGeneratedTool<T> {
53
+ readonly spec: ToolSpec;
54
+ readonly request: OpenApiToolRequest;
55
+ readonly validate: (input: unknown) => T;
56
+ }
57
+
58
+ export interface OpenApiCallerOptions {
59
+ readonly baseUrl: string;
60
+ readonly allowedBaseUrls: readonly string[];
61
+ readonly headers?: Readonly<Record<string, string>>;
62
+ readonly timeoutMs?: number;
63
+ readonly maxResponseBytes?: number;
64
+ readonly fetch?: typeof globalThis.fetch;
65
+ }
66
+
67
+ export interface BoundOpenApiTool<T> {
68
+ readonly spec: ToolSpec;
69
+ readonly validate: (input: unknown) => T;
70
+ readonly handler: (input: T) => Promise<unknown>;
71
+ }
72
+
73
+ export class OpenApiHttpError extends Error {
74
+ readonly status: number;
75
+ readonly body: string;
76
+
77
+ constructor(status: number, body: string) {
78
+ super(`OpenAPI tool request failed with HTTP ${status}: ${body}`);
79
+ this.name = 'OpenApiHttpError';
80
+ this.status = status;
81
+ this.body = body;
82
+ }
83
+ }
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { type CoreSchema } from '@zmdb/schema';
2
+
3
+ import { toolFor, type ToolOptions, type ToolSpec } from './providers.js';
4
+
5
+ export function toolFromSchema(name: string, schema: CoreSchema<string>, opts?: ToolOptions): ToolSpec {
6
+ return toolFor('json-schema', name, schema, opts);
7
+ }
8
+
9
+ export interface ParseResult<T> {
10
+ success: boolean;
11
+ data?: T;
12
+ errors?: readonly string[];
13
+ }
14
+
15
+ export function lenientParse<T = unknown>(text: string, coerce?: (v: unknown) => T): ParseResult<T> {
16
+ // strip a leading/trailing markdown code fence (```json … ```)
17
+ const stripped = text
18
+ .trim()
19
+ .replace(/^```(?:json)?\s*/i, '')
20
+ .replace(/\s*```$/, '')
21
+ .trim();
22
+ let parsed: unknown;
23
+ try {
24
+ parsed = JSON.parse(stripped);
25
+ } catch (err) {
26
+ return { success: false, errors: [err instanceof Error ? err.message : 'invalid JSON'] };
27
+ }
28
+ // boundary: with no `coerce` there is nothing to check the payload against —
29
+ // `T` is the caller's claim about the model's output, exactly as with
30
+ // `JSON.parse`. Pass a `coerce` (or run the AOT validator) to make it proven.
31
+ if (!coerce) return { success: true, data: parsed as T };
32
+ try {
33
+ return { success: true, data: coerce(parsed) };
34
+ } catch (err) {
35
+ return { success: false, errors: [err instanceof Error ? err.message : 'coercion failed'] };
36
+ }
37
+ }
38
+
39
+ export { toolFor };
40
+ export type { ToolOptions, ToolProvider, ToolSchema, ToolSpec, ToolSpecFor } from './providers.js';
@@ -0,0 +1,443 @@
1
+ import { type CoreSchema } from '@zmdb/schema';
2
+ import {
3
+ jsonSchemaForColumn,
4
+ jsonSchemaFromShape,
5
+ shapeOfVariant,
6
+ type JsonSchemaObject,
7
+ type ShapeIR,
8
+ } from '@zmdb/schema/ir';
9
+
10
+ import { ToolSpecRefusalError, type ToolProvider, type ToolSpecRefusal } from './http/types.js';
11
+
12
+ export type { ToolProvider, ToolSpecRefusal };
13
+ export { ToolSpecRefusalError };
14
+
15
+ export interface ToolOptions {
16
+ readonly description?: string;
17
+ }
18
+
19
+ export type ToolSchema = CoreSchema<string>;
20
+
21
+ export interface ToolSpec {
22
+ readonly name: string;
23
+ readonly description?: string;
24
+ readonly parameters: JsonSchemaObject;
25
+ }
26
+
27
+ export interface StrictJsonSchemaObject extends JsonSchemaObject {
28
+ readonly additionalProperties: false;
29
+ }
30
+
31
+ export interface GeminiSchemaObject extends JsonSchemaObject {}
32
+
33
+ export interface ToolSpecFor {
34
+ readonly openai: {
35
+ readonly type: 'function';
36
+ readonly function: {
37
+ readonly name: string;
38
+ readonly description?: string;
39
+ readonly parameters: JsonSchemaObject;
40
+ };
41
+ };
42
+ readonly 'openai-strict': {
43
+ readonly type: 'function';
44
+ readonly function: {
45
+ readonly name: string;
46
+ readonly description?: string;
47
+ readonly strict: true;
48
+ readonly parameters: StrictJsonSchemaObject;
49
+ };
50
+ };
51
+ readonly anthropic: {
52
+ readonly name: string;
53
+ readonly description?: string;
54
+ readonly input_schema: JsonSchemaObject;
55
+ };
56
+ readonly gemini: {
57
+ readonly name: string;
58
+ readonly description?: string;
59
+ readonly parameters: GeminiSchemaObject;
60
+ };
61
+ readonly 'json-schema': ToolSpec;
62
+ }
63
+
64
+ export interface ToolDialect {
65
+ readonly allowedKeywords: ReadonlySet<string>;
66
+ readonly maxProperties?: number;
67
+ readonly source: string;
68
+ readonly verifiedOn: '2026-09-04';
69
+ }
70
+
71
+ const COMMON_KEYWORDS = [
72
+ 'type',
73
+ 'format',
74
+ 'enum',
75
+ 'const',
76
+ 'minimum',
77
+ 'maximum',
78
+ 'minLength',
79
+ 'maxLength',
80
+ 'pattern',
81
+ 'items',
82
+ 'minItems',
83
+ 'maxItems',
84
+ 'properties',
85
+ 'required',
86
+ 'anyOf',
87
+ 'additionalProperties',
88
+ ] as const;
89
+
90
+ /**
91
+ * Provider constraints are data rather than branches scattered through the emitter.
92
+ *
93
+ * The cap is deliberately below every provider's moving request-size ceiling. It is a
94
+ * build-time guard against producing a tool definition large enough to be rejected after
95
+ * deployment, not a promise that a provider will accept every document below it.
96
+ */
97
+ export const TOOL_DIALECTS: Readonly<Record<ToolProvider, ToolDialect>> = {
98
+ openai: {
99
+ allowedKeywords: new Set<string>(COMMON_KEYWORDS),
100
+ maxProperties: 1_024,
101
+ source: 'https://platform.openai.com/docs/guides/structured-outputs',
102
+ verifiedOn: '2026-09-04',
103
+ },
104
+ 'openai-strict': {
105
+ allowedKeywords: new Set<string>(COMMON_KEYWORDS),
106
+ maxProperties: 1_024,
107
+ source: 'https://platform.openai.com/docs/guides/structured-outputs',
108
+ verifiedOn: '2026-09-04',
109
+ },
110
+ anthropic: {
111
+ allowedKeywords: new Set<string>(COMMON_KEYWORDS),
112
+ maxProperties: 1_024,
113
+ source: 'https://platform.claude.com/docs/en/agents-and-tools/tool-use/implement-tool-use',
114
+ verifiedOn: '2026-09-04',
115
+ },
116
+ gemini: {
117
+ allowedKeywords: new Set<string>([...COMMON_KEYWORDS, 'nullable']),
118
+ maxProperties: 1_024,
119
+ source: 'https://ai.google.dev/api/caching#Schema',
120
+ verifiedOn: '2026-09-04',
121
+ },
122
+ 'json-schema': {
123
+ allowedKeywords: new Set<string>(COMMON_KEYWORDS),
124
+ source: 'https://json-schema.org/draft/2020-12/json-schema-core.html',
125
+ verifiedOn: '2026-09-04',
126
+ },
127
+ };
128
+
129
+ type AnyToolSpec = ToolSpecFor[ToolProvider];
130
+
131
+ function isRecord(value: unknown): value is Record<string, unknown> {
132
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
133
+ }
134
+
135
+ function isToolSchema(value: unknown): value is ToolSchema {
136
+ if (!isRecord(value)) return false;
137
+ const ir = value['ir'];
138
+ return isRecord(ir) && Array.isArray(ir['columns']);
139
+ }
140
+
141
+ function descriptionPart(description: string | undefined): { readonly description: string } | object {
142
+ return description ? { description } : {};
143
+ }
144
+
145
+ function refusal(provider: ToolProvider, path: string, construct: string, reason: string, suggestion: string): never {
146
+ throw new ToolSpecRefusalError({ provider, path, construct, reason, suggestion });
147
+ }
148
+
149
+ function joinPath(parent: string, child: string): string {
150
+ return parent.length === 0 ? child : `${parent}.${child}`;
151
+ }
152
+
153
+ function nullableType(value: unknown): unknown {
154
+ if (typeof value === 'string') return value === 'null' ? value : [value, 'null'];
155
+ if (!Array.isArray(value)) return value;
156
+ return value.includes('null') ? [...value] : [...value, 'null'];
157
+ }
158
+
159
+ function requiredNames(value: Record<string, unknown>): ReadonlySet<string> {
160
+ const required = value['required'];
161
+ if (!Array.isArray(required)) return new Set();
162
+ return new Set(required.filter(name => typeof name === 'string'));
163
+ }
164
+
165
+ function propertiesOf(value: Record<string, unknown>): Record<string, unknown> | undefined {
166
+ const properties = value['properties'];
167
+ return isRecord(properties) ? properties : undefined;
168
+ }
169
+
170
+ function translateNode(
171
+ provider: 'openai-strict' | 'gemini',
172
+ value: Record<string, unknown>,
173
+ path: string,
174
+ optional: boolean,
175
+ ): Record<string, unknown> {
176
+ if (Object.keys(value).length === 0) {
177
+ refusal(
178
+ provider,
179
+ path,
180
+ 'untyped json',
181
+ 'the provider requires a type for every tool property, but this column emits no type',
182
+ 'declare the payload with WireAs<W>, or omit the column from the tool',
183
+ );
184
+ }
185
+
186
+ const dialect = TOOL_DIALECTS[provider];
187
+ for (const keyword of Object.keys(value)) {
188
+ if (!dialect.allowedKeywords.has(keyword)) {
189
+ refusal(
190
+ provider,
191
+ path,
192
+ `unsupported keyword ${keyword}`,
193
+ `${provider} cannot express the emitted ${keyword} keyword without changing its meaning`,
194
+ 'declare a provider-compatible wire shape, or omit the column from the tool',
195
+ );
196
+ }
197
+ }
198
+
199
+ const result: Record<string, unknown> = {};
200
+ const nested = propertiesOf(value);
201
+ const nestedRequired = requiredNames(value);
202
+
203
+ for (const [keyword, raw] of Object.entries(value)) {
204
+ if (provider === 'openai-strict' && keyword === 'format' && raw === 'int64') continue;
205
+
206
+ if (keyword === 'properties' && nested !== undefined) {
207
+ const properties: Record<string, unknown> = {};
208
+ for (const name of Object.keys(nested).toSorted()) {
209
+ const child = nested[name];
210
+ if (!isRecord(child)) {
211
+ refusal(
212
+ provider,
213
+ joinPath(path, name),
214
+ 'non-object property schema',
215
+ 'the emitted property schema is not an object',
216
+ 'declare the property with a JSON-Schema-compatible wire type',
217
+ );
218
+ }
219
+ properties[name] = translateNode(provider, child, joinPath(path, name), !nestedRequired.has(name));
220
+ }
221
+ result[keyword] = properties;
222
+ continue;
223
+ }
224
+
225
+ if (keyword === 'items' && isRecord(raw)) {
226
+ result[keyword] = translateNode(provider, raw, `${path}[]`, false);
227
+ continue;
228
+ }
229
+
230
+ if (keyword === 'anyOf' && Array.isArray(raw)) {
231
+ result[keyword] = raw.map((member, index) => {
232
+ if (!isRecord(member)) {
233
+ refusal(
234
+ provider,
235
+ `${path}|${String(index)}`,
236
+ 'non-object union member',
237
+ 'the emitted union member is not a schema object',
238
+ 'declare every union member with a provider-compatible wire type',
239
+ );
240
+ }
241
+ return translateNode(provider, member, `${path}|${String(index)}`, false);
242
+ });
243
+ continue;
244
+ }
245
+
246
+ if (keyword === 'required' && nested !== undefined && provider === 'openai-strict') {
247
+ result[keyword] = Object.keys(nested).toSorted();
248
+ continue;
249
+ }
250
+
251
+ if (keyword === 'type' && provider === 'gemini' && Array.isArray(raw)) {
252
+ const nonNull = raw.filter(item => item !== 'null');
253
+ if (nonNull.length !== 1 || !raw.includes('null')) {
254
+ refusal(
255
+ provider,
256
+ path,
257
+ 'type union',
258
+ 'Gemini cannot express this type array without changing the accepted values',
259
+ 'declare a single nullable wire type, or omit the column from the tool',
260
+ );
261
+ }
262
+ result[keyword] = nonNull[0];
263
+ result['nullable'] = true;
264
+ continue;
265
+ }
266
+
267
+ result[keyword] = raw;
268
+ }
269
+
270
+ if (provider === 'openai-strict') {
271
+ if (optional) result['type'] = nullableType(result['type']);
272
+ if (nested !== undefined) result['additionalProperties'] = false;
273
+ }
274
+ return result;
275
+ }
276
+
277
+ function visibleShape(shape: ShapeIR): ShapeIR {
278
+ return shape
279
+ .filter(entry => !entry.column.sensitive)
280
+ .toSorted((left, right) => left.column.name.localeCompare(right.column.name));
281
+ }
282
+
283
+ function propertyCount(value: unknown): number {
284
+ if (Array.isArray(value)) return value.reduce((total, item) => total + propertyCount(item), 0);
285
+ if (!isRecord(value)) return 0;
286
+ const properties = propertiesOf(value);
287
+ let count = properties === undefined ? 0 : Object.keys(properties).length;
288
+ for (const item of Object.values(value)) count += propertyCount(item);
289
+ return count;
290
+ }
291
+
292
+ function enforceShape(provider: ToolProvider, shape: ShapeIR, document: JsonSchemaObject): void {
293
+ if (shape.length === 0 && provider !== 'json-schema') {
294
+ refusal(
295
+ provider,
296
+ '',
297
+ 'empty create schema',
298
+ 'the create variant has no visible properties',
299
+ 'drop the tool, or unmark a Sensitive column that the model is allowed to supply',
300
+ );
301
+ }
302
+ const maximum = TOOL_DIALECTS[provider].maxProperties;
303
+ const count = propertyCount(document);
304
+ if (maximum !== undefined && count > maximum) {
305
+ refusal(
306
+ provider,
307
+ '',
308
+ `property limit ${String(maximum)}`,
309
+ `the tool contains ${String(count)} properties, above the provider cap of ${String(maximum)}`,
310
+ 'split the operation into smaller tools',
311
+ );
312
+ }
313
+ }
314
+
315
+ /**
316
+ * The provider's parameter document, directly from the declaration IR.
317
+ *
318
+ * This is exported for the AOT emitter. Applications should call {@link toolFor}; exposing
319
+ * the pure step keeps runtime and build-time output byte-identical without a second walker.
320
+ */
321
+ export function toolSchemaForProvider(provider: ToolProvider, shape: ShapeIR): JsonSchemaObject {
322
+ const visible = visibleShape(shape);
323
+ const generic = jsonSchemaFromShape(visible);
324
+ enforceShape(provider, visible, generic);
325
+
326
+ if (provider !== 'openai-strict' && provider !== 'gemini') return generic;
327
+
328
+ const properties: Record<string, unknown> = {};
329
+ for (const { column, optional } of visible) {
330
+ properties[column.name] = translateNode(provider, jsonSchemaForColumn(column), column.name, optional);
331
+ }
332
+
333
+ if (provider === 'openai-strict') {
334
+ const strict: StrictJsonSchemaObject = {
335
+ type: 'object',
336
+ properties,
337
+ required: visible.map(entry => entry.column.name),
338
+ additionalProperties: false,
339
+ };
340
+ return strict;
341
+ }
342
+
343
+ return {
344
+ type: 'object',
345
+ properties,
346
+ required: generic.required,
347
+ };
348
+ }
349
+
350
+ export function frameTool(
351
+ provider: ToolProvider,
352
+ name: string,
353
+ parameters: JsonSchemaObject,
354
+ options: ToolOptions = {},
355
+ ): AnyToolSpec {
356
+ const described = descriptionPart(options.description);
357
+ switch (provider) {
358
+ case 'openai':
359
+ return { type: 'function', function: { name, ...described, parameters } };
360
+ case 'openai-strict':
361
+ return {
362
+ type: 'function',
363
+ function: {
364
+ name,
365
+ ...described,
366
+ strict: true,
367
+ parameters: {
368
+ ...parameters,
369
+ additionalProperties: false,
370
+ },
371
+ },
372
+ };
373
+ case 'anthropic':
374
+ return { name, ...described, input_schema: parameters };
375
+ case 'gemini':
376
+ return { name, ...described, parameters };
377
+ case 'json-schema':
378
+ return { name, ...described, parameters };
379
+ }
380
+ }
381
+
382
+ export function toolFor<_T>(provider: 'openai', name: string, options?: ToolOptions): ToolSpecFor['openai'];
383
+ export function toolFor<_T>(
384
+ provider: 'openai-strict',
385
+ name: string,
386
+ options?: ToolOptions,
387
+ ): ToolSpecFor['openai-strict'];
388
+ export function toolFor<_T>(provider: 'anthropic', name: string, options?: ToolOptions): ToolSpecFor['anthropic'];
389
+ export function toolFor<_T>(provider: 'gemini', name: string, options?: ToolOptions): ToolSpecFor['gemini'];
390
+ export function toolFor<_T>(provider: 'json-schema', name: string, options?: ToolOptions): ToolSpecFor['json-schema'];
391
+ export function toolFor<_T, P extends ToolProvider>(provider: P, name: string, options?: ToolOptions): ToolSpecFor[P];
392
+ export function toolFor(
393
+ provider: 'openai',
394
+ name: string,
395
+ schema: ToolSchema,
396
+ options?: ToolOptions,
397
+ ): ToolSpecFor['openai'];
398
+ export function toolFor(
399
+ provider: 'openai-strict',
400
+ name: string,
401
+ schema: ToolSchema,
402
+ options?: ToolOptions,
403
+ ): ToolSpecFor['openai-strict'];
404
+ export function toolFor(
405
+ provider: 'anthropic',
406
+ name: string,
407
+ schema: ToolSchema,
408
+ options?: ToolOptions,
409
+ ): ToolSpecFor['anthropic'];
410
+ export function toolFor(
411
+ provider: 'gemini',
412
+ name: string,
413
+ schema: ToolSchema,
414
+ options?: ToolOptions,
415
+ ): ToolSpecFor['gemini'];
416
+ export function toolFor(
417
+ provider: 'json-schema',
418
+ name: string,
419
+ schema: ToolSchema,
420
+ options?: ToolOptions,
421
+ ): ToolSpecFor['json-schema'];
422
+ export function toolFor<P extends ToolProvider>(
423
+ provider: P,
424
+ name: string,
425
+ schema: ToolSchema,
426
+ options?: ToolOptions,
427
+ ): ToolSpecFor[P];
428
+ export function toolFor(
429
+ provider: ToolProvider,
430
+ name: string,
431
+ schemaOrOptions?: ToolSchema | ToolOptions,
432
+ options: ToolOptions = {},
433
+ ): AnyToolSpec {
434
+ if (!isToolSchema(schemaOrOptions)) {
435
+ throw new Error(
436
+ 'toolFor<T>() was not replaced at build time. It is compiled away by @zmdb/compiler ' +
437
+ '(the unplugin, Metro adapter, or project compiler), which did not run over this file — a type argument cannot ' +
438
+ 'be read at runtime, so there is nothing to fall back to.',
439
+ );
440
+ }
441
+ const parameters = toolSchemaForProvider(provider, shapeOfVariant(schemaOrOptions.ir, 'create'));
442
+ return frameTool(provider, name, parameters, options);
443
+ }
@@ -0,0 +1,91 @@
1
+ import { validationIssuesOf, type ValidationIssue } from '@zmdb/validator';
2
+
3
+ export interface InvocableTool<T> {
4
+ readonly validate: (args: unknown) => T;
5
+ readonly handler: (input: T, identity?: unknown) => unknown | PromiseLike<unknown>;
6
+ }
7
+
8
+ export type ToolInvocation =
9
+ | { readonly kind: 'success'; readonly content: string }
10
+ | { readonly kind: 'validation-error'; readonly error: unknown; readonly content?: string }
11
+ | { readonly kind: 'handler-error'; readonly error: unknown };
12
+
13
+ export interface ToolAdapterOptions<T, Output = unknown> {
14
+ readonly description: string;
15
+ /**
16
+ * Validate the model-shaped value and return the decoded application value.
17
+ *
18
+ * This function belongs at the call site so an AOT validator can be inlined
19
+ * there. Custom wire codecs can decode in the same function before the
20
+ * handler receives the value.
21
+ */
22
+ readonly validate: (value: unknown) => T;
23
+ readonly execute: (input: T) => Output | PromiseLike<Output>;
24
+ }
25
+
26
+ export const serialiseToolResult = (result: unknown): string => {
27
+ if (typeof result === 'string') return result;
28
+ const serialised = JSON.stringify(result);
29
+ return serialised ?? 'undefined';
30
+ };
31
+
32
+ const validationErrorContent = (error: unknown): string | undefined => {
33
+ const issues = validationIssuesOf(error);
34
+ if (issues === undefined) return undefined;
35
+ return JSON.stringify(
36
+ issues.map(issue =>
37
+ issue.expected === undefined
38
+ ? { path: issue.path, message: issue.message }
39
+ : { path: issue.path, message: issue.message, expected: issue.expected },
40
+ ),
41
+ );
42
+ };
43
+
44
+ export async function invokeTool<T>(
45
+ entry: InvocableTool<T>,
46
+ args: unknown,
47
+ identity?: unknown,
48
+ ): Promise<ToolInvocation> {
49
+ let input: T;
50
+ try {
51
+ input = entry.validate(args);
52
+ } catch (error) {
53
+ const content = validationErrorContent(error);
54
+ return content === undefined ? { kind: 'validation-error', error } : { kind: 'validation-error', error, content };
55
+ }
56
+
57
+ try {
58
+ return { kind: 'success', content: serialiseToolResult(await entry.handler(input, identity)) };
59
+ } catch (error) {
60
+ return { kind: 'handler-error', error };
61
+ }
62
+ }
63
+
64
+ const validationLine = (issue: ValidationIssue): string =>
65
+ issue.expected === undefined ? `${issue.path}: ${issue.message}` : `${issue.path}: expected ${issue.expected}`;
66
+
67
+ const validationFailure = (name: string, issues: readonly ValidationIssue[]): string => {
68
+ const details = issues.length === 0 ? 'validation failed without details' : issues.map(validationLine).join('\n');
69
+ return `Tool "${name}" rejected its arguments:\n${details}`;
70
+ };
71
+
72
+ /**
73
+ * A malformed model call is returned to the model so it can correct the next
74
+ * turn. Errors without a valid issue list, including handler failures, are
75
+ * application failures and remain thrown.
76
+ */
77
+ export async function executeToolAdapter<T, Output>(
78
+ name: string,
79
+ value: unknown,
80
+ options: ToolAdapterOptions<T, Output>,
81
+ ): Promise<Awaited<Output> | string> {
82
+ let input: T;
83
+ try {
84
+ input = options.validate(value);
85
+ } catch (error) {
86
+ const issues = validationIssuesOf(error);
87
+ if (issues === undefined) throw error;
88
+ return validationFailure(name, issues);
89
+ }
90
+ return await options.execute(input);
91
+ }