@providerkit/core 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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +245 -0
  3. package/dist/context.d.ts +69 -0
  4. package/dist/context.d.ts.map +1 -0
  5. package/dist/context.js +132 -0
  6. package/dist/context.js.map +1 -0
  7. package/dist/errors.d.ts +86 -0
  8. package/dist/errors.d.ts.map +1 -0
  9. package/dist/errors.js +356 -0
  10. package/dist/errors.js.map +1 -0
  11. package/dist/index.d.ts +13 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +13 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/providers/anthropic.d.ts +26 -0
  16. package/dist/providers/anthropic.d.ts.map +1 -0
  17. package/dist/providers/anthropic.js +245 -0
  18. package/dist/providers/anthropic.js.map +1 -0
  19. package/dist/providers/openai.d.ts +30 -0
  20. package/dist/providers/openai.d.ts.map +1 -0
  21. package/dist/providers/openai.js +185 -0
  22. package/dist/providers/openai.js.map +1 -0
  23. package/dist/retry.d.ts +79 -0
  24. package/dist/retry.d.ts.map +1 -0
  25. package/dist/retry.js +200 -0
  26. package/dist/retry.js.map +1 -0
  27. package/dist/schema.d.ts +2 -0
  28. package/dist/schema.d.ts.map +1 -0
  29. package/dist/schema.js +48 -0
  30. package/dist/schema.js.map +1 -0
  31. package/dist/tool-args.d.ts +12 -0
  32. package/dist/tool-args.d.ts.map +1 -0
  33. package/dist/tool-args.js +113 -0
  34. package/dist/tool-args.js.map +1 -0
  35. package/dist/tools.d.ts +82 -0
  36. package/dist/tools.d.ts.map +1 -0
  37. package/dist/tools.js +155 -0
  38. package/dist/tools.js.map +1 -0
  39. package/dist/transport.d.ts +31 -0
  40. package/dist/transport.d.ts.map +1 -0
  41. package/dist/transport.js +157 -0
  42. package/dist/transport.js.map +1 -0
  43. package/dist/types.d.ts +168 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +75 -0
  46. package/dist/types.js.map +1 -0
  47. package/dist/usage.d.ts +50 -0
  48. package/dist/usage.d.ts.map +1 -0
  49. package/dist/usage.js +71 -0
  50. package/dist/usage.js.map +1 -0
  51. package/dist/watchdog.d.ts +34 -0
  52. package/dist/watchdog.d.ts.map +1 -0
  53. package/dist/watchdog.js +85 -0
  54. package/dist/watchdog.js.map +1 -0
  55. package/dist/zod.d.ts +32 -0
  56. package/dist/zod.d.ts.map +1 -0
  57. package/dist/zod.js +49 -0
  58. package/dist/zod.js.map +1 -0
  59. package/package.json +76 -0
  60. package/src/context.ts +150 -0
  61. package/src/errors.ts +398 -0
  62. package/src/index.ts +12 -0
  63. package/src/providers/anthropic.ts +315 -0
  64. package/src/providers/openai.ts +246 -0
  65. package/src/retry.ts +246 -0
  66. package/src/schema.ts +67 -0
  67. package/src/tool-args.ts +117 -0
  68. package/src/tools.ts +237 -0
  69. package/src/transport.ts +162 -0
  70. package/src/types.ts +231 -0
  71. package/src/usage.ts +106 -0
  72. package/src/watchdog.ts +119 -0
  73. package/src/zod.ts +74 -0
package/src/retry.ts ADDED
@@ -0,0 +1,246 @@
1
+ // Retrying, and knowing when not to.
2
+ //
3
+ // Three rules do most of the work here, and each was learned separately:
4
+ //
5
+ // 1. Retry only what a retry can fix. A deterministic failure (bad key,
6
+ // invalid request, exhausted balance) hits identically on every attempt,
7
+ // so retrying it just spends the budget to arrive at the same answer later.
8
+ //
9
+ // 2. For a STREAM, retry only while nothing has been emitted. Once a chunk
10
+ // has reached the consumer the stream is committed: a retry would replay
11
+ // tokens the caller already rendered. A mid-output drop is the caller's
12
+ // problem to handle (or a job-level restart's), never a silent re-run.
13
+ //
14
+ // 3. Honour the provider's own number. When it says `Retry-After: 30`, a
15
+ // one-second backoff is three wasted attempts before the same wait.
16
+ import { classify, isBackupEligible, isTransient, parseRetryAfterMs } from "./errors.ts";
17
+
18
+ export interface RetryOptions {
19
+ /** Total attempts including the first. Default 3. */
20
+ maxAttempts?: number;
21
+ baseDelayMs?: number;
22
+ maxDelayMs?: number;
23
+ /** Aborts the wait as well as the work, so Stop lands promptly. */
24
+ signal?: AbortSignal;
25
+ /** Decide retryability. Default: transient kinds only. */
26
+ shouldRetry?: (err: unknown, attempt: number) => boolean;
27
+ onRetry?: (info: { error: unknown; attempt: number; delayMs: number }) => void;
28
+ /** Injected in tests so a suite never really waits. */
29
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
30
+ }
31
+
32
+ const DEFAULT_MAX_ATTEMPTS = 3;
33
+ const DEFAULT_BASE_DELAY_MS = 1_000;
34
+ const DEFAULT_MAX_DELAY_MS = 30_000;
35
+
36
+ /**
37
+ * Full-jitter exponential backoff: a random delay in
38
+ * `[0, min(cap, base · 2^(attempt-1))]`.
39
+ *
40
+ * The jitter is the point, not the exponent. Without it, every client that
41
+ * failed against the same overloaded upstream retries in the same instant and
42
+ * rebuilds the thundering herd that caused the failure.
43
+ */
44
+ export function backoffMs(
45
+ attempt: number,
46
+ base = DEFAULT_BASE_DELAY_MS,
47
+ cap = DEFAULT_MAX_DELAY_MS,
48
+ ): number {
49
+ const ceiling = Math.min(cap, base * 2 ** Math.max(0, attempt - 1));
50
+ return Math.floor(Math.random() * ceiling);
51
+ }
52
+
53
+ /** A sleep that wakes early when the caller aborts, and rejects with the
54
+ * abort reason rather than resolving into work nobody wants any more. */
55
+ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
56
+ return new Promise((resolve, reject) => {
57
+ if (signal?.aborted) return reject(signal.reason);
58
+ const timer = setTimeout(() => {
59
+ signal?.removeEventListener("abort", onAbort);
60
+ resolve();
61
+ }, ms);
62
+ function onAbort() {
63
+ clearTimeout(timer);
64
+ reject(signal?.reason);
65
+ }
66
+ signal?.addEventListener("abort", onAbort, { once: true });
67
+ });
68
+ }
69
+
70
+ /** The delay before the next attempt: the provider's own figure when it gave
71
+ * one, capped, else full-jitter backoff. */
72
+ function delayFor(err: unknown, attempt: number, opts: RetryOptions): number {
73
+ const asked = parseRetryAfterMs(err);
74
+ const cap = opts.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
75
+ if (asked !== undefined) return Math.min(asked, cap);
76
+ return backoffMs(attempt, opts.baseDelayMs ?? DEFAULT_BASE_DELAY_MS, cap);
77
+ }
78
+
79
+ const defaultShouldRetry = (err: unknown): boolean => isTransient(classify(err));
80
+
81
+ /**
82
+ * Run `fn`, retrying transient failures with backoff. For one-shot calls —
83
+ * a title, a summary, a compaction pass.
84
+ */
85
+ export async function withRetry<T>(
86
+ fn: (attempt: number) => Promise<T>,
87
+ opts: RetryOptions = {},
88
+ ): Promise<T> {
89
+ const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
90
+ const shouldRetry = opts.shouldRetry ?? defaultShouldRetry;
91
+ const nap = opts.sleep ?? sleep;
92
+
93
+ let lastError: unknown;
94
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
95
+ if (opts.signal?.aborted) throw opts.signal.reason;
96
+ try {
97
+ return await fn(attempt);
98
+ } catch (err) {
99
+ lastError = err;
100
+ // A caller's Stop is never a transient failure, whatever it looks like.
101
+ if (opts.signal?.aborted) throw err;
102
+ if (attempt >= maxAttempts || !shouldRetry(err, attempt)) throw err;
103
+ const delayMs = delayFor(err, attempt, opts);
104
+ opts.onRetry?.({ error: err, attempt, delayMs });
105
+ await nap(delayMs, opts.signal);
106
+ }
107
+ }
108
+ throw lastError;
109
+ }
110
+
111
+ /**
112
+ * The streaming twin — with the rule that makes it safe: a retry happens only
113
+ * while NOTHING has been yielded yet.
114
+ *
115
+ * `factory` is re-invoked per attempt and gets a fresh signal, so an abandoned
116
+ * attempt's upstream request is cancelled rather than left racing the retry.
117
+ * Once the first chunk is out, the stream is committed and any later failure
118
+ * propagates untouched.
119
+ */
120
+ export async function* withStreamRetry<T>(
121
+ factory: (signal: AbortSignal, attempt: number) => AsyncIterable<T>,
122
+ opts: RetryOptions = {},
123
+ ): AsyncGenerator<T> {
124
+ const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
125
+ const shouldRetry = opts.shouldRetry ?? defaultShouldRetry;
126
+ const nap = opts.sleep ?? sleep;
127
+
128
+ let lastError: unknown;
129
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
130
+ if (opts.signal?.aborted) throw opts.signal.reason;
131
+
132
+ // One controller per attempt: abandoning an attempt must cancel its
133
+ // upstream call, or a retry stacks a second live stream against the same
134
+ // rate limit.
135
+ const controller = new AbortController();
136
+ const onOuterAbort = () => controller.abort(opts.signal?.reason);
137
+ opts.signal?.addEventListener("abort", onOuterAbort, { once: true });
138
+
139
+ let emitted = false;
140
+ try {
141
+ for await (const chunk of factory(controller.signal, attempt)) {
142
+ emitted = true;
143
+ yield chunk;
144
+ }
145
+ return;
146
+ } catch (err) {
147
+ lastError = err;
148
+ if (opts.signal?.aborted) throw err;
149
+ // Rule 2: past the first chunk there is no going back.
150
+ if (emitted || attempt >= maxAttempts || !shouldRetry(err, attempt)) throw err;
151
+ const delayMs = delayFor(err, attempt, opts);
152
+ opts.onRetry?.({ error: err, attempt, delayMs });
153
+ await nap(delayMs, opts.signal);
154
+ } finally {
155
+ opts.signal?.removeEventListener("abort", onOuterAbort);
156
+ // Abandoning mid-iteration (the consumer broke out, or we are retrying)
157
+ // must not leave the upstream request running.
158
+ if (!controller.signal.aborted) controller.abort();
159
+ }
160
+ }
161
+ throw lastError;
162
+ }
163
+
164
+ export interface BackupModelOptions {
165
+ /** Tried in order: `[primary, ...backups]`. The first success wins. */
166
+ models: string[];
167
+ /**
168
+ * Whether a failure may fall through to the remaining models. Default:
169
+ * overload and rate limits only — those are per-model-endpoint, and nothing
170
+ * else on the list is. An auth failure or an invalid request would land
171
+ * identically on every backup.
172
+ */
173
+ shouldTryNext?: (err: unknown) => boolean;
174
+ onModelFailed?: (info: {
175
+ model: string;
176
+ error: unknown;
177
+ position: number;
178
+ total: number;
179
+ }) => void;
180
+ onFallback?: (info: { model: string; position: number; total: number }) => void;
181
+ }
182
+
183
+ const defaultShouldTryNext = (err: unknown): boolean => isBackupEligible(classify(err));
184
+
185
+ function requireModels(models: string[]): void {
186
+ if (models.length === 0) throw new Error("providerkit: `models` must include a primary model");
187
+ }
188
+
189
+ /** Walk `[primary, ...backups]` until one succeeds. Rethrows the last error. */
190
+ export async function withBackupModels<T>(
191
+ attempt: (model: string) => Promise<T>,
192
+ opts: BackupModelOptions,
193
+ ): Promise<T> {
194
+ requireModels(opts.models);
195
+ const shouldTryNext = opts.shouldTryNext ?? defaultShouldTryNext;
196
+ const total = opts.models.length;
197
+
198
+ let lastError: unknown;
199
+ for (const [index, model] of opts.models.entries()) {
200
+ if (index > 0) opts.onFallback?.({ model, position: index + 1, total });
201
+ try {
202
+ return await attempt(model);
203
+ } catch (err) {
204
+ lastError = err;
205
+ opts.onModelFailed?.({ model, error: err, position: index + 1, total });
206
+ if (!shouldTryNext(err)) break;
207
+ }
208
+ }
209
+ throw lastError;
210
+ }
211
+
212
+ /**
213
+ * The streaming twin — carrying the same commitment rule as `withStreamRetry`.
214
+ *
215
+ * This is the correction worth naming: walking to a backup model AFTER chunks
216
+ * have already reached the consumer replays the answer from the top, in a
217
+ * different model's voice, on top of text the caller has already rendered. So
218
+ * a stream that fails past its first chunk ends the walk, exactly as it ends a
219
+ * retry.
220
+ */
221
+ export async function* streamWithBackupModels<T>(
222
+ attempt: (model: string) => AsyncIterable<T>,
223
+ opts: BackupModelOptions,
224
+ ): AsyncGenerator<T> {
225
+ requireModels(opts.models);
226
+ const shouldTryNext = opts.shouldTryNext ?? defaultShouldTryNext;
227
+ const total = opts.models.length;
228
+
229
+ let lastError: unknown;
230
+ for (const [index, model] of opts.models.entries()) {
231
+ if (index > 0) opts.onFallback?.({ model, position: index + 1, total });
232
+ let emitted = false;
233
+ try {
234
+ for await (const chunk of attempt(model)) {
235
+ emitted = true;
236
+ yield chunk;
237
+ }
238
+ return;
239
+ } catch (err) {
240
+ lastError = err;
241
+ opts.onModelFailed?.({ model, error: err, position: index + 1, total });
242
+ if (emitted || !shouldTryNext(err)) break;
243
+ }
244
+ }
245
+ throw lastError;
246
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,67 @@
1
+ // Clamping a model's output to the bounds its own schema advertised.
2
+ //
3
+ // Models treat `maxLength` and `maxItems` as soft hints and routinely overflow
4
+ // them when they have a lot to say. Rejecting the whole submission over a few
5
+ // extra characters is the worst available outcome — the run fails and the
6
+ // person gets nothing, when a perfectly good answer was sitting right there.
7
+ //
8
+ // So OVERFLOW is clamped to the very limit the schema told the model about.
9
+ // Structural problems — a missing required field, the wrong type, a bad enum —
10
+ // are deliberately left alone for the real validator to reject: those are the
11
+ // model misunderstanding the contract, not overrunning it.
12
+ //
13
+ // This walks the generated JSON Schema, a stable public shape, rather than any
14
+ // validator's internals.
15
+ type SchemaNode = Record<string, unknown>;
16
+
17
+ export function clampToSchema(value: unknown, node: unknown): unknown {
18
+ if (!node || typeof node !== "object") return value;
19
+ const schema = node as SchemaNode;
20
+
21
+ // Nullable and union fields (`anyOf: [schema, {type: "null"}]`). Fold through
22
+ // every branch: only the one whose type matches transforms, the rest no-op.
23
+ const union = schema.anyOf ?? schema.oneOf;
24
+ if (Array.isArray(union)) {
25
+ return union.reduce<unknown>((current, branch) => clampToSchema(current, branch), value);
26
+ }
27
+
28
+ if (typeof value === "string") {
29
+ const max = schema.maxLength;
30
+ // Ends in an ellipsis so the cut is visible rather than silent. "…" is one
31
+ // UTF-16 unit — the same unit the validators count — so the result lands
32
+ // at exactly `max`.
33
+ if (typeof max === "number" && max >= 1 && value.length > max) {
34
+ return `${value.slice(0, max - 1)}…`;
35
+ }
36
+ return value;
37
+ }
38
+
39
+ if (typeof value === "number") {
40
+ let out = value;
41
+ if (typeof schema.maximum === "number" && out > schema.maximum) out = schema.maximum;
42
+ if (typeof schema.minimum === "number" && out < schema.minimum) out = schema.minimum;
43
+ return out;
44
+ }
45
+
46
+ if (Array.isArray(value)) {
47
+ const items = value.map((item) => clampToSchema(item, schema.items));
48
+ const max = schema.maxItems;
49
+ return typeof max === "number" && items.length > max ? items.slice(0, max) : items;
50
+ }
51
+
52
+ if (
53
+ value &&
54
+ typeof value === "object" &&
55
+ schema.properties &&
56
+ typeof schema.properties === "object"
57
+ ) {
58
+ const properties = schema.properties as Record<string, unknown>;
59
+ const out: Record<string, unknown> = { ...(value as Record<string, unknown>) };
60
+ for (const key of Object.keys(out)) {
61
+ if (key in properties) out[key] = clampToSchema(out[key], properties[key]);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ return value;
67
+ }
@@ -0,0 +1,117 @@
1
+ // Reading a model's tool-call arguments, including the ones it broke.
2
+ //
3
+ // Two failure modes cost real answers, and both are invisible — the JSON simply
4
+ // does not parse, and the run reports "no result" while the answer was sitting
5
+ // in the fragments.
6
+ //
7
+ // 1. TRUNCATION. The turn hit its output ceiling mid-argument, so the JSON is
8
+ // cut off. Everything before the cut is still good, and the field the cut
9
+ // landed in holds a half-written answer that beats no answer.
10
+ //
11
+ // 2. DOUBLE ESCAPING. Most models write non-ASCII inside tool JSON as
12
+ // `\uXXXX`, which JSON.parse decodes correctly. Some escape it twice and
13
+ // emit `\\u00e7`, so even a clean parse leaves six literal characters
14
+ // standing and a Portuguese answer reaches the user as `atenção`.
15
+
16
+ function isRecord(value: unknown): value is Record<string, unknown> {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+
20
+ /**
21
+ * `\uXXXX` only. An answer that legitimately spells that sequence is
22
+ * vanishingly rare; one that mentions `\n` while talking about code is not.
23
+ */
24
+ const UNICODE_ESCAPE = /\\u([0-9a-fA-F]{4})/g;
25
+
26
+ function healUnicodeEscapes(text: string): string {
27
+ return text.replace(UNICODE_ESCAPE, (_match, hex: string) =>
28
+ String.fromCharCode(parseInt(hex, 16)),
29
+ );
30
+ }
31
+
32
+ function healValue(value: unknown): unknown {
33
+ if (typeof value === "string") return healUnicodeEscapes(value);
34
+ if (Array.isArray(value)) return value.map(healValue);
35
+ if (isRecord(value)) return healArgs(value);
36
+ return value;
37
+ }
38
+
39
+ function healArgs(args: Record<string, unknown>): Record<string, unknown> {
40
+ const out: Record<string, unknown> = {};
41
+ for (const [key, value] of Object.entries(args)) out[key] = healValue(value);
42
+ return out;
43
+ }
44
+
45
+ /** The cut can land inside an escape (`…aten\u00`, or a lone `\`). That
46
+ * fragment is not text, and a trailing backslash also stops the field
47
+ * patterns below from matching. */
48
+ const DANGLING_ESCAPE = /\\(?:u[0-9a-fA-F]{0,3})?$/;
49
+
50
+ function unescapeJson(text: string): string {
51
+ try {
52
+ return JSON.parse(`"${text}"`) as string;
53
+ } catch {
54
+ return text;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Best-effort recovery of `"key": "value"` string fields from truncated JSON.
60
+ *
61
+ * Only strings: they are what a summary field — the one worth rescuing — is
62
+ * made of, and a closing quote is the single reliable boundary in a partial
63
+ * stream. Numbers, booleans and objects are dropped, because a salvaged
64
+ * half-value is worse than none.
65
+ */
66
+ function salvageStringFields(raw: string): Record<string, unknown> {
67
+ const out: Record<string, unknown> = {};
68
+ const text = raw.replace(DANGLING_ESCAPE, "");
69
+
70
+ // A completed `"key": "value",` or `"key": "value"}` pair.
71
+ const COMPLETE_FIELD = /"((?:[^"\\]|\\.)*)"\s*:\s*"((?:[^"\\]|\\.)*)"\s*[,}]/gs;
72
+ let match: RegExpExecArray | null;
73
+ let lastCompleteEnd = 0;
74
+ while ((match = COMPLETE_FIELD.exec(text)) !== null) {
75
+ out[unescapeJson(match[1]!)] = unescapeJson(match[2]!);
76
+ lastCompleteEnd = COMPLETE_FIELD.lastIndex;
77
+ }
78
+
79
+ // The tail after the last complete field: if it opens one more string that
80
+ // never closed, keep its content up to the cut.
81
+ const OPEN_FIELD = /"((?:[^"\\]|\\.)*)"\s*:\s*"((?:[^"\\]|\\.)*)$/s;
82
+ const open = OPEN_FIELD.exec(text.slice(lastCompleteEnd));
83
+ if (open?.[2]) out[unescapeJson(open[1]!)] = unescapeJson(open[2]!);
84
+
85
+ return out;
86
+ }
87
+
88
+ /**
89
+ * Parse a tool call's raw argument string into an object, salvaging what a
90
+ * truncated stream left behind and healing double-escaped text either way.
91
+ *
92
+ * Never throws: a tool call the model malformed is data the caller decides
93
+ * about, not an exception in the transport.
94
+ */
95
+ export function parseToolArgs(raw: string): Record<string, unknown> {
96
+ if (!raw.trim()) return {};
97
+ let parsed: unknown;
98
+ try {
99
+ parsed = JSON.parse(raw);
100
+ } catch {
101
+ return healArgs(salvageStringFields(raw));
102
+ }
103
+ // A non-object payload is a protocol violation, not a value to pass on.
104
+ return healArgs(isRecord(parsed) ? parsed : {});
105
+ }
106
+
107
+ /** Whether `raw` parses at all — how a caller tells a truncated tool call from
108
+ * an intact one, since `parseToolArgs` deliberately never throws. */
109
+ export function isCompleteJson(raw: string): boolean {
110
+ if (!raw.trim()) return false;
111
+ try {
112
+ JSON.parse(raw);
113
+ return true;
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,237 @@
1
+ // The tool kernel: a validated, cancellable, time-bounded call.
2
+ //
3
+ // JSON Schema first, on purpose. A zod dependency would be dead weight for a
4
+ // consumer that has none — and one of the codebases this came from is exactly
5
+ // that. `@providerkit/core/zod` adds the ergonomic wrapper for everyone else, and
6
+ // stays an optional peer.
7
+ //
8
+ // The one rule worth stating: a tool FAILING is not an exception here. A model
9
+ // that called a tool wrong, or a tool that timed out, is data the loop feeds
10
+ // back so the model can correct itself. `invoke` therefore returns an outcome
11
+ // rather than throwing, and only a caller's abort escapes.
12
+ import type { JsonObjectSchema, ToolDefinition } from "./types.ts";
13
+ import { messageOf } from "./errors.ts";
14
+
15
+ export interface ToolContext {
16
+ /** The model's own call id when there is one — the event, any approval row
17
+ * and the tool message must all key on what the provider expects back. */
18
+ callId?: string;
19
+ signal?: AbortSignal;
20
+ /** Anything the host wants to hand its tools (a db handle, the actor). */
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ export type ToolFailure = "invalid_input" | "timeout" | "aborted" | "failed";
25
+
26
+ export type ToolOutcome<O> =
27
+ | { ok: true; callId: string; output: O; summary: string; durationMs: number }
28
+ | {
29
+ ok: false;
30
+ callId: string;
31
+ kind: ToolFailure;
32
+ /** Fed back to the model verbatim — so it must read as an instruction to
33
+ * a reader who cannot see our stack trace. */
34
+ error: string;
35
+ durationMs: number;
36
+ cause?: unknown;
37
+ };
38
+
39
+ export interface ToolSpec<I, O> {
40
+ name: string;
41
+ description: string;
42
+ /** Advertised to the model verbatim. */
43
+ inputSchema: JsonObjectSchema;
44
+ /**
45
+ * Turn raw arguments into `I`, or throw with a message the MODEL can act on.
46
+ * Omit to accept whatever arrived (the schema is then only a hint).
47
+ */
48
+ validate?: (raw: unknown) => I;
49
+ run: (input: I, ctx: ToolContext) => Promise<O>;
50
+ /** How the result reads back to the model. Defaults to JSON. */
51
+ summarize?: (output: O) => string;
52
+ /** Default 60s. A tool with no ceiling can hang a whole run. */
53
+ timeoutMs?: number;
54
+ isReadOnly?: boolean;
55
+ needsApproval?: boolean;
56
+ isConcurrencySafe?: boolean;
57
+ /** A terminal tool ends the run; its validated input is the run's output. */
58
+ isTerminal?: boolean;
59
+ }
60
+
61
+ export interface Tool<I = unknown, O = unknown> {
62
+ readonly name: string;
63
+ readonly description: string;
64
+ readonly inputSchema: JsonObjectSchema;
65
+ readonly timeoutMs: number;
66
+ readonly isReadOnly: boolean;
67
+ readonly needsApproval: boolean;
68
+ readonly isConcurrencySafe: boolean;
69
+ readonly isTerminal: boolean;
70
+ definition(): ToolDefinition;
71
+ /** Validate, run, and report the outcome. Never throws except on abort. */
72
+ invoke(rawArgs: unknown, ctx?: ToolContext): Promise<ToolOutcome<O>>;
73
+ /** The typed path for internal callers: throws on failure. */
74
+ call(input: I, ctx?: ToolContext): Promise<O>;
75
+ }
76
+
77
+ const DEFAULT_TIMEOUT_MS = 60_000;
78
+
79
+ export class ToolTimeoutError extends Error {
80
+ constructor(tool: string, ms: number) {
81
+ super(`Tool "${tool}" timed out after ${ms}ms`);
82
+ this.name = "ToolTimeoutError";
83
+ }
84
+ }
85
+
86
+ /** Rejects when `signal` aborts — races a `run` that ignores its own signal. */
87
+ function abortion(signal: AbortSignal): Promise<never> {
88
+ return new Promise((_resolve, reject) => {
89
+ if (signal.aborted) reject(signal.reason);
90
+ else signal.addEventListener("abort", () => reject(signal.reason), { once: true });
91
+ });
92
+ }
93
+
94
+ function newId(): string {
95
+ return globalThis.crypto?.randomUUID?.() ?? `call_${Math.random().toString(36).slice(2, 12)}`;
96
+ }
97
+
98
+ export function defineTool<I = unknown, O = unknown>(spec: ToolSpec<I, O>): Tool<I, O> {
99
+ const timeoutMs = spec.timeoutMs ?? DEFAULT_TIMEOUT_MS;
100
+ const summarize = spec.summarize ?? ((output: O) => JSON.stringify(output) ?? "");
101
+ let cached: ToolDefinition | null = null;
102
+
103
+ async function execute(input: I, ctx: ToolContext): Promise<{ output: O; durationMs: number }> {
104
+ const started = Date.now();
105
+ // Two deadlines compose into the one signal `run` sees: our timeout and
106
+ // the caller's abort. `run` gets a signal it can honour; the race is there
107
+ // for the ones that do not.
108
+ const controller = new AbortController();
109
+ const onOuterAbort = () => controller.abort(ctx.signal?.reason);
110
+ ctx.signal?.addEventListener("abort", onOuterAbort, { once: true });
111
+ let timedOut = false;
112
+ const timer = setTimeout(() => {
113
+ timedOut = true;
114
+ controller.abort(new ToolTimeoutError(spec.name, timeoutMs));
115
+ }, timeoutMs);
116
+ try {
117
+ if (ctx.signal?.aborted) throw ctx.signal.reason;
118
+ const output = await Promise.race([
119
+ spec.run(input, { ...ctx, signal: controller.signal }),
120
+ abortion(controller.signal),
121
+ ]);
122
+ return { output, durationMs: Date.now() - started };
123
+ } catch (err) {
124
+ throw timedOut ? new ToolTimeoutError(spec.name, timeoutMs) : err;
125
+ } finally {
126
+ clearTimeout(timer);
127
+ ctx.signal?.removeEventListener("abort", onOuterAbort);
128
+ }
129
+ }
130
+
131
+ function classifyFailure(err: unknown, ctx: ToolContext): ToolFailure {
132
+ if (err instanceof ToolTimeoutError) return "timeout";
133
+ // The caller's own abort — distinct from our timeout, and never something
134
+ // to report back to the model as a tool that "failed".
135
+ if (ctx.signal?.aborted) return "aborted";
136
+ const name = err instanceof Error ? err.name : undefined;
137
+ return name === "AbortError" ? "aborted" : "failed";
138
+ }
139
+
140
+ return {
141
+ name: spec.name,
142
+ description: spec.description,
143
+ inputSchema: spec.inputSchema,
144
+ timeoutMs,
145
+ isReadOnly: spec.isReadOnly ?? true,
146
+ needsApproval: spec.needsApproval ?? false,
147
+ isConcurrencySafe: spec.isConcurrencySafe ?? true,
148
+ isTerminal: spec.isTerminal ?? false,
149
+
150
+ definition() {
151
+ cached ??= {
152
+ name: spec.name,
153
+ description: spec.description,
154
+ inputSchema: spec.inputSchema,
155
+ };
156
+ return cached;
157
+ },
158
+
159
+ async call(input, ctx = {}) {
160
+ return (await execute(input, ctx)).output;
161
+ },
162
+
163
+ async invoke(rawArgs, ctx = {}) {
164
+ const callId = ctx.callId ?? newId();
165
+ const started = Date.now();
166
+
167
+ let input: I;
168
+ try {
169
+ input = spec.validate ? spec.validate(rawArgs) : (rawArgs as I);
170
+ } catch (err) {
171
+ return {
172
+ ok: false,
173
+ callId,
174
+ kind: "invalid_input",
175
+ error: `Invalid arguments for ${spec.name}: ${messageOf(err)}`,
176
+ durationMs: 0,
177
+ cause: err,
178
+ };
179
+ }
180
+
181
+ try {
182
+ const { output, durationMs } = await execute(input, { ...ctx, callId });
183
+ return { ok: true, callId, output, summary: summarize(output), durationMs };
184
+ } catch (err) {
185
+ return {
186
+ ok: false,
187
+ callId,
188
+ kind: classifyFailure(err, ctx),
189
+ error: messageOf(err),
190
+ durationMs: Date.now() - started,
191
+ cause: err,
192
+ };
193
+ }
194
+ },
195
+ };
196
+ }
197
+
198
+ export class ToolRegistry {
199
+ private readonly tools = new Map<string, Tool>();
200
+
201
+ constructor(tools: readonly Tool[] = []) {
202
+ for (const tool of tools) this.register(tool);
203
+ }
204
+
205
+ register(tool: Tool): this {
206
+ this.tools.set(tool.name, tool);
207
+ return this;
208
+ }
209
+
210
+ get(name: string): Tool | undefined {
211
+ return this.tools.get(name);
212
+ }
213
+
214
+ has(name: string): boolean {
215
+ return this.tools.has(name);
216
+ }
217
+
218
+ get names(): string[] {
219
+ return [...this.tools.keys()];
220
+ }
221
+
222
+ /**
223
+ * Definitions for an allow-list, in the order given — which is the order the
224
+ * model reads them in, and it is part of the cached prompt prefix. Reordering
225
+ * or appending mid-conversation invalidates that prefix, so a caller that
226
+ * cares should freeze the list when the session opens.
227
+ */
228
+ definitions(allow?: readonly string[]): ToolDefinition[] {
229
+ const names = allow ?? this.names;
230
+ const out: ToolDefinition[] = [];
231
+ for (const name of names) {
232
+ const tool = this.tools.get(name);
233
+ if (tool) out.push(tool.definition());
234
+ }
235
+ return out;
236
+ }
237
+ }