@zerotal/ai 1.5.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/src/schema.ts ADDED
@@ -0,0 +1,292 @@
1
+ /**
2
+ * `@zerotal/validator` schema → JSON Schema, for structured output and tool inputs.
3
+ *
4
+ * The supported JSON Schema subset is narrow — narrower than most people expect,
5
+ * and the API rejects anything outside it at *request* time, which is the worst
6
+ * possible place to find out. So this module makes one decision and holds it:
7
+ *
8
+ * > **Strip what the API cannot express, then re-check it client-side.**
9
+ *
10
+ * `min(2)`, `max(140)`, `regex(...)` and friends are dropped from the emitted
11
+ * schema and re-applied by {@link recheckAgainstSchema} once the model answers.
12
+ * We already own a validator; running it over the parsed object costs nothing and
13
+ * keeps the constraint honest instead of decorative. The alternative — refusing
14
+ * the schema at definition time — would make `rule.string().min(2)` unusable for
15
+ * structured output, which is a worse deal for a constraint we can enforce
16
+ * ourselves.
17
+ *
18
+ * The exception is anything with no client-side rescue: a **recursive** schema
19
+ * cannot be expressed at all, and a **file** field has no JSON representation.
20
+ * Those throw here, at definition time, where the stack trace points at the
21
+ * schema rather than at a 400 from the provider.
22
+ */
23
+ import type { FieldRule, FieldRuleDefinition, Schema } from "@zerotal/validator";
24
+ import { runValidation } from "@zerotal/validator";
25
+ import { AiSchemaError } from "./errors.ts";
26
+ import type { JsonSchema } from "./types.ts";
27
+
28
+ /** Either shape callers have on hand: the builder map, or the raw definitions. */
29
+ export type SchemaInput = Record<string, FieldRule | FieldRuleDefinition>;
30
+
31
+ /**
32
+ * Rule names that map onto a supported JSON Schema keyword. Everything else is
33
+ * stripped and re-checked. Kept as a map rather than a switch so the supported
34
+ * set is one readable list.
35
+ */
36
+ const FORMAT_RULES: Record<string, string> = {
37
+ email: "email",
38
+ url: "uri",
39
+ ip: "ipv4",
40
+ };
41
+
42
+ /** Unwrap a builder rule to its definition. */
43
+ function defOf(value: FieldRule | FieldRuleDefinition): FieldRuleDefinition {
44
+ return "_def" in value ? value._def : value;
45
+ }
46
+
47
+ /** Normalise either input shape to raw definitions. */
48
+ export function toSchema(input: SchemaInput): Schema {
49
+ const out: Schema = {};
50
+ for (const [key, value] of Object.entries(input)) out[key] = defOf(value);
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * Translate a validator schema into the JSON Schema the providers accept.
56
+ *
57
+ * Every object gets `additionalProperties: false` — required, not optional, and
58
+ * the single most common reason a hand-written schema is rejected.
59
+ *
60
+ * Optional and nullable fields are emitted the same way: listed in `required`,
61
+ * with the value type widened to `anyOf: [<type>, {type: "null"}]`. That is the
62
+ * form both Anthropic and OpenAI's strict mode accept, and
63
+ * {@link recheckAgainstSchema} drops a `null` that stood in for "absent" before
64
+ * re-validating, so the round trip preserves the original meaning.
65
+ *
66
+ * @throws {AiSchemaError} for a recursive schema or a `file` field.
67
+ *
68
+ * @example
69
+ * translateSchema({
70
+ * title: rule.string().min(3), // min stripped, re-checked after
71
+ * tags: rule.array(rule.string()),
72
+ * author: rule.string().optional(), // → anyOf [string, null]
73
+ * });
74
+ */
75
+ export function translateSchema(input: SchemaInput): JsonSchema {
76
+ const schema = toSchema(input);
77
+ return objectSchema(schema, new Set());
78
+ }
79
+
80
+ /** Build the `{type: "object", ...}` node for a map of field definitions. */
81
+ function objectSchema(schema: Schema, path: Set<FieldRuleDefinition>): JsonSchema {
82
+ const properties: Record<string, JsonSchema> = {};
83
+ const required: string[] = [];
84
+
85
+ for (const [name, def] of Object.entries(schema)) {
86
+ properties[name] = fieldSchema(def, name, path);
87
+ // Every property is listed. Optionality lives in the null branch of anyOf,
88
+ // because a subset `required` is rejected by strict tool use.
89
+ required.push(name);
90
+ }
91
+
92
+ return {
93
+ type: "object",
94
+ properties,
95
+ required,
96
+ additionalProperties: false,
97
+ };
98
+ }
99
+
100
+ /** Translate one field, widening to `anyOf` when it may be absent or null. */
101
+ function fieldSchema(
102
+ def: FieldRuleDefinition,
103
+ name: string,
104
+ path: Set<FieldRuleDefinition>,
105
+ ): JsonSchema {
106
+ if (path.has(def)) {
107
+ throw new AiSchemaError(
108
+ `The schema for '${name}' is recursive. Structured output does not support recursive ` +
109
+ `schemas — flatten the shape, or bound the depth by declaring each level explicitly.`,
110
+ { field: name },
111
+ );
112
+ }
113
+
114
+ const next = new Set(path).add(def);
115
+ const base = baseSchema(def, name, next);
116
+
117
+ const mayBeNull = def.nullable || !def.required || def.sometimes === true;
118
+ if (!mayBeNull) return base;
119
+
120
+ return { anyOf: [base, { type: "null" }] };
121
+ }
122
+
123
+ /** The un-widened schema for a field's declared type. */
124
+ function baseSchema(
125
+ def: FieldRuleDefinition,
126
+ name: string,
127
+ path: Set<FieldRuleDefinition>,
128
+ ): JsonSchema {
129
+ switch (def.type) {
130
+ case "string":
131
+ return withStringKeywords({ type: "string" }, def);
132
+
133
+ case "number":
134
+ return { type: def.rules.some((r) => r.name === "integer") ? "integer" : "number" };
135
+
136
+ case "boolean":
137
+ return { type: "boolean" };
138
+
139
+ case "date":
140
+ // No native date type in JSON Schema; `date-time` is in the supported
141
+ // format list, and the model returns an ISO-8601 string.
142
+ return { type: "string", format: "date-time" };
143
+
144
+ case "array": {
145
+ if (!def.children) {
146
+ throw new AiSchemaError(
147
+ `The array field '${name}' has no item type. Declare one: rule.array(rule.string()).`,
148
+ { field: name },
149
+ );
150
+ }
151
+ return { type: "array", items: fieldSchema(def.children, `${name}[]`, path) };
152
+ }
153
+
154
+ case "object": {
155
+ if (!def.shape) {
156
+ throw new AiSchemaError(
157
+ `The object field '${name}' has no shape. Declare one: rule.object({ … }).`,
158
+ { field: name },
159
+ );
160
+ }
161
+ const properties: Record<string, JsonSchema> = {};
162
+ const required: string[] = [];
163
+ for (const [key, child] of Object.entries(def.shape)) {
164
+ properties[key] = fieldSchema(child, `${name}.${key}`, path);
165
+ required.push(key);
166
+ }
167
+ return { type: "object", properties, required, additionalProperties: false };
168
+ }
169
+
170
+ case "file":
171
+ throw new AiSchemaError(
172
+ `The field '${name}' is a file. A model cannot return a file through structured output — ` +
173
+ `ask for a filename or an identifier instead.`,
174
+ { field: name },
175
+ );
176
+
177
+ default:
178
+ throw new AiSchemaError(
179
+ `The field '${name}' has an unsupported type '${String(def.type)}'.`,
180
+ {
181
+ field: name,
182
+ type: def.type,
183
+ },
184
+ );
185
+ }
186
+ }
187
+
188
+ /** Apply the string rules that survive translation: `enum` and `format`. */
189
+ function withStringKeywords(base: JsonSchema, def: FieldRuleDefinition): JsonSchema {
190
+ const out: JsonSchema = { ...base };
191
+
192
+ for (const rule of def.rules) {
193
+ if (rule.name === "in" && Array.isArray(rule.args[0])) {
194
+ // `enum` narrows harder than any format, so it wins outright.
195
+ return { enum: (rule.args[0] as string[]).slice() };
196
+ }
197
+ const format = FORMAT_RULES[rule.name];
198
+ if (format) out.format = format;
199
+ }
200
+
201
+ return out;
202
+ }
203
+
204
+ /**
205
+ * Which of a schema's constraints this translation could not express.
206
+ *
207
+ * Useful in a test or a boot-time audit: it names exactly what
208
+ * {@link recheckAgainstSchema} will be carrying, so a schema whose important
209
+ * constraint is invisible to the model is a thing you can notice rather than
210
+ * discover.
211
+ *
212
+ * @example
213
+ * strippedConstraints({ title: rule.string().min(3).max(80) });
214
+ * // → ["title: min", "title: max"]
215
+ */
216
+ export function strippedConstraints(input: SchemaInput): string[] {
217
+ const out: string[] = [];
218
+ walk(toSchema(input), "", out, new Set());
219
+ return out;
220
+ }
221
+
222
+ function walk(schema: Schema, prefix: string, out: string[], path: Set<FieldRuleDefinition>): void {
223
+ for (const [name, def] of Object.entries(schema)) {
224
+ if (path.has(def)) continue;
225
+ const next = new Set(path).add(def);
226
+ const label = prefix ? `${prefix}.${name}` : name;
227
+
228
+ for (const rule of def.rules) {
229
+ if (rule.name === "in" || FORMAT_RULES[rule.name] || rule.name === "integer") continue;
230
+ out.push(`${label}: ${rule.name}`);
231
+ }
232
+
233
+ if (def.shape) walk(def.shape, label, out, next);
234
+ if (def.children) walk({ [`${name}[]`]: def.children }, prefix, out, next);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Re-apply the constraints translation had to drop.
240
+ *
241
+ * Called on the model's parsed answer. A `null` standing in for an absent
242
+ * optional field is removed first — the wire form widened it to null, and the
243
+ * validator would otherwise reject a value the caller never asked for.
244
+ *
245
+ * @returns The validated object.
246
+ * @throws {AiSchemaError} listing every field that failed.
247
+ *
248
+ * @internal
249
+ */
250
+ export function recheckAgainstSchema<T>(input: SchemaInput, value: unknown): T {
251
+ const schema = toSchema(input);
252
+
253
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
254
+ throw new AiSchemaError(
255
+ `The model returned ${Array.isArray(value) ? "an array" : typeof value}, not an object.`,
256
+ { received: value },
257
+ );
258
+ }
259
+
260
+ const cleaned = dropAbsentNulls(schema, value as Record<string, unknown>);
261
+ const result = runValidation(schema, cleaned);
262
+
263
+ if (!result.success) {
264
+ const detail = Object.entries(result.errors)
265
+ .map(([field, message]) => `${field}: ${message}`)
266
+ .join("; ");
267
+ throw new AiSchemaError(
268
+ `The model's answer does not satisfy the schema — ${detail}. These constraints are enforced ` +
269
+ `here rather than by the provider, because structured output cannot express them.`,
270
+ { errors: result.errors },
271
+ );
272
+ }
273
+
274
+ return result.data as T;
275
+ }
276
+
277
+ /** Remove `null`s that stood in for "absent" on optional, non-nullable fields. */
278
+ function dropAbsentNulls(schema: Schema, value: Record<string, unknown>): Record<string, unknown> {
279
+ const out: Record<string, unknown> = { ...value };
280
+
281
+ for (const [name, def] of Object.entries(schema)) {
282
+ if (out[name] === null && !def.nullable) {
283
+ delete out[name];
284
+ continue;
285
+ }
286
+ if (def.shape && out[name] && typeof out[name] === "object") {
287
+ out[name] = dropAbsentNulls(def.shape, out[name] as Record<string, unknown>);
288
+ }
289
+ }
290
+
291
+ return out;
292
+ }
package/src/spend.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Spend ceilings and the running spend ledger.
3
+ *
4
+ * Two guards, both cheap, both approximate on purpose:
5
+ *
6
+ * - **Per-request** is checked *before* the call, from a token estimate, because
7
+ * after the call the money is already spent. It bounds the blast radius of one
8
+ * runaway prompt.
9
+ * - **Per-day** is checked from actual reported usage, accumulated in-process.
10
+ * In-process is a real limitation — N workers get N ceilings — and it is
11
+ * stated rather than hidden, because the alternative (a shared counter behind
12
+ * the cache) buys precision for an availability dependency on the hot path.
13
+ *
14
+ * A model with no price contributes 0 and is never blocked. See `pricing.ts`.
15
+ */
16
+ import { AiSpendLimitError } from "./errors.ts";
17
+ import { estimateCost, modelPrice } from "./pricing.ts";
18
+ import type { AiLimitsConfigShape, AiUsage } from "./types.ts";
19
+
20
+ /** Today's spend, keyed by UTC date so the window rolls without a timer. */
21
+ let _day = "";
22
+ let _spentUsd = 0;
23
+
24
+ /** UTC `YYYY-MM-DD`. UTC, not local, so a deploy across zones agrees with itself. */
25
+ function today(): string {
26
+ return new Date().toISOString().slice(0, 10);
27
+ }
28
+
29
+ function roll(): void {
30
+ const now = today();
31
+ if (now !== _day) {
32
+ _day = now;
33
+ _spentUsd = 0;
34
+ }
35
+ }
36
+
37
+ /** USD recorded so far today, in this process. */
38
+ export function spentToday(): number {
39
+ roll();
40
+ return _spentUsd;
41
+ }
42
+
43
+ /** Add a completed request's cost to today's total. */
44
+ export function recordSpend(model: string, usage: AiUsage): number {
45
+ roll();
46
+ const cost = estimateCost(model, usage);
47
+ _spentUsd += cost;
48
+ return cost;
49
+ }
50
+
51
+ /** Reset the ledger. Tests, and the `ai:spend --reset` path. */
52
+ export function resetSpend(): void {
53
+ _day = today();
54
+ _spentUsd = 0;
55
+ }
56
+
57
+ /**
58
+ * Refuse a request that would breach a ceiling.
59
+ *
60
+ * @param model - The model about to be called.
61
+ * @param estimatedInputTokens - Counted, not guessed, where the driver can.
62
+ * @param maxOutputTokens - The request's own ceiling; the worst case we can bound.
63
+ *
64
+ * @throws {AiSpendLimitError} when either ceiling would be breached.
65
+ */
66
+ export function assertWithinLimits(
67
+ limits: AiLimitsConfigShape,
68
+ model: string,
69
+ estimatedInputTokens: number,
70
+ maxOutputTokens: number,
71
+ ): void {
72
+ if (limits.perDayUsd > 0 && spentToday() >= limits.perDayUsd) {
73
+ throw new AiSpendLimitError(
74
+ `Daily AI spend ceiling reached: $${spentToday().toFixed(2)} of $${limits.perDayUsd.toFixed(2)}. ` +
75
+ `Raise limits.perDayUsd in config/ai.ts, or wait for the UTC day to roll.`,
76
+ { model, spentUsd: spentToday(), limitUsd: limits.perDayUsd },
77
+ );
78
+ }
79
+
80
+ if (limits.perRequestUsd <= 0) return;
81
+ // Unpriced model: the ceiling has nothing to compare against, so it stands
82
+ // aside rather than blocking every request to a model we simply don't know.
83
+ if (!modelPrice(model)) return;
84
+
85
+ const worstCase = estimateCost(model, {
86
+ inputTokens: estimatedInputTokens,
87
+ outputTokens: maxOutputTokens,
88
+ cacheReadTokens: 0,
89
+ cacheWriteTokens: 0,
90
+ });
91
+
92
+ if (worstCase > limits.perRequestUsd) {
93
+ throw new AiSpendLimitError(
94
+ `This request could cost up to $${worstCase.toFixed(4)}, over the per-request ceiling of ` +
95
+ `$${limits.perRequestUsd.toFixed(4)}. Shorten the prompt, lower maxTokens, or raise ` +
96
+ `limits.perRequestUsd in config/ai.ts.`,
97
+ { model, estimatedInputTokens, maxOutputTokens, worstCaseUsd: worstCase },
98
+ );
99
+ }
100
+ }
package/src/stats.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * In-process counters behind the monitor's AI section.
3
+ *
4
+ * A ring buffer, not a table: the panel answers "what is this app doing with the
5
+ * model right now", and the durable record of that is the provider's own
6
+ * dashboard plus whatever the app writes down deliberately. Keeping prompts in
7
+ * a framework-owned store would make every app that installs this package a
8
+ * place user prompts accumulate, which is not a decision this package gets to
9
+ * make for them.
10
+ */
11
+
12
+ /** One recorded generation. */
13
+ export interface AiDelivery {
14
+ at: number;
15
+ driver: string;
16
+ model: string;
17
+ operation: string;
18
+ inputTokens: number;
19
+ outputTokens: number;
20
+ cacheReadTokens: number;
21
+ durationMs: number;
22
+ costUsd: number;
23
+ ok: boolean;
24
+ refused: boolean;
25
+ /** Already redacted by the manager — never the raw prompt. */
26
+ preview: string;
27
+ error?: string;
28
+ }
29
+
30
+ /** Rolled-up figures for one model. */
31
+ export interface ModelStat {
32
+ model: string;
33
+ calls: number;
34
+ inputTokens: number;
35
+ outputTokens: number;
36
+ cacheReadTokens: number;
37
+ costUsd: number;
38
+ failures: number;
39
+ refusals: number;
40
+ /** Median latency, in milliseconds. */
41
+ p50: number;
42
+ /** 95th-percentile latency, in milliseconds. */
43
+ p95: number;
44
+ }
45
+
46
+ /** How many generations are kept. Roughly a working session's worth. */
47
+ const CAPACITY = 200;
48
+
49
+ const _deliveries: AiDelivery[] = [];
50
+
51
+ /** Record one generation. Called from the observability bridge. @internal */
52
+ export function recordDelivery(delivery: AiDelivery): void {
53
+ _deliveries.push(delivery);
54
+ if (_deliveries.length > CAPACITY) _deliveries.splice(0, _deliveries.length - CAPACITY);
55
+ }
56
+
57
+ /** The most recent generations, newest first. */
58
+ export function recentGenerations(limit = 50): AiDelivery[] {
59
+ return _deliveries.slice(-limit).reverse();
60
+ }
61
+
62
+ /** Per-model roll-up over everything still in the buffer. */
63
+ export function modelStats(): ModelStat[] {
64
+ const byModel = new Map<string, { rows: AiDelivery[]; latencies: number[] }>();
65
+
66
+ for (const delivery of _deliveries) {
67
+ const entry = byModel.get(delivery.model) ?? { rows: [], latencies: [] };
68
+ entry.rows.push(delivery);
69
+ if (delivery.ok) entry.latencies.push(delivery.durationMs);
70
+ byModel.set(delivery.model, entry);
71
+ }
72
+
73
+ const out: ModelStat[] = [];
74
+ for (const [model, { rows, latencies }] of byModel) {
75
+ latencies.sort((a, b) => a - b);
76
+ out.push({
77
+ model,
78
+ calls: rows.length,
79
+ inputTokens: sum(rows, (r) => r.inputTokens),
80
+ outputTokens: sum(rows, (r) => r.outputTokens),
81
+ cacheReadTokens: sum(rows, (r) => r.cacheReadTokens),
82
+ costUsd: sum(rows, (r) => r.costUsd),
83
+ failures: rows.filter((r) => !r.ok).length,
84
+ refusals: rows.filter((r) => r.refused).length,
85
+ p50: percentile(latencies, 0.5),
86
+ p95: percentile(latencies, 0.95),
87
+ });
88
+ }
89
+
90
+ return out.sort((a, b) => b.costUsd - a.costUsd || b.calls - a.calls);
91
+ }
92
+
93
+ /** Share of recorded calls that the provider declined, 0–1. */
94
+ export function refusalRate(): number {
95
+ if (_deliveries.length === 0) return 0;
96
+ return _deliveries.filter((d) => d.refused).length / _deliveries.length;
97
+ }
98
+
99
+ /** Reset the buffer. Tests. */
100
+ export function resetStats(): void {
101
+ _deliveries.length = 0;
102
+ }
103
+
104
+ function sum(rows: AiDelivery[], pick: (row: AiDelivery) => number): number {
105
+ let total = 0;
106
+ for (const row of rows) total += pick(row);
107
+ return total;
108
+ }
109
+
110
+ /**
111
+ * Nearest-rank percentile over a sorted list.
112
+ *
113
+ * Nearest-rank rather than interpolated: with a couple of dozen samples the
114
+ * interpolation invents a latency nothing actually took, and the point of a p95
115
+ * here is to name a request that really happened.
116
+ */
117
+ function percentile(sorted: number[], fraction: number): number {
118
+ if (sorted.length === 0) return 0;
119
+ const rank = Math.ceil(fraction * sorted.length);
120
+ return Math.round(sorted[Math.min(rank, sorted.length) - 1] ?? 0);
121
+ }
package/src/tool.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { RuleBuilder } from "@zerotal/validator";
2
+ import type { FieldRule } from "@zerotal/validator";
3
+ import { recheckAgainstSchema, translateSchema, type SchemaInput } from "./schema.ts";
4
+ import type { AiTool, AiToolContext } from "./types.ts";
5
+
6
+ /**
7
+ * Define a tool the model can call, with its input described by the validator
8
+ * you already use for forms.
9
+ *
10
+ * The schema is translated to JSON Schema for the provider and re-checked on the
11
+ * way back in, so the handler receives input that has actually been validated —
12
+ * not merely input the provider promised to shape. Constraints the provider
13
+ * cannot express (`min`, `regex`, …) are enforced by that second pass; see
14
+ * `schema.ts` for why that is the deal.
15
+ *
16
+ * @example
17
+ * const lookupOrder = tool({
18
+ * name: "lookup_order",
19
+ * description:
20
+ * "Fetch one order by its id. Call this whenever the user refers to an order " +
21
+ * "number — do not answer from the conversation alone.",
22
+ * input: (rule) => ({ id: rule.string().uuid() }),
23
+ * async handle({ id }) {
24
+ * return await Order.find(id);
25
+ * },
26
+ * });
27
+ *
28
+ * await Ai.agent({ prompt: "Where is order 2f1c…?", tools: [lookupOrder] });
29
+ */
30
+ export function tool<I extends Record<string, unknown> = Record<string, unknown>>(options: {
31
+ /** Snake_case, specific: `lookup_order` beats `orders`. */
32
+ name: string;
33
+ /**
34
+ * What it does *and when to call it*. The trigger condition is the half that
35
+ * moves the call rate — a description that only states what the tool does
36
+ * leaves the model guessing about when it applies.
37
+ */
38
+ description: string;
39
+ /** The input shape, as a validator schema. */
40
+ input: ((rule: RuleBuilder) => Record<string, FieldRule>) | SchemaInput;
41
+ /** Runs when the model calls the tool. Return anything JSON-serializable. */
42
+ handle: (input: I, ctx: AiToolContext) => Promise<unknown> | unknown;
43
+ }): AiTool {
44
+ const schema: SchemaInput =
45
+ typeof options.input === "function" ? options.input(new RuleBuilder()) : options.input;
46
+
47
+ return {
48
+ name: options.name,
49
+ description: options.description,
50
+ inputSchema: translateSchema(schema),
51
+ handler: (raw, ctx) => options.handle(recheckAgainstSchema<I>(schema, raw), ctx),
52
+ };
53
+ }
54
+ // The return is `AiTool`, not `AiTool<I>`, on purpose. `I` appears in a
55
+ // parameter position, so `AiTool<{a, b}>` is *not* assignable to
56
+ // `AiTool<Record<string, unknown>>` — and `tools: [add]` would be a type error
57
+ // at the one call site every user writes. The narrowing still happens: `handle`
58
+ // receives `I`, validated by `recheckAgainstSchema`, and only the stored
59
+ // signature is widened.
60
+
61
+ /**
62
+ * Run a tool's handler and turn whatever it returns — or throws — into the
63
+ * string the provider expects back.
64
+ *
65
+ * A throwing handler must not end the run: the model is perfectly capable of
66
+ * trying something else once it is told the call failed, and killing the turn
67
+ * denies it that. So the error becomes an error-flagged result instead.
68
+ *
69
+ * @internal
70
+ */
71
+ export async function runTool(
72
+ t: AiTool,
73
+ input: Record<string, unknown>,
74
+ ctx: AiToolContext,
75
+ ): Promise<{ content: string; isError: boolean }> {
76
+ try {
77
+ const value = await t.handler(input, ctx);
78
+ return { content: stringifyToolResult(value), isError: false };
79
+ } catch (error) {
80
+ return {
81
+ content: error instanceof Error ? error.message : String(error),
82
+ isError: true,
83
+ };
84
+ }
85
+ }
86
+
87
+ /** Strings pass through; everything else is JSON. Undefined becomes a stated no-op. */
88
+ function stringifyToolResult(value: unknown): string {
89
+ if (typeof value === "string") return value;
90
+ if (value === undefined) return "(no output)";
91
+ try {
92
+ return JSON.stringify(value) ?? String(value);
93
+ } catch {
94
+ // A cycle or a BigInt. Better a named failure the model can react to than a
95
+ // thrown error that ends the turn.
96
+ return "(tool output could not be serialized to JSON)";
97
+ }
98
+ }