@kindgi/tools 0.0.0-bootstrap.0 → 0.1.0
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/LICENSE +201 -0
- package/README.md +86 -1
- package/dist/define.d.ts +138 -0
- package/dist/define.d.ts.map +1 -0
- package/dist/define.js +318 -0
- package/dist/define.js.map +1 -0
- package/dist/errors.d.ts +113 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +4 -0
- package/dist/errors.js.map +1 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +213 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/invoke.d.ts +27 -0
- package/dist/invoke.d.ts.map +1 -0
- package/dist/invoke.js +127 -0
- package/dist/invoke.js.map +1 -0
- package/dist/manifest.d.ts +23 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +37 -0
- package/dist/manifest.js.map +1 -0
- package/dist/precondition.d.ts +25 -0
- package/dist/precondition.d.ts.map +1 -0
- package/dist/precondition.js +34 -0
- package/dist/precondition.js.map +1 -0
- package/dist/registry.d.ts +87 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +171 -0
- package/dist/registry.js.map +1 -0
- package/dist/spec-registry.d.ts +48 -0
- package/dist/spec-registry.d.ts.map +1 -0
- package/dist/spec-registry.js +37 -0
- package/dist/spec-registry.js.map +1 -0
- package/dist/tool.schema.json +297 -0
- package/dist/types.d.ts +508 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -0
- package/package.json +64 -4
- package/src/define.ts +537 -0
- package/src/errors.ts +139 -0
- package/src/http.ts +268 -0
- package/src/index.ts +68 -0
- package/src/invoke.ts +165 -0
- package/src/manifest.ts +40 -0
- package/src/precondition.ts +40 -0
- package/src/registry.ts +262 -0
- package/src/spec-registry.ts +78 -0
- package/src/tool.schema.json +297 -0
- package/src/types.ts +538 -0
package/src/define.ts
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
import * as addFormatsModule from 'ajv-formats';
|
|
5
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createSpecRegistry,
|
|
9
|
+
isZodSchema,
|
|
10
|
+
loadZodConverter,
|
|
11
|
+
loadZodConverterSync,
|
|
12
|
+
toJSONSchemaSync,
|
|
13
|
+
} from '@kindgi/schema';
|
|
14
|
+
import type {
|
|
15
|
+
AnySchema,
|
|
16
|
+
InvalidZodConversionError,
|
|
17
|
+
SpecRegistry,
|
|
18
|
+
ZodConverter,
|
|
19
|
+
ZodLikeSchema,
|
|
20
|
+
} from '@kindgi/schema';
|
|
21
|
+
import type { Result, ToolId } from '@kindgi/types';
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
InvalidSchemaError,
|
|
25
|
+
InvalidToolDefinitionError,
|
|
26
|
+
ToolError,
|
|
27
|
+
UnknownEffectError,
|
|
28
|
+
} from './errors.js';
|
|
29
|
+
import { type ToolSpecSynthesizerOptions, getToolSpecSynthesizer } from './spec-registry.js';
|
|
30
|
+
import toolSchema from './tool.schema.json' with { type: 'json' };
|
|
31
|
+
import {
|
|
32
|
+
EFFECT_KINDS,
|
|
33
|
+
type Tool,
|
|
34
|
+
type ToolContext,
|
|
35
|
+
type ToolManifest,
|
|
36
|
+
type ToolSpec,
|
|
37
|
+
} from './types.js';
|
|
38
|
+
|
|
39
|
+
const TOOL_SCHEMA_URI = 'https://kindgi.com/schemas/v1/tool.schema.json';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Exact semver regex — accepts `MAJOR.MINOR.PATCH[-PRERELEASE]`. Tool
|
|
43
|
+
* VERSIONS are always exact (a tool row ships a specific semver);
|
|
44
|
+
* agent bindings reference tools via `{ id, version: <range> }`
|
|
45
|
+
* where the range grammar is npm-style (parsed by `semver` at
|
|
46
|
+
* dispatch time).
|
|
47
|
+
*/
|
|
48
|
+
const SEMVER_EXACT_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
49
|
+
|
|
50
|
+
// ajv-formats CJS/ESM interop — same trick used in @kindgi/schema.
|
|
51
|
+
type AddFormatsFn = (ajv: InstanceType<typeof Ajv2020>, opts?: unknown) => unknown;
|
|
52
|
+
const addFormatsRaw = addFormatsModule as unknown;
|
|
53
|
+
const addFormats: AddFormatsFn =
|
|
54
|
+
typeof addFormatsRaw === 'function'
|
|
55
|
+
? (addFormatsRaw as AddFormatsFn)
|
|
56
|
+
: (addFormatsRaw as { default: AddFormatsFn }).default;
|
|
57
|
+
|
|
58
|
+
let cachedRegistry: SpecRegistry | undefined;
|
|
59
|
+
function registry(): SpecRegistry {
|
|
60
|
+
if (cachedRegistry !== undefined) return cachedRegistry;
|
|
61
|
+
const built = createSpecRegistry([toolSchema]);
|
|
62
|
+
if (built.kind === 'err') {
|
|
63
|
+
throw new Error(`@kindgi/tools: bundled schema failed to compile: ${built.error.message}`);
|
|
64
|
+
}
|
|
65
|
+
cachedRegistry = built.value;
|
|
66
|
+
return cachedRegistry;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Sync converter resolver. `@kindgi/schema` handles caching + peer-dep
|
|
71
|
+
* absence; when zod is installed, this returns the converter on every
|
|
72
|
+
* call after the first at O(1); when zod is missing, this returns
|
|
73
|
+
* `undefined` and callers surface a typed `invalid-schema` error.
|
|
74
|
+
*/
|
|
75
|
+
function resolveZodConverterSync(): ZodConverter | undefined {
|
|
76
|
+
return loadZodConverterSync();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Sanity-check that a candidate JSON Schema compiles under Draft 2020-12.
|
|
81
|
+
* We build a throwaway Ajv per call — cheap for authoring-time, safe under
|
|
82
|
+
* concurrent defineTool() calls.
|
|
83
|
+
*/
|
|
84
|
+
function compilesAsSchema(
|
|
85
|
+
schema: unknown,
|
|
86
|
+
where: 'input' | 'output',
|
|
87
|
+
): InvalidSchemaError | undefined {
|
|
88
|
+
const ajv = new Ajv2020({ strict: true, allErrors: true, allowUnionTypes: false });
|
|
89
|
+
addFormats(ajv);
|
|
90
|
+
try {
|
|
91
|
+
ajv.compile(schema as object);
|
|
92
|
+
return undefined;
|
|
93
|
+
} catch (cause) {
|
|
94
|
+
return {
|
|
95
|
+
code: 'invalid-schema',
|
|
96
|
+
message: `Tool ${where} schema does not compile: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
97
|
+
where,
|
|
98
|
+
cause,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Given an `AnySchema` (JSON Schema object OR Zod v4 schema), return the
|
|
105
|
+
* JSON Schema wire form. Converts synchronously using the pre-loaded Zod
|
|
106
|
+
* converter — callers must have called `ensureZodConverter()` first if
|
|
107
|
+
* they might be handed a Zod schema.
|
|
108
|
+
*/
|
|
109
|
+
function resolveWireSchema(
|
|
110
|
+
schema: AnySchema,
|
|
111
|
+
where: 'input' | 'output',
|
|
112
|
+
converter: ZodConverter | undefined,
|
|
113
|
+
): Result<
|
|
114
|
+
{ readonly wire: Readonly<Record<string, unknown>>; readonly zod?: ZodLikeSchema },
|
|
115
|
+
InvalidSchemaError
|
|
116
|
+
> {
|
|
117
|
+
if (!isZodSchema(schema)) {
|
|
118
|
+
return { kind: 'ok', value: { wire: schema } };
|
|
119
|
+
}
|
|
120
|
+
// The input side for `input` (a `.default()` field is optional to the
|
|
121
|
+
// caller), the output side for `output` (the field is always there).
|
|
122
|
+
const converted = toJSONSchemaSync(schema, converter, where);
|
|
123
|
+
if (converted.kind === 'err') {
|
|
124
|
+
return { kind: 'err', error: toSchemaError(converted.error, where) };
|
|
125
|
+
}
|
|
126
|
+
return { kind: 'ok', value: { wire: converted.value, zod: schema } };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function toSchemaError(
|
|
130
|
+
err: InvalidZodConversionError,
|
|
131
|
+
where: 'input' | 'output',
|
|
132
|
+
): InvalidSchemaError {
|
|
133
|
+
return {
|
|
134
|
+
code: 'invalid-schema',
|
|
135
|
+
message: `Tool ${where} Zod schema conversion failed: ${err.message}`,
|
|
136
|
+
where,
|
|
137
|
+
cause: err.cause,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Author-time inference from a Zod schema's `_zod.input` — what a caller
|
|
143
|
+
* may send. Non-Zod schemas fall through to `unknown`.
|
|
144
|
+
*
|
|
145
|
+
* A handler's input is typed `InferOutput` of the input schema: it gets
|
|
146
|
+
* the parsed value (defaults filled, transforms applied), not what the
|
|
147
|
+
* caller sent.
|
|
148
|
+
*/
|
|
149
|
+
export type InferInput<T> = T extends { readonly _zod: { readonly input: infer I } } ? I : unknown;
|
|
150
|
+
|
|
151
|
+
/** Symmetric — inferred from `_zod.output`. */
|
|
152
|
+
export type InferOutput<T> = T extends { readonly _zod: { readonly output: infer O } }
|
|
153
|
+
? O
|
|
154
|
+
: unknown;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The author-facing spec accepted by `defineTool`. Two generic parameters,
|
|
158
|
+
* one per schema slot, each of which is either a JSON Schema object or a
|
|
159
|
+
* Zod v4 schema. Two more generics carry the handler's actual argument
|
|
160
|
+
* and return types — defaulting to the schema-inferred types, but
|
|
161
|
+
* available for contextual inference from the handler literal: authors
|
|
162
|
+
* who write `handler: async () => ({ x: 1 })` get
|
|
163
|
+
* `TOutput = { x: number }` without declaring it separately.
|
|
164
|
+
*/
|
|
165
|
+
export interface DefineToolSpec<
|
|
166
|
+
TInSchema extends AnySchema,
|
|
167
|
+
TOutSchema extends AnySchema,
|
|
168
|
+
THandlerIn = InferOutput<TInSchema>,
|
|
169
|
+
THandlerOut = InferOutput<TOutSchema>,
|
|
170
|
+
> extends Omit<ToolManifest, 'input' | 'output'> {
|
|
171
|
+
/**
|
|
172
|
+
* Tool input schema. Accepts either a Zod v4 schema (auto-converted
|
|
173
|
+
* to JSON Schema at author time; preserved on `tool.inputZod` for
|
|
174
|
+
* TS-side type inference) or a JSON Schema Draft 2020-12 object.
|
|
175
|
+
* Handed to the model as the tool's parameter schema so it can
|
|
176
|
+
* generate valid calls.
|
|
177
|
+
*/
|
|
178
|
+
readonly input: TInSchema;
|
|
179
|
+
/**
|
|
180
|
+
* Tool output schema. Same Zod-or-JSON-Schema shape as `input`.
|
|
181
|
+
* `invokeTool` validates the handler's return value against this
|
|
182
|
+
* before surfacing it — an off-schema return fails with `bad-output`.
|
|
183
|
+
*/
|
|
184
|
+
readonly output: TOutSchema;
|
|
185
|
+
/**
|
|
186
|
+
* Imperative handler. Mutually exclusive with `spec` — a tool is
|
|
187
|
+
* either author-written code or a declarative spec, never both.
|
|
188
|
+
* Omit when providing `spec`; the framework synthesizes a handler
|
|
189
|
+
* from the spec's kind via the registered synthesizer.
|
|
190
|
+
*/
|
|
191
|
+
readonly handler?: (input: THandlerIn, ctx: ToolContext) => Promise<THandlerOut>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The concrete Tool returned by `defineTool`. When the author passes a
|
|
196
|
+
* Zod schema at the `input` slot, `inputZod` is present and carries the
|
|
197
|
+
* original schema so callers can `z.infer<typeof tool.inputZod>` at the
|
|
198
|
+
* type level. Symmetric for `output`.
|
|
199
|
+
*
|
|
200
|
+
* Wire form (`tool.input` / `tool.output`) is always the converted JSON
|
|
201
|
+
* Schema — non-TS consumers see no change.
|
|
202
|
+
*/
|
|
203
|
+
export type DefinedTool<
|
|
204
|
+
TInSchema extends AnySchema,
|
|
205
|
+
TOutSchema extends AnySchema,
|
|
206
|
+
THandlerIn = InferOutput<TInSchema>,
|
|
207
|
+
THandlerOut = InferOutput<TOutSchema>,
|
|
208
|
+
> = Tool<THandlerIn, THandlerOut> &
|
|
209
|
+
(TInSchema extends ZodLikeSchema
|
|
210
|
+
? { readonly inputZod: TInSchema }
|
|
211
|
+
: { readonly inputZod?: undefined }) &
|
|
212
|
+
(TOutSchema extends ZodLikeSchema
|
|
213
|
+
? { readonly outputZod: TOutSchema }
|
|
214
|
+
: { readonly outputZod?: undefined });
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Build a validated `Tool` from a user-supplied definition.
|
|
218
|
+
*
|
|
219
|
+
* Two authoring surfaces coexist:
|
|
220
|
+
* 1. JSON Schema Draft 2020-12 objects — the only shape that crosses
|
|
221
|
+
* the wire. Non-TS consumers use this.
|
|
222
|
+
* 2. Zod v4 schemas — TS-user sugar. Framework detects at author time,
|
|
223
|
+
* converts via `z.toJSONSchema()`, caches the JSON Schema wire form
|
|
224
|
+
* on `tool.input` / `tool.output`, preserves the original Zod schema
|
|
225
|
+
* on `tool.inputZod` / `tool.outputZod` for static type inference.
|
|
226
|
+
*
|
|
227
|
+
* Verifies (fail-fast):
|
|
228
|
+
* 1. The manifest projection conforms to `@kindgi/specs/tool.schema.json`.
|
|
229
|
+
* 2. Every declared `effects[].kind` is in the closed `EFFECT_KINDS` set.
|
|
230
|
+
* 3. `input` and `output` are legal JSON Schema Draft 2020-12 documents
|
|
231
|
+
* (after Zod conversion, when applicable).
|
|
232
|
+
*
|
|
233
|
+
* The `handler` is not validated at author time — its input/output are
|
|
234
|
+
* enforced by `invokeTool` at each call. Returning a tool from this function
|
|
235
|
+
* is the runtime's promise that it's structurally safe to invoke.
|
|
236
|
+
*
|
|
237
|
+
* `zod` is a **peer dependency**. Workspaces that never author with Zod
|
|
238
|
+
* never install it and never pay any resolution cost. Callers that DO
|
|
239
|
+
* pass a Zod schema without `zod` installed get a clean
|
|
240
|
+
* `invalid-schema` error surfaced through the returned `Result`.
|
|
241
|
+
*/
|
|
242
|
+
export function defineTool<
|
|
243
|
+
const TInSchema extends AnySchema,
|
|
244
|
+
const TOutSchema extends AnySchema,
|
|
245
|
+
THandlerIn = InferOutput<TInSchema>,
|
|
246
|
+
THandlerOut = InferOutput<TOutSchema>,
|
|
247
|
+
>(
|
|
248
|
+
spec: DefineToolSpec<TInSchema, TOutSchema, THandlerIn, THandlerOut>,
|
|
249
|
+
options?: DefineToolOptions,
|
|
250
|
+
): Result<DefinedTool<TInSchema, TOutSchema, THandlerIn, THandlerOut>, ToolError>;
|
|
251
|
+
/**
|
|
252
|
+
* Overload with explicit data-type generics, for JSON-Schema-only
|
|
253
|
+
* authors who want to state `<TInput, TOutput>` themselves. Without the
|
|
254
|
+
* generics, inference derives the handler types from a Zod schema, or
|
|
255
|
+
* (for JSON Schema) from the handler literal's return type.
|
|
256
|
+
*/
|
|
257
|
+
export function defineTool<TInput = unknown, TOutput = unknown>(
|
|
258
|
+
spec: Tool<TInput, TOutput>,
|
|
259
|
+
options?: DefineToolOptions,
|
|
260
|
+
): Result<Tool<TInput, TOutput>, ToolError>;
|
|
261
|
+
export function defineTool(
|
|
262
|
+
spec: DefineToolSpec<AnySchema, AnySchema>,
|
|
263
|
+
options: DefineToolOptions = {},
|
|
264
|
+
): Result<DefinedTool<AnySchema, AnySchema>, ToolError> {
|
|
265
|
+
// Fast path: JSON-Schema-only authoring never needs the peer dep.
|
|
266
|
+
const authoredWithZod = isZodSchema(spec.input) || isZodSchema(spec.output);
|
|
267
|
+
const converter = authoredWithZod ? resolveZodConverterSync() : undefined;
|
|
268
|
+
return finalizeDefinition(spec, converter, options);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** How `defineTool` builds a tool; for the runtime, not for pack authors. */
|
|
272
|
+
export interface DefineToolOptions {
|
|
273
|
+
/** What a declarative `spec`'s synthesized handler gets (the runtime's guarded `fetch`). */
|
|
274
|
+
readonly synthesizer?: ToolSpecSynthesizerOptions;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Async companion to `defineTool` — resolves the Zod peer dependency
|
|
279
|
+
* via dynamic `import()` instead of Node's `createRequire`. Prefer this
|
|
280
|
+
* in environments that prohibit `createRequire` (edge runtimes, some
|
|
281
|
+
* bundler configurations) OR for pack-init code that already lives on
|
|
282
|
+
* the async path.
|
|
283
|
+
*/
|
|
284
|
+
export async function defineToolAsync<
|
|
285
|
+
const TInSchema extends AnySchema,
|
|
286
|
+
const TOutSchema extends AnySchema,
|
|
287
|
+
THandlerIn = InferOutput<TInSchema>,
|
|
288
|
+
THandlerOut = InferOutput<TOutSchema>,
|
|
289
|
+
>(
|
|
290
|
+
spec: DefineToolSpec<TInSchema, TOutSchema, THandlerIn, THandlerOut>,
|
|
291
|
+
options: DefineToolOptions = {},
|
|
292
|
+
): Promise<Result<DefinedTool<TInSchema, TOutSchema, THandlerIn, THandlerOut>, ToolError>> {
|
|
293
|
+
const authoredWithZod = isZodSchema(spec.input) || isZodSchema(spec.output);
|
|
294
|
+
const converter = authoredWithZod ? await loadZodConverter() : undefined;
|
|
295
|
+
return finalizeDefinition(spec, converter, options);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function finalizeDefinition<
|
|
299
|
+
TInSchema extends AnySchema,
|
|
300
|
+
TOutSchema extends AnySchema,
|
|
301
|
+
THandlerIn,
|
|
302
|
+
THandlerOut,
|
|
303
|
+
>(
|
|
304
|
+
spec: DefineToolSpec<TInSchema, TOutSchema, THandlerIn, THandlerOut>,
|
|
305
|
+
converter: ZodConverter | undefined,
|
|
306
|
+
options: DefineToolOptions,
|
|
307
|
+
): Result<DefinedTool<TInSchema, TOutSchema, THandlerIn, THandlerOut>, ToolError> {
|
|
308
|
+
// `version` is required on every tool manifest.
|
|
309
|
+
// Deep semver grammar checked here via a lightweight regex; range
|
|
310
|
+
// parsing (for agent-side `tools[].version` refs) happens elsewhere
|
|
311
|
+
// via the `semver` library at dispatch time. This regex accepts
|
|
312
|
+
// exact semvers only — tool VERSIONS are exact (a tool ships a
|
|
313
|
+
// specific `1.2.3`); ranges live on the agent binding.
|
|
314
|
+
const specVersion = (spec as { readonly version?: unknown }).version;
|
|
315
|
+
if (typeof specVersion !== 'string' || !SEMVER_EXACT_RE.test(specVersion)) {
|
|
316
|
+
return {
|
|
317
|
+
kind: 'err',
|
|
318
|
+
error: {
|
|
319
|
+
code: 'invalid-tool-definition',
|
|
320
|
+
message:
|
|
321
|
+
'`version` is required on every tool manifest and must be an exact semver (e.g., `1.2.3`, `1.2.3-beta.1`). Agent bindings then reference tools via `{ id, version: <range> }`.',
|
|
322
|
+
issues: [
|
|
323
|
+
{
|
|
324
|
+
path: '/version',
|
|
325
|
+
message: 'version must be a non-empty exact semver string',
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// handler / spec mutual-exclusion + spec synthesis.
|
|
333
|
+
const authorHandler = (spec as { readonly handler?: unknown }).handler;
|
|
334
|
+
const declarativeSpec = (spec as { readonly spec?: ToolSpec }).spec;
|
|
335
|
+
if (authorHandler !== undefined && declarativeSpec !== undefined) {
|
|
336
|
+
return {
|
|
337
|
+
kind: 'err',
|
|
338
|
+
error: {
|
|
339
|
+
code: 'invalid-tool-definition',
|
|
340
|
+
message:
|
|
341
|
+
'`handler` and `spec` are mutually exclusive. Provide an imperative handler OR a declarative spec, not both.',
|
|
342
|
+
issues: [{ path: '/handler', message: 'handler + spec both set' }],
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
if (authorHandler === undefined && declarativeSpec === undefined) {
|
|
347
|
+
return {
|
|
348
|
+
kind: 'err',
|
|
349
|
+
error: {
|
|
350
|
+
code: 'invalid-tool-definition',
|
|
351
|
+
message:
|
|
352
|
+
'A tool must declare exactly one of `handler` (imperative) or `spec` (declarative).',
|
|
353
|
+
issues: [{ path: '/handler', message: 'handler + spec both absent' }],
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
let effectiveHandler = authorHandler as
|
|
358
|
+
| ((input: THandlerIn, ctx: ToolContext) => Promise<THandlerOut>)
|
|
359
|
+
| undefined;
|
|
360
|
+
if (declarativeSpec !== undefined) {
|
|
361
|
+
const synth = getToolSpecSynthesizer(declarativeSpec.kind);
|
|
362
|
+
if (synth === undefined) {
|
|
363
|
+
return {
|
|
364
|
+
kind: 'err',
|
|
365
|
+
error: {
|
|
366
|
+
code: 'invalid-tool-definition',
|
|
367
|
+
message: `No synthesizer registered for tool spec kind "${declarativeSpec.kind}". Import the package that owns this kind (e.g. \`@kindgi/tools\` for 'http') before calling \`defineTool\`.`,
|
|
368
|
+
issues: [{ path: '/spec/kind', message: `unknown kind "${declarativeSpec.kind}"` }],
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
effectiveHandler = synth(
|
|
373
|
+
declarativeSpec,
|
|
374
|
+
(spec as { readonly id: string }).id,
|
|
375
|
+
options.synthesizer,
|
|
376
|
+
) as (input: THandlerIn, ctx: ToolContext) => Promise<THandlerOut>;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const inputResolved = resolveWireSchema(spec.input, 'input', converter);
|
|
380
|
+
if (inputResolved.kind === 'err') return { kind: 'err', error: inputResolved.error };
|
|
381
|
+
const outputResolved = resolveWireSchema(spec.output, 'output', converter);
|
|
382
|
+
if (outputResolved.kind === 'err') return { kind: 'err', error: outputResolved.error };
|
|
383
|
+
|
|
384
|
+
// Rebuild the spec against the JSON Schema wire form. Downstream code
|
|
385
|
+
// (invokeTool, manifest projection, MCP publish) reads `.input` /
|
|
386
|
+
// `.output`; the original Zod objects are stored separately on
|
|
387
|
+
// `inputZod` / `outputZod`.
|
|
388
|
+
const wireSpec = {
|
|
389
|
+
...(spec as unknown as ToolManifest),
|
|
390
|
+
input: inputResolved.value.wire,
|
|
391
|
+
output: outputResolved.value.wire,
|
|
392
|
+
handler: effectiveHandler,
|
|
393
|
+
} as unknown as Tool<THandlerIn, THandlerOut>;
|
|
394
|
+
|
|
395
|
+
const manifest: ToolManifest = stripHandler(wireSpec);
|
|
396
|
+
|
|
397
|
+
const schemaResult = registry().validate<ToolManifest>(TOOL_SCHEMA_URI, manifest);
|
|
398
|
+
if (schemaResult.kind === 'err') {
|
|
399
|
+
const err = schemaResult.error;
|
|
400
|
+
if (err.code !== 'validation-error') {
|
|
401
|
+
throw new Error(`@kindgi/tools: unexpected schema error ${err.code}: ${err.message}`);
|
|
402
|
+
}
|
|
403
|
+
return { kind: 'err', error: toDefinitionError(err) };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const badEffect = checkEffectKinds(manifest);
|
|
407
|
+
if (badEffect) return { kind: 'err', error: badEffect };
|
|
408
|
+
|
|
409
|
+
const badInput = compilesAsSchema(manifest.input, 'input');
|
|
410
|
+
if (badInput) return { kind: 'err', error: badInput };
|
|
411
|
+
const badOutput = compilesAsSchema(manifest.output, 'output');
|
|
412
|
+
if (badOutput) return { kind: 'err', error: badOutput };
|
|
413
|
+
|
|
414
|
+
const tool = {
|
|
415
|
+
...wireSpec,
|
|
416
|
+
...(inputResolved.value.zod !== undefined && { inputZod: inputResolved.value.zod }),
|
|
417
|
+
...(outputResolved.value.zod !== undefined && { outputZod: outputResolved.value.zod }),
|
|
418
|
+
} as unknown as DefinedTool<TInSchema, TOutSchema, THandlerIn, THandlerOut>;
|
|
419
|
+
|
|
420
|
+
return { kind: 'ok', value: tool };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function stripHandler<TInput, TOutput>(spec: Tool<TInput, TOutput>): ToolManifest {
|
|
424
|
+
// Structural copy dropping `handler`. `handler` is a function and Ajv rejects
|
|
425
|
+
// functions during validation, so it MUST be stripped before schema check.
|
|
426
|
+
const { handler: _handler, inputZod: _iz, outputZod: _oz, ...manifest } = spec;
|
|
427
|
+
return manifest;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function toDefinitionError(err: {
|
|
431
|
+
readonly errors: readonly unknown[];
|
|
432
|
+
readonly message: string;
|
|
433
|
+
}): InvalidToolDefinitionError {
|
|
434
|
+
const issues = err.errors.map((e) => {
|
|
435
|
+
const ajvErr = e as { instancePath?: unknown; message?: unknown };
|
|
436
|
+
return {
|
|
437
|
+
path: typeof ajvErr.instancePath === 'string' ? ajvErr.instancePath : '',
|
|
438
|
+
message: typeof ajvErr.message === 'string' ? ajvErr.message : 'validation failed',
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
return { code: 'invalid-tool-definition', message: err.message, issues };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function checkEffectKinds(manifest: ToolManifest): UnknownEffectError | undefined {
|
|
445
|
+
const kinds = new Set<string>(EFFECT_KINDS);
|
|
446
|
+
for (const eff of manifest.effects ?? []) {
|
|
447
|
+
if (!kinds.has(eff.kind)) {
|
|
448
|
+
return {
|
|
449
|
+
code: 'unknown-effect',
|
|
450
|
+
message: `Tool "${manifest.id}" declares unknown effect kind "${eff.kind}"`,
|
|
451
|
+
kind: eff.kind,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Validate an arbitrary wire manifest against `@kindgi/specs/tool.schema.json`
|
|
460
|
+
* + the same effect / input-schema / output-schema checks `defineTool`
|
|
461
|
+
* runs, but without requiring a runtime `handler`. Used by transport
|
|
462
|
+
* layers (HTTP API, MCP publish) that accept metadata-only tool
|
|
463
|
+
* registrations — the actual handler is bound server-side via the
|
|
464
|
+
* runtime registry.
|
|
465
|
+
*
|
|
466
|
+
* The returned value has the manifest fields only; there is no
|
|
467
|
+
* `handler` (metadata-only shape).
|
|
468
|
+
*/
|
|
469
|
+
export function validateToolManifest(
|
|
470
|
+
manifest: unknown,
|
|
471
|
+
): Result<ToolManifest, InvalidToolDefinitionError | InvalidSchemaError | UnknownEffectError> {
|
|
472
|
+
if (manifest === null || typeof manifest !== 'object') {
|
|
473
|
+
return {
|
|
474
|
+
kind: 'err',
|
|
475
|
+
error: {
|
|
476
|
+
code: 'invalid-tool-definition',
|
|
477
|
+
message: 'Tool manifest must be an object',
|
|
478
|
+
issues: [{ path: '', message: 'must be an object' }],
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
// `defineTool` strips `handler` before schema validation; forward the
|
|
483
|
+
// wire object unchanged. `handler` isn't in the ToolManifest shape,
|
|
484
|
+
// so any incidental value on the wire is ignored by validation.
|
|
485
|
+
const { handler: _ignored, ...candidate } = manifest as Record<string, unknown>;
|
|
486
|
+
const schemaResult = registry().validate<ToolManifest>(TOOL_SCHEMA_URI, candidate);
|
|
487
|
+
if (schemaResult.kind === 'err') {
|
|
488
|
+
const err = schemaResult.error;
|
|
489
|
+
if (err.code !== 'validation-error') {
|
|
490
|
+
throw new Error(`@kindgi/tools: unexpected schema error ${err.code}: ${err.message}`);
|
|
491
|
+
}
|
|
492
|
+
return { kind: 'err', error: toDefinitionError(err) };
|
|
493
|
+
}
|
|
494
|
+
const parsed = candidate as unknown as ToolManifest;
|
|
495
|
+
const badEffect = checkEffectKinds(parsed);
|
|
496
|
+
if (badEffect) return { kind: 'err', error: badEffect };
|
|
497
|
+
const badInput = compilesAsSchema(parsed.input, 'input');
|
|
498
|
+
if (badInput) return { kind: 'err', error: badInput };
|
|
499
|
+
const badOutput = compilesAsSchema(parsed.output, 'output');
|
|
500
|
+
if (badOutput) return { kind: 'err', error: badOutput };
|
|
501
|
+
const badUrl = checkHttpUrlTemplate(parsed);
|
|
502
|
+
if (badUrl) return { kind: 'err', error: badUrl };
|
|
503
|
+
return { kind: 'ok', value: parsed };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* An HTTP tool's `urlTemplate` fills `{placeholders}` in its path and
|
|
508
|
+
* query only. In the scheme, host or port, a run's input (or a model's
|
|
509
|
+
* arguments) would choose where the server sends the request.
|
|
510
|
+
*/
|
|
511
|
+
function checkHttpUrlTemplate(manifest: ToolManifest): InvalidToolDefinitionError | undefined {
|
|
512
|
+
const spec = manifest.spec;
|
|
513
|
+
if (spec?.kind !== 'http') return undefined;
|
|
514
|
+
const origin = /^(https?):\/\/([^/?#]*)/i.exec(spec.urlTemplate);
|
|
515
|
+
const message =
|
|
516
|
+
origin === null
|
|
517
|
+
? 'must start with http:// or https:// and a host'
|
|
518
|
+
: /[{}]/.test(origin[2] ?? '')
|
|
519
|
+
? 'fills {placeholders} in its path and query only, never in its host or port'
|
|
520
|
+
: (origin[2] ?? '') === ''
|
|
521
|
+
? 'must name a host'
|
|
522
|
+
: undefined;
|
|
523
|
+
if (message === undefined) return undefined;
|
|
524
|
+
return {
|
|
525
|
+
code: 'invalid-tool-definition',
|
|
526
|
+
message: `Tool "${manifest.id}" spec.urlTemplate ${message}`,
|
|
527
|
+
issues: [{ path: '/spec/urlTemplate', message }],
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Exported for tests and downstream registry building. */
|
|
532
|
+
export function toolIdOf(spec: Pick<ToolManifest, 'id'>): ToolId {
|
|
533
|
+
return spec.id;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** The `$id` of the JSON Schema this loader validates against. */
|
|
537
|
+
export const TOOL_SCHEMA_URI_EXPORTED = TOOL_SCHEMA_URI;
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
import type { ToolId } from '@kindgi/types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Errors emitted by @kindgi/tools. Every variant carries a `code` for
|
|
8
|
+
* pattern matching; messages are human-readable, not API contract.
|
|
9
|
+
*/
|
|
10
|
+
export type ToolError =
|
|
11
|
+
| InvalidToolDefinitionError
|
|
12
|
+
| InvalidSchemaError
|
|
13
|
+
| UnknownEffectError
|
|
14
|
+
| DuplicateToolError
|
|
15
|
+
| DuplicateToolVersionError
|
|
16
|
+
| ToolNotFoundError
|
|
17
|
+
| ToolVersionNotFoundError
|
|
18
|
+
| InvalidVersionRangeError
|
|
19
|
+
| ToolVersionUnresolvableError
|
|
20
|
+
| InputValidationError
|
|
21
|
+
| OutputValidationError
|
|
22
|
+
| HandlerError
|
|
23
|
+
| PreconditionFailedError;
|
|
24
|
+
|
|
25
|
+
/** `defineTool` was called with something the schema loader rejects. */
|
|
26
|
+
export interface InvalidToolDefinitionError {
|
|
27
|
+
readonly code: 'invalid-tool-definition';
|
|
28
|
+
readonly message: string;
|
|
29
|
+
readonly issues: readonly { readonly path: string; readonly message: string }[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The provided `input` or `output` is not a valid JSON Schema Draft 2020-12. */
|
|
33
|
+
export interface InvalidSchemaError {
|
|
34
|
+
readonly code: 'invalid-schema';
|
|
35
|
+
readonly message: string;
|
|
36
|
+
readonly where: 'input' | 'output';
|
|
37
|
+
readonly cause: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** An effect kind not in `EFFECT_KINDS` was declared. */
|
|
41
|
+
export interface UnknownEffectError {
|
|
42
|
+
readonly code: 'unknown-effect';
|
|
43
|
+
readonly message: string;
|
|
44
|
+
readonly kind: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* ToolRegistry.register was called with an id that already exists AND the
|
|
49
|
+
* registry doesn't support multiple versions per id. `createToolRegistry`
|
|
50
|
+
* holds several versions per id and returns `duplicate-tool-version`
|
|
51
|
+
* instead when the same (id, version) is registered twice.
|
|
52
|
+
*/
|
|
53
|
+
export interface DuplicateToolError {
|
|
54
|
+
readonly code: 'duplicate-tool';
|
|
55
|
+
readonly message: string;
|
|
56
|
+
readonly id: ToolId;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Same `(id, version)` pair was registered twice — versions must be unique. */
|
|
60
|
+
export interface DuplicateToolVersionError {
|
|
61
|
+
readonly code: 'duplicate-tool-version';
|
|
62
|
+
readonly message: string;
|
|
63
|
+
readonly id: ToolId;
|
|
64
|
+
readonly version: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** ToolRegistry.get was called with an id that isn't registered. */
|
|
68
|
+
export interface ToolNotFoundError {
|
|
69
|
+
readonly code: 'tool-not-found';
|
|
70
|
+
readonly message: string;
|
|
71
|
+
readonly id: ToolId;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The id exists but the requested exact version doesn't. */
|
|
75
|
+
export interface ToolVersionNotFoundError {
|
|
76
|
+
readonly code: 'tool-version-not-found';
|
|
77
|
+
readonly message: string;
|
|
78
|
+
readonly id: ToolId;
|
|
79
|
+
readonly version: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The provided version range isn't a valid semver range (per npm grammar). */
|
|
83
|
+
export interface InvalidVersionRangeError {
|
|
84
|
+
readonly code: 'invalid-version-range';
|
|
85
|
+
readonly message: string;
|
|
86
|
+
readonly id: ToolId;
|
|
87
|
+
readonly range: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `resolve(id, range)` found the id but no registered version satisfies
|
|
92
|
+
* the range. Distinct from `tool-not-found` (no id at all) and from
|
|
93
|
+
* `invalid-version-range` (grammar error on the range string).
|
|
94
|
+
*/
|
|
95
|
+
export interface ToolVersionUnresolvableError {
|
|
96
|
+
readonly code: 'tool-version-unresolvable';
|
|
97
|
+
readonly message: string;
|
|
98
|
+
readonly id: ToolId;
|
|
99
|
+
readonly range: string;
|
|
100
|
+
readonly availableVersions: readonly string[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** invokeTool: the caller's input didn't validate against tool.input. */
|
|
104
|
+
export interface InputValidationError {
|
|
105
|
+
readonly code: 'input-validation-failed';
|
|
106
|
+
readonly message: string;
|
|
107
|
+
readonly toolId: ToolId;
|
|
108
|
+
readonly errors: readonly unknown[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** invokeTool: the handler returned something that didn't validate against tool.output. */
|
|
112
|
+
export interface OutputValidationError {
|
|
113
|
+
readonly code: 'output-validation-failed';
|
|
114
|
+
readonly message: string;
|
|
115
|
+
readonly toolId: ToolId;
|
|
116
|
+
readonly errors: readonly unknown[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** invokeTool: the handler threw. */
|
|
120
|
+
export interface HandlerError {
|
|
121
|
+
readonly code: 'handler-error';
|
|
122
|
+
readonly message: string;
|
|
123
|
+
readonly toolId: ToolId;
|
|
124
|
+
readonly cause: unknown;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* invokeTool: the runtime refused to run the tool — something it declares
|
|
129
|
+
* wasn't there (a `ToolPreconditionError` from the runtime's handler
|
|
130
|
+
* wrapper). The tool's own code never ran.
|
|
131
|
+
*/
|
|
132
|
+
export interface PreconditionFailedError {
|
|
133
|
+
readonly code: 'precondition-failed';
|
|
134
|
+
readonly message: string;
|
|
135
|
+
readonly toolId: ToolId;
|
|
136
|
+
/** The runtime's code for what was missing (`secret-unavailable`, …). */
|
|
137
|
+
readonly reason: string;
|
|
138
|
+
readonly cause: unknown;
|
|
139
|
+
}
|