@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/README.md +4 -2
- package/dist/advisor.d.ts +2 -1
- package/dist/advisor.d.ts.map +1 -1
- package/dist/advisor.js +3 -1
- package/dist/advisor.js.map +1 -1
- package/dist/divergence.d.ts.map +1 -1
- package/dist/divergence.js +6 -1
- package/dist/divergence.js.map +1 -1
- package/dist/judgment.d.ts +26 -0
- package/dist/judgment.d.ts.map +1 -1
- package/dist/judgment.js +21 -0
- package/dist/judgment.js.map +1 -1
- package/dist/mcp.d.ts +44 -9
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +57 -40
- package/dist/mcp.js.map +1 -1
- package/dist/promotion.d.ts +26 -0
- package/dist/promotion.d.ts.map +1 -1
- package/dist/promotion.js +49 -1
- package/dist/promotion.js.map +1 -1
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +11 -1
- package/dist/remote.js.map +1 -1
- package/dist/tools.d.ts +87 -6
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +175 -15
- package/dist/tools.js.map +1 -1
- package/package.json +7 -5
- package/src/advisor.ts +141 -0
- package/src/divergence.ts +642 -0
- package/src/errors.ts +62 -0
- package/src/evals.ts +162 -0
- package/src/index.ts +9 -0
- package/src/judgment.ts +458 -0
- package/src/mcp.ts +274 -0
- package/src/promotion.ts +1240 -0
- package/src/remote.ts +371 -0
- package/src/tools.ts +561 -0
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
|
+
}
|