@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/remote.ts ADDED
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Remote-method tools (interchange spec rev 2.1, section 7 client side):
3
+ * defineRemoteMethod wraps one sidecar method (POST /v1/run/{method}) in
4
+ * defineActuarialTool, so a chainladder-python run becomes ordinary advisor
5
+ * evidence — enveloped, tenant-sealed, abortable.
6
+ *
7
+ * Contract decisions (each is load-bearing):
8
+ *
9
+ * - THE WIRE IS THE INTERCHANGE SPEC. The model-facing input schema is the
10
+ * sidecar request body MINUS engagementRef and minus any tenant field:
11
+ * { triangles: { primary, secondary? }, selection?, exposure?,
12
+ * parameters?, seed? }. Embedded documents are validated CLIENT-side via
13
+ * interchange parseDocument (integrity verified) before anything leaves
14
+ * the process, and the response document is parsed the same way — a 2xx
15
+ * body that fails parseDocument is refused with
16
+ * AgentsError("REMOTE_RESULT_INVALID"), never handed to the model.
17
+ *
18
+ * - THE TENANT SEAM IS UNTOUCHED. The input schema declares no tenant key
19
+ * (defineActuarialTool lints it at definition time), the wire body is
20
+ * built ONLY from the declared slots, and no engagementRef surface exists
21
+ * on the tool at all — the remote call carries zero correlation identity.
22
+ * The sidecar's own data-level lint (TENANT_KEY_REJECTED) backstops any
23
+ * tenant key hiding inside a supplied document's extensions.
24
+ *
25
+ * - PARAMETERS ARE AN EXPLICIT VOCABULARY, NOT A RECORD. The tenant lint
26
+ * rejects z.record / .passthrough (arbitrary-key smuggling surfaces), so
27
+ * the schema declares the sidecar's documented knobs explicitly; the
28
+ * sidecar 422s anything a given method does not accept, which keeps the
29
+ * two vocabularies honest against each other.
30
+ *
31
+ * - ERRORS MAP TO ENVELOPES WITH THE SIDECAR'S OWN CODES. A non-2xx with a
32
+ * schema'd { error: { code, message } } body keeps that code verbatim
33
+ * (UNAUTHORIZED, MISSING_SEED, REPLAY_REFUSED, ...); transport failures
34
+ * get SIDECAR_UNREACHABLE, the client-side deadline SIDECAR_TIMEOUT, and
35
+ * a caller abort ABORTED. The model always receives an envelope it can
36
+ * recover from, never a thrown exception.
37
+ */
38
+
39
+ import { z } from "zod";
40
+ import {
41
+ parseDocument,
42
+ type MethodResultDoc,
43
+ type StochasticResultDoc,
44
+ } from "@actuarial-ts/interchange";
45
+ import { AgentsError } from "./errors.js";
46
+ import {
47
+ defineActuarialTool,
48
+ envelopeFailure,
49
+ type ActuarialToolContext,
50
+ type ToolEnvelopeFailure,
51
+ } from "./tools.js";
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Wire schema (model-facing): the spec-7 request body minus engagementRef
55
+ // and minus any tenant surface.
56
+
57
+ /** BF/Benktander/CapeCod apriori base: one value per origin label. */
58
+ const exposureSchema = z.object({
59
+ origins: z.array(z.string().min(1)).min(1).describe("Origin labels, matching the primary triangle's origins exactly"),
60
+ values: z.array(z.number().finite()).min(1).describe("One exposure value per origin, same order as origins"),
61
+ kind: z.string().min(1).describe('Exposure kind, e.g. "earnedPremium"'),
62
+ });
63
+
64
+ /**
65
+ * The sidecar's documented parameter vocabulary, declared explicitly (the
66
+ * tenant lint forbids open-key records by design). Every key is nullable:
67
+ * null means "not sent", and each METHOD accepts only its own subset — the
68
+ * sidecar refuses unknown keys per method with a 422 the envelope relays.
69
+ */
70
+ const parametersSchema = z.object({
71
+ average: z
72
+ .enum(["volume", "simple", "regression"])
73
+ .nullable()
74
+ .describe("Development average when NO selection document is supplied (conflicts with selection)"),
75
+ n_periods: z
76
+ .number()
77
+ .int()
78
+ .nullable()
79
+ .describe("Development periods when NO selection is supplied: -1 = all periods (conflicts with selection)"),
80
+ strictness: z
81
+ .enum(["warn", "strict"])
82
+ .nullable()
83
+ .describe("Selection-replay strictness: warn (default) runs compromises with warnings; strict refuses them with 422"),
84
+ sigma_interpolation: z
85
+ .enum(["log-linear", "mack"])
86
+ .nullable()
87
+ .describe('MackChainladder sigma extrapolation; the mack1993-vw profile requires "mack" (engine default is log-linear)'),
88
+ apriori: z.number().nullable().describe("BornhuetterFerguson/Benktander a-priori multiplier on the exposure base"),
89
+ n_iters: z.number().int().nullable().describe("Benktander iterations (1 = BF)"),
90
+ trend: z.number().nullable().describe("CapeCod trend rate"),
91
+ decay: z.number().nullable().describe("CapeCod decay factor"),
92
+ growth: z
93
+ .enum(["loglogistic", "weibull"])
94
+ .nullable()
95
+ .describe("ClarkLDF growth curve family"),
96
+ n_sims: z.number().int().nullable().describe("BootstrapODPSample simulation count (default 1000, max 100000)"),
97
+ });
98
+
99
+ const remoteRunInputSchema = z.object({
100
+ triangles: z.object({
101
+ primary: z
102
+ .unknown()
103
+ .describe("The primary TriangleDoc: a full actuarial-interchange triangle document, integrity tag intact"),
104
+ secondary: z
105
+ .unknown()
106
+ .nullable()
107
+ .describe("Second TriangleDoc (MunichAdjustment's incurred slot; primary = paid). Null for every other method"),
108
+ }),
109
+ selection: z
110
+ .unknown()
111
+ .nullable()
112
+ .describe(
113
+ "SelectionDoc replayed as the development intent (spec 3.2). Null = engine-native factors from parameters. Supplying BOTH a selection and average/n_periods parameters is refused",
114
+ ),
115
+ exposure: exposureSchema
116
+ .nullable()
117
+ .describe("Apriori exposure base — required by BornhuetterFerguson, Benktander, and CapeCod; null otherwise"),
118
+ parameters: parametersSchema.nullable().describe("Method-specific knobs; null sends none"),
119
+ seed: z
120
+ .number()
121
+ .int()
122
+ .nullable()
123
+ .describe("BootstrapODPSample only (REQUIRED there); deterministic methods refuse a supplied seed"),
124
+ });
125
+
126
+ export type RemoteRunInput = z.infer<typeof remoteRunInputSchema>;
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // The HTTP call (shared by defineRemoteMethod and host-authored tools)
130
+
131
+ /** Result-document kinds a sidecar run may return. */
132
+ const RESULT_KINDS = new Set(["method-result", "stochastic-result"]);
133
+
134
+ export interface RemoteMethodCallOptions {
135
+ /** Sidecar base URL, e.g. http://127.0.0.1:8091 (no trailing /v1). */
136
+ sidecarUrl: string;
137
+ /** The spec-7 method name, e.g. "Chainladder" or "MackChainladder". */
138
+ method: string;
139
+ /** Client-side deadline for one run. Default 60000 ms. */
140
+ timeoutMs?: number;
141
+ /** Extra request headers — the bearer token travels here, e.g.
142
+ * { authorization: "Bearer <SIDECAR_TOKEN>" }. */
143
+ headers?: Record<string, string>;
144
+ }
145
+
146
+ export interface RemoteMethodSuccess {
147
+ success: true;
148
+ /** "method-result" | "stochastic-result" (what the run returned). */
149
+ kind: "method-result" | "stochastic-result";
150
+ /** The parsed, integrity-verified result document. */
151
+ doc: MethodResultDoc | StochasticResultDoc;
152
+ /** Reader-side warnings from parseDocument (normally empty). */
153
+ parseWarnings: string[];
154
+ }
155
+
156
+ export type RemoteMethodResult = RemoteMethodSuccess | ToolEnvelopeFailure;
157
+
158
+ function isAbortLike(err: unknown, name: string): boolean {
159
+ return (err as { name?: unknown } | null)?.name === name;
160
+ }
161
+
162
+ /**
163
+ * POSTs one spec-7 wire body to the sidecar and parses the response through
164
+ * interchange parseDocument. Never throws for transport/protocol failures —
165
+ * it returns the failure envelope directly, carrying the sidecar's own error
166
+ * code when the response body is schema'd. The ONE registered throw-shaped
167
+ * failure, REMOTE_RESULT_INVALID (a 2xx body that is not a verifiable
168
+ * result document), is also returned enveloped.
169
+ */
170
+ export async function callRemoteMethod(
171
+ options: RemoteMethodCallOptions,
172
+ body: Record<string, unknown>,
173
+ signal?: AbortSignal,
174
+ ): Promise<RemoteMethodResult> {
175
+ const timeoutMs = options.timeoutMs ?? 60_000;
176
+ const url = `${options.sidecarUrl.replace(/\/+$/, "")}/v1/run/${encodeURIComponent(options.method)}`;
177
+ const timeout = AbortSignal.timeout(timeoutMs);
178
+ const combined = signal !== undefined ? AbortSignal.any([signal, timeout]) : timeout;
179
+
180
+ let response: Response;
181
+ try {
182
+ response = await fetch(url, {
183
+ method: "POST",
184
+ headers: { "content-type": "application/json", ...(options.headers ?? {}) },
185
+ body: JSON.stringify(body),
186
+ signal: combined,
187
+ });
188
+ } catch (err) {
189
+ // fetch rejects with the abort REASON: AbortSignal.timeout's reason is a
190
+ // TimeoutError, a caller abort is an AbortError; anything else is the
191
+ // transport itself failing. Node may also wrap the reason in a TypeError
192
+ // whose cause carries the story — the signal states disambiguate.
193
+ if (signal?.aborted === true && !timeout.aborted) {
194
+ return { success: false, error: { code: "ABORTED", message: "the caller aborted the sidecar call" } };
195
+ }
196
+ if (timeout.aborted || isAbortLike(err, "TimeoutError")) {
197
+ return {
198
+ success: false,
199
+ error: {
200
+ code: "SIDECAR_TIMEOUT",
201
+ message: `the sidecar did not answer ${options.method} within ${timeoutMs} ms`,
202
+ },
203
+ };
204
+ }
205
+ if (isAbortLike(err, "AbortError")) {
206
+ return { success: false, error: { code: "ABORTED", message: "the caller aborted the sidecar call" } };
207
+ }
208
+ const detail = err instanceof Error ? err.message : String(err);
209
+ const cause = (err as { cause?: { message?: unknown } } | null)?.cause?.message;
210
+ return {
211
+ success: false,
212
+ error: {
213
+ code: "SIDECAR_UNREACHABLE",
214
+ message: `could not reach the sidecar at ${url}: ${typeof cause === "string" ? cause : detail}`,
215
+ },
216
+ };
217
+ }
218
+
219
+ let payload: unknown;
220
+ let bodyText = "";
221
+ try {
222
+ bodyText = await response.text();
223
+ payload = JSON.parse(bodyText);
224
+ } catch {
225
+ payload = undefined;
226
+ }
227
+
228
+ if (!response.ok) {
229
+ const schemad = (payload as { error?: { code?: unknown; message?: unknown } } | undefined)?.error;
230
+ const code = typeof schemad?.code === "string" && schemad.code.length > 0
231
+ ? schemad.code
232
+ : `SIDECAR_HTTP_${response.status}`;
233
+ const message = typeof schemad?.message === "string" && schemad.message.length > 0
234
+ ? schemad.message
235
+ : `sidecar answered HTTP ${response.status} for ${options.method}`;
236
+ return { success: false, error: { code, message } };
237
+ }
238
+
239
+ try {
240
+ if (payload === undefined) {
241
+ throw new Error(`response body is not JSON: ${bodyText.slice(0, 200)}`);
242
+ }
243
+ const { doc, warnings } = parseDocument(payload); // strictness "refuse": a broken tag throws
244
+ if (!RESULT_KINDS.has(doc.kind)) {
245
+ throw new Error(`expected a method-result or stochastic-result document, got kind "${doc.kind}"`);
246
+ }
247
+ return {
248
+ success: true,
249
+ kind: doc.kind as "method-result" | "stochastic-result",
250
+ doc: doc as MethodResultDoc | StochasticResultDoc,
251
+ parseWarnings: warnings,
252
+ };
253
+ } catch (err) {
254
+ const detail = err instanceof Error ? err.message : String(err);
255
+ return envelopeFailure(
256
+ new AgentsError(
257
+ "REMOTE_RESULT_INVALID",
258
+ `the sidecar's 2xx response for ${options.method} is not a verifiable interchange result document: ${detail}`,
259
+ ),
260
+ );
261
+ }
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // The tool factory
266
+
267
+ export interface DefineRemoteMethodOptions extends RemoteMethodCallOptions {
268
+ id: string;
269
+ description: string;
270
+ }
271
+
272
+ /** The context slice the remote tool consumes beyond the tenant seam:
273
+ * Mastra's per-call abort signal (verified against @mastra/core 1.49's
274
+ * ToolExecutionContext), forwarded into fetch so a cancelled agent run
275
+ * cancels the sidecar call. */
276
+ interface AbortableToolContext extends ActuarialToolContext {
277
+ abortSignal?: AbortSignal;
278
+ }
279
+
280
+ /** Client-side guard: an embedded document slot must parse (integrity
281
+ * verified) AND be of the expected kind before it goes on the wire. Returns
282
+ * the failure envelope for a wrong kind (mirroring the sidecar's own
283
+ * WRONG_DOCUMENT_KIND vocabulary); parse failures throw ReservingError
284
+ * BAD_INTERCHANGE, which the tool wrapper envelopes with that code. */
285
+ function checkDocumentSlot(
286
+ raw: unknown,
287
+ slot: string,
288
+ expectedKind: string,
289
+ ): ToolEnvelopeFailure | null {
290
+ const { doc } = parseDocument(raw);
291
+ if (doc.kind !== expectedKind) {
292
+ return {
293
+ success: false,
294
+ error: {
295
+ code: "WRONG_DOCUMENT_KIND",
296
+ message: `'${slot}' must be a ${expectedKind} document, got kind "${doc.kind}"`,
297
+ },
298
+ };
299
+ }
300
+ return null;
301
+ }
302
+
303
+ function compactParameters(
304
+ parameters: z.infer<typeof parametersSchema> | null | undefined,
305
+ ): Record<string, unknown> | null {
306
+ if (parameters === null || parameters === undefined) return null;
307
+ const out: Record<string, unknown> = {};
308
+ for (const [key, value] of Object.entries(parameters)) {
309
+ if (value !== null && value !== undefined) out[key] = value;
310
+ }
311
+ return Object.keys(out).length > 0 ? out : null;
312
+ }
313
+
314
+ /**
315
+ * Defines a read-kind actuarial tool that runs ONE sidecar method. The
316
+ * input schema is the spec-7 wire shape (minus engagementRef/tenant
317
+ * anything); execute validates the embedded documents, POSTs the wire body,
318
+ * forwards the Mastra abort signal, and returns either the parsed
319
+ * integrity-verified result document or a failure envelope carrying the
320
+ * sidecar's own error code.
321
+ */
322
+ export function defineRemoteMethod(options: DefineRemoteMethodOptions) {
323
+ const { id, description, ...call } = options;
324
+ return defineActuarialTool({
325
+ id,
326
+ description,
327
+ kind: "read",
328
+ inputSchema: remoteRunInputSchema,
329
+ // The document slots are z.unknown() ON PURPOSE: each is a whole
330
+ // interchange document, validated at execute time by checkDocumentSlot ->
331
+ // parseDocument (schema + integrity tag), and interchange documents carry
332
+ // no tenant identifiers by spec section 12. Declaring the paths here keeps
333
+ // the tenant lint fail-closed for everything else.
334
+ allowUninspected: ["input.triangles.primary", "input.triangles.secondary", "input.selection"],
335
+ // Tenant-free BY DESIGN: the sidecar is stateless and the wire body is
336
+ // spec-7 minus engagementRef minus any tenant surface (see the file
337
+ // header). There is no tenant-scoped read or write on this path.
338
+ tenant: "none",
339
+ execute: async (input, _tenant, context): Promise<RemoteMethodResult> => {
340
+ const badPrimary = checkDocumentSlot(input.triangles.primary, "triangles.primary", "triangle");
341
+ if (badPrimary !== null) return badPrimary;
342
+ if (input.triangles.secondary !== null && input.triangles.secondary !== undefined) {
343
+ const badSecondary = checkDocumentSlot(input.triangles.secondary, "triangles.secondary", "triangle");
344
+ if (badSecondary !== null) return badSecondary;
345
+ }
346
+ if (input.selection !== null && input.selection !== undefined) {
347
+ const badSelection = checkDocumentSlot(input.selection, "selection", "selection");
348
+ if (badSelection !== null) return badSelection;
349
+ }
350
+
351
+ const parameters = compactParameters(input.parameters);
352
+ const body: Record<string, unknown> = {
353
+ triangles: {
354
+ primary: input.triangles.primary,
355
+ ...(input.triangles.secondary !== null && input.triangles.secondary !== undefined
356
+ ? { secondary: input.triangles.secondary }
357
+ : {}),
358
+ },
359
+ ...(input.selection !== null && input.selection !== undefined
360
+ ? { selection: input.selection }
361
+ : {}),
362
+ ...(input.exposure !== null && input.exposure !== undefined
363
+ ? { exposure: input.exposure }
364
+ : {}),
365
+ ...(parameters !== null ? { parameters } : {}),
366
+ ...(input.seed !== null && input.seed !== undefined ? { seed: input.seed } : {}),
367
+ };
368
+ return callRemoteMethod(call, body, (context as AbortableToolContext).abortSignal);
369
+ },
370
+ });
371
+ }