@actuarial-ts/agents 0.2.0 → 0.3.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/tools.ts ADDED
@@ -0,0 +1,561 @@
1
+ /**
2
+ * Actuarial tool factory: Mastra createTool with the two hard guarantees the
3
+ * ActNG server proved in production, generalized for any host.
4
+ *
5
+ * 1. SECURITY SEAM. The tenant id (project id) ALWAYS comes from the
6
+ * server-side request context, never from the model. tenantOf reads it;
7
+ * defineActuarialTool REJECTS, at definition time, any input schema that
8
+ * declares a tenant-id key - the model must not even be able to express
9
+ * one. (Verified against @mastra/core 1.49: tools execute as
10
+ * (inputData, context) with context.requestContext.get(key).)
11
+ *
12
+ * 2. ERROR CONTRACT. Tools never throw into the model. Anything the host's
13
+ * execute throws is converted to { success: false, error: { code, message } }
14
+ * so the agent can recover: retry with adjusted parameters, suggest an
15
+ * alternative, or ask. Errors carrying a string code (the server's
16
+ * HttpError, this package's AgentsError, compliance's ComplianceError)
17
+ * keep their code; everything else gets the fallback.
18
+ */
19
+
20
+ import { createTool } from "@mastra/core/tools";
21
+ import type { z } from "zod";
22
+ import { AgentsError } from "./errors.js";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Failure envelope
26
+
27
+ /** The uniform tool-failure shape: agents branch on success, hosts log code. */
28
+ export type ToolEnvelopeFailure = {
29
+ success: false;
30
+ error: { code: string; message: string };
31
+ };
32
+
33
+ /**
34
+ * Converts anything thrown by a tool into the failure envelope. Never throws.
35
+ * Error-like values with a non-empty string "code" property (HttpError,
36
+ * AgentsError, ComplianceError) keep their code; everything else gets
37
+ * fallbackCode.
38
+ */
39
+ export function envelopeFailure(err: unknown, fallbackCode = "TOOL_ERROR"): ToolEnvelopeFailure {
40
+ let code = fallbackCode;
41
+ let message = "Unknown error";
42
+ try {
43
+ const candidate = (err as { code?: unknown } | null | undefined)?.code;
44
+ if (typeof candidate === "string" && candidate.length > 0) code = candidate;
45
+ if (err instanceof Error) {
46
+ message = err.message;
47
+ } else if (typeof err === "string" && err.length > 0) {
48
+ message = err;
49
+ } else {
50
+ const msg = (err as { message?: unknown } | null | undefined)?.message;
51
+ if (typeof msg === "string" && msg.length > 0) message = msg;
52
+ }
53
+ // Never leak storage-driver internals (SQLite codes, schema shape) to a
54
+ // tool consumer — that is low-grade information disclosure and an
55
+ // unhelpful surface. Normalize any driver error to a generic envelope.
56
+ if (code.startsWith("SQLITE_")) {
57
+ code = "STORAGE_ERROR";
58
+ message = "a storage operation failed";
59
+ }
60
+ } catch {
61
+ // A hostile getter must not break the envelope; keep the fallbacks.
62
+ }
63
+ return { success: false, error: { code, message } };
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Tenant seam
68
+
69
+ /**
70
+ * The structural slice of Mastra's ToolExecutionContext that the tenant seam
71
+ * needs. Typed structurally (not as the concrete Mastra type) so tests can
72
+ * pass a minimal object and hosts on any 1.49+ patch level are assignable.
73
+ */
74
+ export interface TenantToolContext {
75
+ requestContext?: { get(key: string): unknown };
76
+ }
77
+
78
+ /**
79
+ * Reads the tenant id from the server-set request context. Throws a typed
80
+ * AgentsError("NO_TENANT_CONTEXT") when absent, non-string, or empty; inside
81
+ * a defineActuarialTool execute the wrapper converts that throw into the
82
+ * failure envelope, so the model sees a recoverable error, never a crash.
83
+ */
84
+ export function tenantOf(context: TenantToolContext | undefined, key = "projectId"): string {
85
+ return resolveTenant(context, { source: "request-context", key });
86
+ }
87
+
88
+ /** The auth-info bag a bearer-token middleware sets (req.auth in the host). */
89
+ export type McpAuthInfoLike = Record<string, unknown>;
90
+
91
+ /** The MCP slice of a tool context this package reads auth info from. */
92
+ export interface McpContextLike {
93
+ requestContext?: { get(key: string): unknown };
94
+ mcp?: { extra?: { authInfo?: McpAuthInfoLike } };
95
+ }
96
+
97
+ /**
98
+ * Resolves the MCP auth-info bag from a tool context, trying every shape the
99
+ * installed and documented transports expose (see mcp.ts's file header for
100
+ * the transport-by-transport story). Returns undefined when no auth info is
101
+ * present — the caller decides that is fatal.
102
+ */
103
+ export function resolveMcpAuthInfo(context: McpContextLike | undefined): McpAuthInfoLike | undefined {
104
+ if (!context) return undefined;
105
+
106
+ // Primary: the streamable-HTTP call path passes the transport extra at
107
+ // context.mcp.extra (mcpOptions.mcp.extra in @mastra/mcp).
108
+ const directAuthInfo = context.mcp?.extra?.authInfo;
109
+ if (directAuthInfo) return directAuthInfo;
110
+
111
+ const requestContext = context.requestContext;
112
+ if (requestContext && typeof requestContext.get === "function") {
113
+ // Documented universal fallback: the whole extra bag under "mcp.extra".
114
+ const proxiedExtra = requestContext.get("mcp.extra") as
115
+ | { authInfo?: McpAuthInfoLike }
116
+ | undefined;
117
+ if (proxiedExtra?.authInfo) return proxiedExtra.authInfo;
118
+
119
+ // Installed @mastra/mcp 1.14.0: createProxiedRequestContext copies each
120
+ // extra key onto the RequestContext verbatim, so authInfo is top-level.
121
+ const topLevelAuthInfo = requestContext.get("authInfo") as McpAuthInfoLike | undefined;
122
+ if (topLevelAuthInfo) return topLevelAuthInfo;
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ /** Where a tool's tenant id is trusted to come from. */
128
+ export type TenantSource = "request-context" | "mcp-auth";
129
+
130
+ export interface ResolveTenantOptions {
131
+ /** The trusted path. Default "request-context" (the host sets it server-side). */
132
+ source?: TenantSource;
133
+ /** The key holding the tenant id. Default "projectId". */
134
+ key?: string;
135
+ }
136
+
137
+ /**
138
+ * THE tenant read. Every path a tenant id may enter this package goes through
139
+ * here — the agent-facing request context and the MCP auth info are the same
140
+ * seam with two transports, and having two independent readers is how they
141
+ * drift (they did: tools.ts read requestContext while mcp.ts read authInfo,
142
+ * with nothing forcing the two to stay in step).
143
+ *
144
+ * Throws AgentsError("NO_TENANT_CONTEXT") when the source has no non-empty
145
+ * string under the key. Inside a defineActuarialTool execute the wrapper
146
+ * converts that throw into a { success: false } envelope, so tools fail
147
+ * CLOSED for any unauthenticated caller.
148
+ */
149
+ export function resolveTenant(
150
+ context: TenantToolContext | McpContextLike | undefined,
151
+ options: ResolveTenantOptions = {},
152
+ ): string {
153
+ const source = options.source ?? "request-context";
154
+ const key = options.key ?? "projectId";
155
+ const value =
156
+ source === "mcp-auth"
157
+ ? resolveMcpAuthInfo(context as McpContextLike | undefined)?.[key]
158
+ : (context as TenantToolContext | undefined)?.requestContext?.get(key);
159
+ if (typeof value !== "string" || value.length === 0) {
160
+ throw new AgentsError(
161
+ "NO_TENANT_CONTEXT",
162
+ source === "mcp-auth"
163
+ ? `MCP tool invoked without a non-empty "${key}" in the request's authInfo; the host's bearer-token middleware must set req.auth = { ${key} } so it reaches the tool context — the tenant never comes from the model`
164
+ : `Tool invoked without a "${key}" in the request context; the host must set it server-side from the authenticated request, never from the model`,
165
+ );
166
+ }
167
+ return value;
168
+ }
169
+
170
+ /**
171
+ * Tenant-id keys a tool input schema may never declare. Case-insensitive and
172
+ * separator-tolerant (projectId, project_id, tenantId, TenantID, ...): the
173
+ * lint exists to make the security seam unexpressable, so it errs wide.
174
+ */
175
+ const TENANT_KEY_PATTERN = /^(project|tenant)[_-]?id$/i;
176
+
177
+ /** Top-level shape keys of a zod object schema, or null when not an object schema. */
178
+ export function zodObjectShape(schema: unknown): Record<string, unknown> | null {
179
+ if (typeof schema !== "object" || schema === null) return null;
180
+ const def = (schema as { _def?: { typeName?: unknown } })._def;
181
+ if (def?.typeName !== "ZodObject") return null;
182
+ const shape = (schema as { shape?: unknown }).shape;
183
+ if (typeof shape !== "object" || shape === null) return null;
184
+ return shape as Record<string, unknown>;
185
+ }
186
+
187
+ type ZodDefLike = {
188
+ typeName?: unknown;
189
+ unknownKeys?: unknown;
190
+ catchall?: { _def?: { typeName?: unknown } };
191
+ type?: unknown; // ZodArray, ZodPromise, ZodBranded
192
+ innerType?: unknown; // ZodOptional, ZodNullable, ZodDefault, ZodCatch, ZodReadonly
193
+ schema?: unknown; // ZodEffects
194
+ options?: unknown[]; // ZodUnion, ZodDiscriminatedUnion
195
+ items?: unknown[]; // ZodTuple
196
+ rest?: unknown; // ZodTuple
197
+ left?: unknown; // ZodIntersection
198
+ right?: unknown; // ZodIntersection
199
+ getter?: () => unknown; // ZodLazy
200
+ in?: unknown; // ZodPipeline
201
+ out?: unknown; // ZodPipeline
202
+ valueType?: unknown; // ZodSet, ZodMap
203
+ };
204
+
205
+ /** Leaves that end recursion: nothing the model sends through them can carry a key. */
206
+ const LEAF_TYPE_NAMES = new Set([
207
+ "ZodString",
208
+ "ZodNumber",
209
+ "ZodBigInt",
210
+ "ZodBoolean",
211
+ "ZodDate",
212
+ "ZodEnum",
213
+ "ZodNativeEnum",
214
+ "ZodLiteral",
215
+ "ZodNull",
216
+ "ZodUndefined",
217
+ "ZodVoid",
218
+ "ZodNever",
219
+ "ZodNaN",
220
+ ]);
221
+
222
+ /** Wholly uninspectable values: the lint cannot see into what they admit. */
223
+ const OPAQUE_TYPE_NAMES = new Set(["ZodAny", "ZodUnknown", "ZodMap"]);
224
+
225
+ /**
226
+ * The recursive tenant-key lint behind defineActuarialTool. Walks every
227
+ * container the model could reach — nested objects, arrays, optional /
228
+ * nullable / default / effects wrappers, unions — and rejects:
229
+ * - any object key matching TENANT_KEY_PATTERN at any depth;
230
+ * - .passthrough() objects and non-never .catchall(...) (they let the model
231
+ * smuggle arbitrary keys past the lint);
232
+ * - z.record(...) (dynamic string keys — same smuggling surface).
233
+ * Leaves (strings, numbers, enums, literals) end recursion. Everything else
234
+ * is decided by an EXPLICIT list: known containers are traversed, known
235
+ * uninspectable shapes (any/unknown/map) are refused, and an unrecognized
236
+ * typeName throws rather than passing. The lint used to silently return for
237
+ * shapes it did not recognize, which made it fail-open: z.tuple, z.lazy,
238
+ * .readonly(), .brand(), z.intersection and five other ordinary containers
239
+ * each smuggled a nested tenant id straight past it. On a security seam,
240
+ * "I don't know what this is" has to mean "refused", not "fine".
241
+ */
242
+ function assertNoTenantKeys(
243
+ schema: unknown,
244
+ toolId: string,
245
+ path: string,
246
+ allowUninspected?: ReadonlySet<string>,
247
+ usedAllowances?: Set<string>,
248
+ ): void {
249
+ // Path depth doubles as a cycle guard: a self-referential z.lazy() would
250
+ // otherwise recurse forever. 64 levels is far beyond any real tool input.
251
+ if (path.split(".").length > 64) {
252
+ throw new AgentsError(
253
+ "BAD_INPUT_SCHEMA",
254
+ `Tool "${toolId}": the input schema nests deeper than 64 levels at "${path.slice(0, 120)}..." ` +
255
+ "(likely a self-referential z.lazy()); the tenant lint cannot verify unbounded schemas",
256
+ );
257
+ }
258
+ if (typeof schema !== "object" || schema === null) return;
259
+ const def = (schema as { _def?: ZodDefLike })._def;
260
+ if (!def) return;
261
+ const typeName = def.typeName;
262
+
263
+ if (typeName === "ZodObject") {
264
+ if (def.unknownKeys === "passthrough") {
265
+ throw new AgentsError(
266
+ "TENANT_IN_SCHEMA",
267
+ `Tool "${toolId}": the object at "${path}" uses .passthrough(), which lets the model smuggle arbitrary keys (including tenant ids) past the seam; declare every key explicitly`,
268
+ );
269
+ }
270
+ const catchallType = def.catchall?._def?.typeName;
271
+ if (catchallType !== undefined && catchallType !== "ZodNever") {
272
+ throw new AgentsError(
273
+ "TENANT_IN_SCHEMA",
274
+ `Tool "${toolId}": the object at "${path}" uses .catchall(...), which admits undeclared keys; declare every key explicitly`,
275
+ );
276
+ }
277
+ const shape = zodObjectShape(schema) ?? {};
278
+ for (const [key, value] of Object.entries(shape)) {
279
+ if (TENANT_KEY_PATTERN.test(key)) {
280
+ throw new AgentsError(
281
+ "TENANT_IN_SCHEMA",
282
+ `Tool "${toolId}" declares input key "${path}.${key}": tenant ids travel only via the server-set RequestContext (read them with tenantOf), never through the model-facing input schema`,
283
+ );
284
+ }
285
+ assertNoTenantKeys(value, toolId, `${path}.${key}`, allowUninspected, usedAllowances);
286
+ }
287
+ return;
288
+ }
289
+ if (typeName === "ZodRecord") {
290
+ throw new AgentsError(
291
+ "TENANT_IN_SCHEMA",
292
+ `Tool "${toolId}": the record at "${path}" admits arbitrary string keys (including tenant ids); use an explicit z.object shape`,
293
+ );
294
+ }
295
+ if (typeof typeName === "string" && LEAF_TYPE_NAMES.has(typeName)) {
296
+ return;
297
+ }
298
+ if (typeof typeName === "string" && OPAQUE_TYPE_NAMES.has(typeName)) {
299
+ if (allowUninspected?.has(path)) {
300
+ // A DECLARED opt-out: the tool's definition names this exact path as
301
+ // intentionally opaque because validation happens downstream of zod
302
+ // (e.g. whole interchange documents checked by parseDocument, which
303
+ // carry no tenant identifiers by spec section 12). The declaration is
304
+ // greppable at the definition site; silence is still refused.
305
+ usedAllowances?.add(path);
306
+ return;
307
+ }
308
+ throw new AgentsError(
309
+ "BAD_INPUT_SCHEMA",
310
+ `Tool "${toolId}": the ${typeName} at "${path}" admits values the tenant lint cannot ` +
311
+ 'inspect. Either declare the shape with typed keys, or — if this input is validated ' +
312
+ 'downstream (parseDocument etc.) — name the exact path in `allowUninspected` so the ' +
313
+ "exception is deliberate and greppable",
314
+ );
315
+ }
316
+ if (typeName === "ZodArray") {
317
+ assertNoTenantKeys(def.type, toolId, `${path}[]`, allowUninspected, usedAllowances);
318
+ return;
319
+ }
320
+ if (
321
+ typeName === "ZodOptional" ||
322
+ typeName === "ZodNullable" ||
323
+ typeName === "ZodDefault" ||
324
+ typeName === "ZodCatch" ||
325
+ typeName === "ZodReadonly"
326
+ ) {
327
+ assertNoTenantKeys(def.innerType, toolId, path, allowUninspected, usedAllowances);
328
+ return;
329
+ }
330
+ if (typeName === "ZodPromise" || typeName === "ZodBranded") {
331
+ assertNoTenantKeys(def.type, toolId, path, allowUninspected, usedAllowances);
332
+ return;
333
+ }
334
+ if (typeName === "ZodEffects") {
335
+ assertNoTenantKeys(def.schema, toolId, path, allowUninspected, usedAllowances);
336
+ return;
337
+ }
338
+ if (typeName === "ZodUnion" || typeName === "ZodDiscriminatedUnion") {
339
+ for (const opt of def.options ?? []) assertNoTenantKeys(opt, toolId, path, allowUninspected, usedAllowances);
340
+ return;
341
+ }
342
+ if (typeName === "ZodTuple") {
343
+ for (const [index, item] of (def.items ?? []).entries()) {
344
+ assertNoTenantKeys(item, toolId, `${path}[${index}]`, allowUninspected, usedAllowances);
345
+ }
346
+ if (def.rest !== undefined && def.rest !== null) {
347
+ assertNoTenantKeys(def.rest, toolId, `${path}[rest]`, allowUninspected, usedAllowances);
348
+ }
349
+ return;
350
+ }
351
+ if (typeName === "ZodIntersection") {
352
+ assertNoTenantKeys(def.left, toolId, path, allowUninspected, usedAllowances);
353
+ assertNoTenantKeys(def.right, toolId, path, allowUninspected, usedAllowances);
354
+ return;
355
+ }
356
+ if (typeName === "ZodPipeline") {
357
+ assertNoTenantKeys(def.in, toolId, path, allowUninspected, usedAllowances);
358
+ assertNoTenantKeys(def.out, toolId, path, allowUninspected, usedAllowances);
359
+ return;
360
+ }
361
+ if (typeName === "ZodSet") {
362
+ assertNoTenantKeys(def.valueType, toolId, `${path}[]`, allowUninspected, usedAllowances);
363
+ return;
364
+ }
365
+ if (typeName === "ZodLazy") {
366
+ // Resolve once. A directly self-referential lazy would recurse forever;
367
+ // the depth guard below catches that as a schema error rather than a hang.
368
+ assertNoTenantKeys(def.getter?.(), toolId, path, allowUninspected, usedAllowances);
369
+ return;
370
+ }
371
+
372
+ // Fail closed: a typeName this lint has never heard of gets refused, not
373
+ // waved through. New zod container types must be added here DELIBERATELY,
374
+ // with a decision about how the tenant lint traverses them.
375
+ throw new AgentsError(
376
+ "BAD_INPUT_SCHEMA",
377
+ `Tool "${toolId}": the schema at "${path}" has unrecognized type "${String(typeName)}"; ` +
378
+ "the tenant lint refuses shapes it cannot traverse (fail closed)",
379
+ );
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // Tool factory
384
+
385
+ /** read = inspect/analyze only; action = mutates host state (drives client refresh). */
386
+ export type ActuarialToolKind = "read" | "action";
387
+
388
+ /**
389
+ * The execution context handed to an actuarial tool's execute. Structural on
390
+ * purpose: it is the slice of Mastra's ToolExecutionContext this package
391
+ * relies on, and tests exercise tools without booting an agent.
392
+ */
393
+ export type ActuarialToolContext = TenantToolContext;
394
+
395
+ interface DefineActuarialToolCommon<TShape extends z.ZodRawShape> {
396
+ /**
397
+ * Exact schema paths (the lint's dot notation, rooted at "input") where an
398
+ * uninspectable type — z.unknown(), z.any(), z.map() — is INTENTIONAL
399
+ * because the value is validated downstream of zod. Example:
400
+ * `["input.triangles.primary"]` for a whole interchange document checked by
401
+ * parseDocument at execute time.
402
+ *
403
+ * Every declared path must actually match an opaque node; a leftover
404
+ * declaration is an error, so the list cannot rot as the schema evolves.
405
+ * Undeclared opaque nodes remain refused — this is a per-path opt-out, not
406
+ * a switch.
407
+ */
408
+ allowUninspected?: string[];
409
+ id: string;
410
+ description: string;
411
+ kind: ActuarialToolKind;
412
+ /**
413
+ * The model-facing input schema. MUST NOT contain a tenant-id key
414
+ * (projectId/tenantId in any casing) - defineActuarialTool throws
415
+ * AgentsError("TENANT_IN_SCHEMA") at definition time if it does.
416
+ */
417
+ inputSchema: z.ZodObject<TShape>;
418
+ }
419
+
420
+ /**
421
+ * Every tool STATES its relationship to the tenant seam; there is no default.
422
+ *
423
+ * `tenant: "required"` — the wrapper resolves the tenant from the trusted
424
+ * source BEFORE invoking execute and passes it as the second argument. The
425
+ * body cannot forget the read, because the read is not the body's job any
426
+ * more: an unauthenticated call fails closed with NO_TENANT_CONTEXT and
427
+ * execute never runs.
428
+ *
429
+ * `tenant: "none"` — for the handful of genuinely tenant-free tools (e.g.
430
+ * get_divergence_evidence, which restates evidence already injected by the
431
+ * host). The second argument is null, and the opt-out is greppable: searching
432
+ * `tenant: "none"` lists every tool that skips the seam, with its reason
433
+ * reviewable at the definition site.
434
+ */
435
+ export type DefineActuarialToolOptions<TShape extends z.ZodRawShape, TResult> =
436
+ | (DefineActuarialToolCommon<TShape> & {
437
+ tenant: "required";
438
+ /** Trusted source for the tenant id. Default "request-context". */
439
+ tenantSource?: TenantSource;
440
+ /** Key holding the tenant id at the source. Default "projectId". */
441
+ tenantKey?: string;
442
+ /**
443
+ * The tool body. `tenant` is the resolved id — already authenticated,
444
+ * never model-supplied. May throw freely; the wrapper guarantees the
445
+ * model receives either the return value or a { success: false, error }
446
+ * envelope, never an exception.
447
+ */
448
+ execute: (
449
+ input: z.infer<z.ZodObject<TShape>>,
450
+ tenant: string,
451
+ context: ActuarialToolContext,
452
+ ) => Promise<TResult>;
453
+ })
454
+ | (DefineActuarialToolCommon<TShape> & {
455
+ tenant: "none";
456
+ execute: (
457
+ input: z.infer<z.ZodObject<TShape>>,
458
+ tenant: null,
459
+ context: ActuarialToolContext,
460
+ ) => Promise<TResult>;
461
+ });
462
+
463
+ /**
464
+ * Wraps Mastra's createTool with the envelope + tenant-seam guarantees and
465
+ * tags the result with its kind for toolRegistry classification.
466
+ */
467
+ export function defineActuarialTool<TShape extends z.ZodRawShape, TResult>(
468
+ options: DefineActuarialToolOptions<TShape, TResult>,
469
+ ) {
470
+ // FAIL CLOSED: a schema the seam cannot inspect is not definable, and the
471
+ // tenant-key lint recurses through every container the model could reach.
472
+ const shape = zodObjectShape(options.inputSchema);
473
+ if (!shape) {
474
+ throw new AgentsError(
475
+ "BAD_INPUT_SCHEMA",
476
+ `Tool "${options.id}": inputSchema must be a plain z.object(...) the tenant seam can inspect; got a schema whose shape is unreadable`,
477
+ );
478
+ }
479
+ const allowUninspected = new Set(options.allowUninspected ?? []);
480
+ const usedAllowances = new Set<string>();
481
+ assertNoTenantKeys(options.inputSchema, options.id, "input", allowUninspected, usedAllowances);
482
+ for (const declared of allowUninspected) {
483
+ if (!usedAllowances.has(declared)) {
484
+ throw new AgentsError(
485
+ "BAD_INPUT_SCHEMA",
486
+ `Tool "${options.id}": allowUninspected names "${declared}", but no uninspectable ` +
487
+ "schema node exists at that path; remove the stale declaration",
488
+ );
489
+ }
490
+ }
491
+ if (options.tenant !== "required" && options.tenant !== "none") {
492
+ // Runtime backstop for JS callers the union type cannot reach: the seam
493
+ // relationship is stated, never assumed.
494
+ throw new AgentsError(
495
+ "BAD_INPUT_SCHEMA",
496
+ `Tool "${(options as { id: string }).id}": \`tenant\` must be "required" or "none" — every tool states its ` +
497
+ "relationship to the tenant seam explicitly",
498
+ );
499
+ }
500
+ const tool = createTool({
501
+ id: options.id,
502
+ description: options.description,
503
+ inputSchema: options.inputSchema,
504
+ execute: async (input: z.infer<z.ZodObject<TShape>>, context: unknown) => {
505
+ try {
506
+ if (options.tenant === "required") {
507
+ // Resolve BEFORE the body runs: an unauthenticated call fails closed
508
+ // here, and execute never sees it.
509
+ const tenant = resolveTenant(context as ActuarialToolContext, {
510
+ source: options.tenantSource,
511
+ key: options.tenantKey,
512
+ });
513
+ return await options.execute(input, tenant, context as ActuarialToolContext);
514
+ }
515
+ return await options.execute(input, null, context as ActuarialToolContext);
516
+ } catch (err) {
517
+ return envelopeFailure(err);
518
+ }
519
+ },
520
+ });
521
+ return Object.assign(tool, { kind: options.kind });
522
+ }
523
+
524
+ // ---------------------------------------------------------------------------
525
+ // Registry
526
+
527
+ /** The minimal slice toolRegistry needs; every defineActuarialTool result satisfies it. */
528
+ export interface RegistrableActuarialTool {
529
+ id: string;
530
+ kind: ActuarialToolKind;
531
+ }
532
+
533
+ export interface ActuarialToolRegistry<T extends RegistrableActuarialTool> {
534
+ /** Tools keyed by id, ready for new Agent({ tools }). */
535
+ tools: Record<string, T>;
536
+ /** Ids of state-mutating tools (the host client refreshes after these). */
537
+ actionToolIds: Set<string>;
538
+ }
539
+
540
+ /**
541
+ * Classifies a tool list into the shape hosts wire into their agent: a
542
+ * tools record keyed by id plus the action-tool id set that drives client
543
+ * refresh semantics (the generalization of the server's ACTION_TOOL_IDS).
544
+ */
545
+ export function toolRegistry<T extends RegistrableActuarialTool>(
546
+ tools: readonly T[],
547
+ ): ActuarialToolRegistry<T> {
548
+ const record: Record<string, T> = {};
549
+ const actionToolIds = new Set<string>();
550
+ for (const tool of tools) {
551
+ if (record[tool.id]) {
552
+ throw new AgentsError(
553
+ "DUPLICATE_TOOL_ID",
554
+ `Two tools share the id "${tool.id}"; tool ids must be unique within a registry`,
555
+ );
556
+ }
557
+ record[tool.id] = tool;
558
+ if (tool.kind === "action") actionToolIds.add(tool.id);
559
+ }
560
+ return { tools: record, actionToolIds };
561
+ }