@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/errors.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Error idiom for the agents package, mirroring core's ReservingError and
3
+ * compliance's ComplianceError: a typed Error subclass carrying a registered
4
+ * machine code. The registry below is the closed contract - add the code here
5
+ * when introducing a new throw.
6
+ *
7
+ * These errors are DEFINITION-TIME and SEAM errors (bad tool schemas, bad
8
+ * gate specs, missing tenant context, undocumented judgment). They are never
9
+ * allowed to reach the model: defineActuarialTool's wrapper converts anything
10
+ * thrown during tool execution into a { success: false, error } envelope.
11
+ */
12
+
13
+ /** Every machine-readable code an AgentsError can carry. */
14
+ export const AGENTS_ERROR_CODES = [
15
+ /** A tool read the request context and found no (or a non-string/empty) tenant id. */
16
+ "NO_TENANT_CONTEXT",
17
+ /** A tool's input schema declares a tenant-id key (projectId/tenantId); tenant ids travel only via RequestContext. */
18
+ "TENANT_IN_SCHEMA",
19
+ "BAD_INPUT_SCHEMA",
20
+ /** A judgment chain was defined with an invalid gate list or gate spec. */
21
+ "BAD_GATE",
22
+ /** A judgment gate was resumed without a non-blank rationale. */
23
+ "MISSING_RATIONALE",
24
+ /** Two tools with the same id were passed to toolRegistry. */
25
+ "DUPLICATE_TOOL_ID",
26
+ /** promoteStudy was called with malformed deps/options (programmer error). */
27
+ "BAD_PROMOTION_OPTIONS",
28
+ /** A study document carries no selections; nothing to promote. */
29
+ "EMPTY_STUDY",
30
+ /** The study's stated replayTolerance exceeds the host ceiling (spec 6 Gate 1). */
31
+ "TOLERANCE_CEILING_EXCEEDED",
32
+ /** A selection's segment labels match no host workspace target (no fuzzy matching in v1). */
33
+ "SEGMENT_UNRESOLVED",
34
+ /** Two selections resolve to the same workspace target; v1 promotes one selection per segment. */
35
+ "SEGMENT_AMBIGUOUS",
36
+ /** The divergence explainer was invoked on a report whose verdict is not "disagree" (spec 9 item 3: only-on-disagree is structural). */
37
+ "VERDICT_NOT_DISAGREE",
38
+ /** The result docs handed to the divergence explainer do not match the crosscheck report's engine stamps (wrong docs, or a/b swapped). */
39
+ "DIVERGENCE_INPUT_MISMATCH",
40
+ /** The divergence evidence tool ran without assembled evidence in the request context (drive the agent through explainDivergence). */
41
+ "NO_DIVERGENCE_EVIDENCE",
42
+ /** A remote sidecar 2xx response failed interchange parseDocument (bad JSON, bad schema, broken integrity tag, or a non-result kind). */
43
+ "REMOTE_RESULT_INVALID",
44
+ /** The boot self-test found an MCP-exposed probe tool that did NOT fail closed without tenant context; the MCP tenant seam is not wired up and the server must abort startup. */
45
+ "MCP_SELF_TEST_FAILED",
46
+ ] as const;
47
+
48
+ export type AgentsErrorCode = (typeof AGENTS_ERROR_CODES)[number];
49
+
50
+ /**
51
+ * Thrown for invalid agent-toolkit input: tenant-seam violations, malformed
52
+ * gate specs, undocumented judgment. Carries a registered machine code so
53
+ * envelopes and tests can match on it without parsing messages.
54
+ */
55
+ export class AgentsError extends Error {
56
+ readonly code: AgentsErrorCode;
57
+ constructor(code: AgentsErrorCode, message: string) {
58
+ super(message);
59
+ this.name = "AgentsError";
60
+ this.code = code;
61
+ }
62
+ }
package/src/evals.ts ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Golden-prompt tool-selection eval harness: does a realistic instruction
3
+ * make the agent call the right tool(s)? Generalizes the ActNG server's
4
+ * eval-advisor script.
5
+ *
6
+ * Asserts tool SELECTION, not prose: each case lists tools that must appear
7
+ * among the turn's calls. Running against a real agent costs live API tokens
8
+ * and is opt-in by design - the harness itself is pure bookkeeping, which is
9
+ * why package tests exercise it with a stubbed agent and canned streams.
10
+ *
11
+ * Chunk shapes (verified against @mastra/core 1.49 fullStream): tool calls
12
+ * arrive as { type: "tool-call", payload: { toolName } }, with a legacy
13
+ * { type: "tool-call", toolName } fallback.
14
+ */
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Types
18
+
19
+ export interface ToolSelectionEvalCase {
20
+ id: string;
21
+ prompt: string;
22
+ /** Tool names that must ALL appear among the turn's calls. */
23
+ expectTools: readonly string[];
24
+ }
25
+
26
+ export interface ToolSelectionCaseResult {
27
+ id: string;
28
+ pass: boolean;
29
+ /** Tool names actually called, in first-call order. */
30
+ called: string[];
31
+ /** Expected tools that never got called. */
32
+ missing: string[];
33
+ /** Stream error or timeout, when the case failed exceptionally. */
34
+ error?: string;
35
+ }
36
+
37
+ export interface ToolSelectionEvalReport {
38
+ results: ToolSelectionCaseResult[];
39
+ summary: { total: number; passed: number; failed: number };
40
+ }
41
+
42
+ /**
43
+ * The structural slice of an agent the harness needs: stream(messages,
44
+ * options) resolving to something with an async-iterable fullStream.
45
+ *
46
+ * WHY structural instead of the concrete @mastra/core Agent class: the Agent
47
+ * type carries heavy generics and live-model plumbing the harness never
48
+ * touches; it only consumes the fullStream chunk feed. Typing the seam
49
+ * structurally lets package tests substitute a canned-stream stub (no LLM, no
50
+ * network) and lets hosts pass any Agent from any compatible Mastra patch
51
+ * level without generic gymnastics.
52
+ */
53
+ export interface ToolStreamingAgent {
54
+ stream(
55
+ messages: Array<{ role: "user"; content: string }>,
56
+ options?: Record<string, unknown>,
57
+ ): Promise<{ fullStream: AsyncIterable<unknown> }>;
58
+ }
59
+
60
+ export interface RunToolSelectionEvalsOptions {
61
+ agent: ToolStreamingAgent;
62
+ cases: readonly ToolSelectionEvalCase[];
63
+ /** Forwarded verbatim to agent.stream options (the tenant seam for tools). */
64
+ requestContext?: unknown;
65
+ /** Max agent steps per case. Default 8. */
66
+ maxSteps?: number;
67
+ /** Per-case wall clock: a stalled stream fails the case, not the suite. Default 180000. */
68
+ timeoutMs?: number;
69
+ /**
70
+ * Optional per-case memory option factory (thread/resource), for agents
71
+ * whose Memory configuration requires one on every stream call.
72
+ */
73
+ memoryFor?: (evalCase: ToolSelectionEvalCase) => { thread: string; resource: string };
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Harness
78
+
79
+ /** Extracts the tool name from a fullStream chunk, or null for non-tool-call chunks. */
80
+ function toolCallName(chunk: unknown): string | null {
81
+ const c = chunk as {
82
+ type?: unknown;
83
+ toolName?: unknown;
84
+ payload?: { toolName?: unknown };
85
+ };
86
+ if (c?.type !== "tool-call") return null;
87
+ const name = c.payload?.toolName ?? c.toolName;
88
+ return typeof name === "string" && name.length > 0 ? name : null;
89
+ }
90
+
91
+ /**
92
+ * Runs every case sequentially against the agent, collecting the tool calls
93
+ * from the fullStream and asserting each case's expectTools is a subset of
94
+ * the tools actually called. A per-case Promise.race timeout keeps one
95
+ * stalled stream from hanging the whole suite.
96
+ */
97
+ export async function runToolSelectionEvals(
98
+ options: RunToolSelectionEvalsOptions,
99
+ ): Promise<ToolSelectionEvalReport> {
100
+ const { agent, cases, requestContext } = options;
101
+ const maxSteps = options.maxSteps ?? 8;
102
+ const timeoutMs = options.timeoutMs ?? 180_000;
103
+
104
+ const results: ToolSelectionCaseResult[] = [];
105
+ for (const evalCase of cases) {
106
+ const called = new Set<string>();
107
+ let error: string | undefined;
108
+ let timer: ReturnType<typeof setTimeout> | undefined;
109
+ // On timeout the underlying stream is CANCELLED, not abandoned: the
110
+ // iterator's return() is invoked so a well-behaved provider stops
111
+ // generating (and billing), and no chunks bleed into the next case.
112
+ // Hosts whose models accept an abort signal should prefer abortable
113
+ // models; the iterator return() is the portable floor.
114
+ let activeIterator: AsyncIterator<unknown> | undefined;
115
+ try {
116
+ await Promise.race([
117
+ (async () => {
118
+ const stream = await agent.stream([{ role: "user", content: evalCase.prompt }], {
119
+ ...(requestContext !== undefined ? { requestContext } : {}),
120
+ maxSteps,
121
+ ...(options.memoryFor ? { memory: options.memoryFor(evalCase) } : {}),
122
+ });
123
+ const iterator = stream.fullStream[Symbol.asyncIterator]();
124
+ activeIterator = iterator;
125
+ for (;;) {
126
+ const next = await iterator.next();
127
+ if (next.done) break;
128
+ const name = toolCallName(next.value);
129
+ if (name) called.add(name);
130
+ }
131
+ })(),
132
+ new Promise<never>((_, reject) => {
133
+ timer = setTimeout(
134
+ () => reject(new Error(`case timed out after ${timeoutMs}ms`)),
135
+ timeoutMs,
136
+ );
137
+ }),
138
+ ]);
139
+ } catch (err) {
140
+ error = err instanceof Error ? err.message : String(err);
141
+ // Best-effort cancellation of a stream that outlived its race.
142
+ void activeIterator?.return?.().catch(() => undefined);
143
+ } finally {
144
+ if (timer !== undefined) clearTimeout(timer);
145
+ }
146
+ const missing = evalCase.expectTools.filter((tool) => !called.has(tool));
147
+ const result: ToolSelectionCaseResult = {
148
+ id: evalCase.id,
149
+ pass: error === undefined && missing.length === 0,
150
+ called: [...called],
151
+ missing,
152
+ ...(error !== undefined ? { error } : {}),
153
+ };
154
+ results.push(result);
155
+ }
156
+
157
+ const passed = results.filter((r) => r.pass).length;
158
+ return {
159
+ results,
160
+ summary: { total: results.length, passed, failed: results.length - passed },
161
+ };
162
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export * from "./errors.js";
2
+ export * from "./tools.js";
3
+ export * from "./judgment.js";
4
+ export * from "./promotion.js";
5
+ export * from "./advisor.js";
6
+ export * from "./evals.js";
7
+ export * from "./divergence.js";
8
+ export * from "./remote.js";
9
+ export * from "./mcp.js";
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Judgment gates that write the compliance ledger: the generalization of the
3
+ * ActNG server's derive-expected-losses workflow (cap -> restoration ->
4
+ * trends -> ELR) into a factory any host can point at its own gate list.
5
+ *
6
+ * The loop each gate enforces is propose -> justify -> approve -> record:
7
+ * the gate gathers evidence and SUSPENDS with a recommendation; a human
8
+ * decides; the resume payload carries the decision plus a VERBATIM rationale;
9
+ * the gate applies the decision and appends the resulting assumptions to an
10
+ * @actuarial-ts/compliance ledger threaded through the workflow state. A
11
+ * completed chain returns { trail, ledger } - a ledger ready to hand straight
12
+ * to generateDisclosure, so ASOP 41 documentation falls out of running the
13
+ * analysis instead of being reconstructed afterwards.
14
+ *
15
+ * Ground truth (verified against @mastra/core 1.49 dist types and a live
16
+ * suspend/resume probe):
17
+ * - Steps execute as ({ inputData, resumeData, suspend, requestContext });
18
+ * suspend(payload) pauses the run with the payload surfaced under
19
+ * result.steps[stepId].suspendPayload; run.resume({ step, resumeData,
20
+ * requestContext }) re-executes the step with resumeData set.
21
+ * - Resume data is validated against the gate's resumeSchema by Mastra
22
+ * itself; run.resume REJECTS on schema violations. The chain additionally
23
+ * rejects blank rationales at runtime (AgentsError MISSING_RATIONALE), so a
24
+ * host schema of plain z.string() cannot smuggle undocumented judgment.
25
+ * - Suspend/resume requires snapshot storage: hosts register the returned
26
+ * workflow on their Mastra instance (new Mastra({ workflows: { chain } })),
27
+ * which provides in-memory storage by default and durable storage when
28
+ * configured - exactly how the server keeps paused derivations resumable
29
+ * across restarts.
30
+ *
31
+ * Purity: the package never reads a clock. The host supplies now() and every
32
+ * ledger timestamp comes from it.
33
+ *
34
+ * Tenant seam: the tenant id travels in the workflow requestContext, never in
35
+ * step inputs - the same boundary as every actuarial tool.
36
+ */
37
+
38
+ import { createStep, createWorkflow } from "@mastra/core/workflows";
39
+ import { z } from "zod";
40
+ import {
41
+ createLedger,
42
+ recordAssumption,
43
+ type AssumptionActor,
44
+ type AssumptionEntry,
45
+ type AssumptionLedger,
46
+ type JsonValue,
47
+ } from "@actuarial-ts/compliance";
48
+ import { AgentsError } from "./errors.js";
49
+ import { zodObjectShape } from "./tools.js";
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Public types
53
+
54
+ /**
55
+ * The request-context key a host sets (server-side, from its authenticated
56
+ * session — the same trust path as the tenant id) to identify WHO is resuming
57
+ * gates. The resume payload cannot supply it: a payload travels through
58
+ * model-reachable surfaces, and an identity that can be asserted from there
59
+ * is a claim, not a record.
60
+ */
61
+ export const ACTOR_IDENTITY_CONTEXT_KEY = "actorIdentity";
62
+
63
+ /** One decision in the audit trail; skip gates record skipped: true. */
64
+ export interface JudgmentTrailEntry {
65
+ stage: string;
66
+ decision: string;
67
+ rationale: string;
68
+ skipped: boolean;
69
+ /** Coarse classification from the resume payload (default | actuary | agent). */
70
+ actor?: AssumptionActor;
71
+ /**
72
+ * The authenticated identity from the request context, when the host set
73
+ * one. Absent means the host supplied none — identity is never invented,
74
+ * and never read from the resume payload.
75
+ */
76
+ actorIdentity?: string;
77
+ }
78
+
79
+ /**
80
+ * What earlier gates decided, keyed by gate id: the validated resume payload
81
+ * for decided gates, or { skipped: true, reason } for self-skipped ones.
82
+ * Available to skipWhen/gatherEvidence/applyDecision so a later gate can
83
+ * self-skip on an earlier decision (the server's ilf gate skips when the cap
84
+ * gate chose to stay unlimited).
85
+ */
86
+ export type JudgmentDecisions = Record<string, unknown>;
87
+
88
+ /** Marker recorded into the decisions map when a gate self-skips. */
89
+ export interface JudgmentSkipRecord {
90
+ skipped: true;
91
+ reason: string;
92
+ }
93
+
94
+ /** The context every gate hook receives. */
95
+ export interface JudgmentGateContext {
96
+ /** The workflow request context (tenant id lives here, never in inputs). */
97
+ requestContext: { get(key: string): unknown };
98
+ /** Host-injected clock; the source of every ledger timestamp. */
99
+ now: () => string;
100
+ /** Decisions from earlier gates, keyed by gate id. */
101
+ decisions: JudgmentDecisions;
102
+ /** The trail accumulated so far. */
103
+ trail: readonly JudgmentTrailEntry[];
104
+ }
105
+
106
+ /**
107
+ * A ledger entry as a gate's applyDecision returns it: timestamp, actor, and
108
+ * rationale may be omitted - the chain fills timestamp from ctx.now(), actor
109
+ * from the resume payload's optional actor field (default "actuary"), and
110
+ * rationale from the decision's verbatim rationale.
111
+ */
112
+ export interface JudgmentLedgerEntry {
113
+ /** Dotted assumption path, e.g. "layer.cap". */
114
+ field: string;
115
+ value: JsonValue;
116
+ previousValue?: JsonValue;
117
+ source?: string;
118
+ rationale?: string;
119
+ timestamp?: string;
120
+ actor?: AssumptionActor;
121
+ }
122
+
123
+ /** What applyDecision hands back to the chain. */
124
+ export interface JudgmentApplication {
125
+ /** Assumptions this decision fixed; appended to the threaded ledger. */
126
+ ledgerEntries?: JudgmentLedgerEntry[];
127
+ /** Human-readable decision text for the trail; derived from the payload when omitted. */
128
+ summary?: string;
129
+ }
130
+
131
+ /**
132
+ * One human judgment gate. TDecision is the validated resume payload shape;
133
+ * its schema MUST declare a rationale key (createJudgmentChain rejects the
134
+ * spec otherwise) and SHOULD make it a non-empty string - either way the
135
+ * chain rejects blank rationales at runtime.
136
+ */
137
+ export interface JudgmentGateSpec<TDecision = unknown> {
138
+ /** Step id; also the resume target (run.resume({ step: id, ... })). */
139
+ id: string;
140
+ /** Stage label surfaced in the suspend payload and the trail. */
141
+ stage: string;
142
+ /** Validates the resume payload. Must contain a rationale key. */
143
+ resumeSchema: z.ZodType<TDecision>;
144
+ /**
145
+ * Returns the skip reason when the gate should pass through without a
146
+ * human decision (e.g. a later gate made moot by an earlier decision),
147
+ * null to proceed. A skipped gate records a trail note and never suspends.
148
+ */
149
+ skipWhen?: (ctx: JudgmentGateContext) => string | null;
150
+ /** Gathers the evidence and recommendation the gate suspends with. */
151
+ gatherEvidence: (
152
+ ctx: JudgmentGateContext,
153
+ ) => Promise<{ recommendation: string; evidence: unknown }> | { recommendation: string; evidence: unknown };
154
+ /** Applies the human decision through the host's service layer. */
155
+ applyDecision: (ctx: JudgmentGateContext, decision: TDecision) => Promise<JudgmentApplication>;
156
+ }
157
+
158
+ /** The completed chain's output: the audit trail and the fused compliance ledger. */
159
+ export interface JudgmentChainOutcome {
160
+ trail: JudgmentTrailEntry[];
161
+ /** Ready for @actuarial-ts/compliance generateDisclosure. */
162
+ ledger: AssumptionLedger;
163
+ }
164
+
165
+ export interface CreateJudgmentChainOptions {
166
+ /** Workflow id. */
167
+ id: string;
168
+ /** The gates, in order. Must be non-empty with unique ids. */
169
+ gates: readonly JudgmentGateSpec<any>[]; // eslint-disable-line @typescript-eslint/no-explicit-any -- heterogeneous decision types; each gate stays internally typed
170
+ /**
171
+ * Host-injected clock for ledger timestamps (purity: this package never
172
+ * reads Date). Return an ISO-8601 string.
173
+ */
174
+ now: () => string;
175
+ /** Runs once after the last gate, before the chain returns its outcome. */
176
+ onComplete?: (
177
+ outcome: JudgmentChainOutcome,
178
+ ctx: JudgmentGateContext,
179
+ ) => Promise<void> | void;
180
+ /** Optional request-context validation (e.g. z.object({ projectId: z.string() })). */
181
+ requestContextSchema?: z.ZodTypeAny;
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Chain state threaded through step outputs
186
+
187
+ // Declares EVERY JudgmentTrailEntry field: zod strips undeclared keys on the
188
+ // step-output parse, so an undeclared field here is silently erased between
189
+ // gates (the ledger schema below carries the same warning for the same reason).
190
+ const trailEntrySchema = z.object({
191
+ stage: z.string(),
192
+ decision: z.string(),
193
+ rationale: z.string(),
194
+ skipped: z.boolean(),
195
+ actor: z.enum(["default", "actuary", "agent"]).optional(),
196
+ actorIdentity: z.string().optional(),
197
+ });
198
+
199
+ // Declares EVERY AssumptionEntry field: zod strips undeclared keys on parse,
200
+ // and losing previousValue/source between steps would corrupt the ledger.
201
+ const ledgerEntrySchema = z.object({
202
+ seq: z.number().int().positive(),
203
+ timestamp: z.string(),
204
+ actor: z.enum(["default", "actuary", "agent"]),
205
+ field: z.string(),
206
+ value: z.unknown(),
207
+ previousValue: z.unknown().optional(),
208
+ source: z.string().optional(),
209
+ rationale: z.string().optional(),
210
+ });
211
+
212
+ const chainStateSchema = z.object({
213
+ trail: z.array(trailEntrySchema).default([]),
214
+ ledgerEntries: z.array(ledgerEntrySchema).default([]),
215
+ decisions: z.record(z.unknown()).default({}),
216
+ });
217
+
218
+ const suspendSchema = z.object({
219
+ stage: z.string(),
220
+ recommendation: z.string(),
221
+ evidence: z.unknown(),
222
+ });
223
+
224
+ const chainInputSchema = z.object({});
225
+ const chainResultSchema = z.object({
226
+ trail: z.array(trailEntrySchema),
227
+ ledger: z.custom<AssumptionLedger>(
228
+ (value) =>
229
+ typeof value === "object" &&
230
+ value !== null &&
231
+ Array.isArray((value as { entries?: unknown }).entries),
232
+ ),
233
+ });
234
+
235
+ interface ChainState {
236
+ trail: JudgmentTrailEntry[];
237
+ ledgerEntries: AssumptionEntry[];
238
+ decisions: JudgmentDecisions;
239
+ }
240
+
241
+ /**
242
+ * The first gate receives the workflow input ({}), and snapshot storage may
243
+ * round-trip state through JSON, so normalize defensively instead of trusting
244
+ * zod defaults to have been applied.
245
+ */
246
+ function normalizeState(input: unknown): ChainState {
247
+ const raw = (input ?? {}) as Partial<ChainState>;
248
+ return {
249
+ trail: Array.isArray(raw.trail) ? raw.trail : [],
250
+ ledgerEntries: Array.isArray(raw.ledgerEntries) ? raw.ledgerEntries : [],
251
+ decisions:
252
+ typeof raw.decisions === "object" && raw.decisions !== null ? raw.decisions : {},
253
+ };
254
+ }
255
+
256
+ /** Trail text for a decision when applyDecision returns no summary. */
257
+ function describeDecision(decision: unknown): string {
258
+ if (typeof decision === "object" && decision !== null) {
259
+ const named = (decision as { decision?: unknown }).decision;
260
+ if (typeof named === "string" && named.length > 0) return named;
261
+ const { rationale: _rationale, actor: _actor, ...rest } = decision as Record<string, unknown>;
262
+ if (Object.keys(rest).length > 0) return JSON.stringify(rest);
263
+ }
264
+ return "decided";
265
+ }
266
+
267
+ function actorOf(decision: unknown): AssumptionActor {
268
+ const actor = (decision as { actor?: unknown } | null | undefined)?.actor;
269
+ return actor === "agent" || actor === "default" || actor === "actuary" ? actor : "actuary";
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Factory
274
+
275
+ /** Reserved id of the terminal step that assembles { trail, ledger }. */
276
+ const COMPLETE_STEP_ID = "complete";
277
+
278
+ /**
279
+ * The committed workflow type hosts receive: register it on a Mastra instance
280
+ * for snapshot storage, then createRun/start/resume exactly like any Mastra
281
+ * workflow. (Instantiation expression pins the concrete schema generics; the
282
+ * step list is dynamic, so the per-step chain typing is erased - see the cast
283
+ * at the bottom of createJudgmentChain.)
284
+ */
285
+ export type JudgmentChainWorkflow = ReturnType<
286
+ typeof createWorkflow<string, typeof chainInputSchema, typeof chainResultSchema>
287
+ >;
288
+
289
+ /**
290
+ * Builds a committed Mastra workflow from an ordered gate list. Each gate:
291
+ * skipWhen? -> pass through with a trail note; else gatherEvidence ->
292
+ * suspend({ stage, recommendation, evidence }); on resume -> validate the
293
+ * decision (schema + non-blank rationale), applyDecision, append the
294
+ * resulting assumptions to the threaded ledger. The terminal step rebuilds
295
+ * the frozen ledger, invokes onComplete, and returns { trail, ledger }.
296
+ *
297
+ * FOOTGUN (learned the hard way): a Mastra Workflow exposes a .then(step)
298
+ * builder method, which makes it an ACCIDENTAL THENABLE. Never await the
299
+ * returned workflow and never return it from an async function - JS will
300
+ * call .then(resolve, reject) with the promise resolver as a "step" and the
301
+ * promise never settles. Assign it synchronously and register it on your
302
+ * Mastra instance.
303
+ */
304
+ export function createJudgmentChain(options: CreateJudgmentChainOptions): JudgmentChainWorkflow {
305
+ const { id, gates, now, onComplete, requestContextSchema } = options;
306
+
307
+ if (gates.length === 0) {
308
+ throw new AgentsError("BAD_GATE", `Judgment chain "${id}" needs at least one gate`);
309
+ }
310
+ const seen = new Set<string>();
311
+ for (const gate of gates) {
312
+ if (gate.id === COMPLETE_STEP_ID) {
313
+ throw new AgentsError(
314
+ "BAD_GATE",
315
+ `Gate id "${COMPLETE_STEP_ID}" is reserved for the chain's terminal step`,
316
+ );
317
+ }
318
+ if (seen.has(gate.id)) {
319
+ throw new AgentsError("BAD_GATE", `Duplicate gate id "${gate.id}" in chain "${id}"`);
320
+ }
321
+ seen.add(gate.id);
322
+ const shape = zodObjectShape(gate.resumeSchema);
323
+ if (!shape || !("rationale" in shape)) {
324
+ throw new AgentsError(
325
+ "BAD_GATE",
326
+ `Gate "${gate.id}" resumeSchema must be a zod object containing a "rationale" key: every human decision enters the audit trail with its verbatim rationale`,
327
+ );
328
+ }
329
+ }
330
+
331
+ const gateSteps = gates.map((gate) =>
332
+ createStep({
333
+ id: gate.id,
334
+ inputSchema: chainStateSchema,
335
+ outputSchema: chainStateSchema,
336
+ suspendSchema,
337
+ resumeSchema: gate.resumeSchema,
338
+ execute: async ({ inputData, resumeData, suspend, requestContext }) => {
339
+ const state = normalizeState(inputData);
340
+ const ctx: JudgmentGateContext = {
341
+ requestContext,
342
+ now,
343
+ decisions: state.decisions,
344
+ trail: state.trail,
345
+ };
346
+
347
+ const skipReason = gate.skipWhen?.(ctx) ?? null;
348
+ if (skipReason !== null) {
349
+ const skip: JudgmentSkipRecord = { skipped: true, reason: skipReason };
350
+ return {
351
+ trail: [
352
+ ...state.trail,
353
+ { stage: gate.stage, decision: "skipped", rationale: skipReason, skipped: true },
354
+ ],
355
+ ledgerEntries: state.ledgerEntries,
356
+ decisions: { ...state.decisions, [gate.id]: skip },
357
+ };
358
+ }
359
+
360
+ if (resumeData === undefined) {
361
+ const { recommendation, evidence } = await gate.gatherEvidence(ctx);
362
+ await suspend({ stage: gate.stage, recommendation, evidence });
363
+ return state; // unreachable after suspend; satisfies the output type
364
+ }
365
+
366
+ const decision = resumeData;
367
+ const rationale = (decision as { rationale?: unknown } | null)?.rationale;
368
+ if (typeof rationale !== "string" || rationale.trim() === "") {
369
+ throw new AgentsError(
370
+ "MISSING_RATIONALE",
371
+ `Gate "${gate.id}" (${gate.stage}) resumed without a rationale; undocumented judgment is exactly what the ledger exists to prevent`,
372
+ );
373
+ }
374
+ const actor = actorOf(decision);
375
+ // WHO decided comes from the server-set context, never the payload:
376
+ // the payload's job is the decision and its coarse classification.
377
+ const rawIdentity = requestContext?.get(ACTOR_IDENTITY_CONTEXT_KEY);
378
+ const actorIdentity =
379
+ typeof rawIdentity === "string" && rawIdentity.length > 0 ? rawIdentity : undefined;
380
+
381
+ const applied = await gate.applyDecision(ctx, decision);
382
+
383
+ // recordAssumption is the single validator/freezer: replay the
384
+ // threaded entries into a ledger, append, and thread the plain
385
+ // entries array (snapshot storage may JSON round-trip state).
386
+ let ledger: AssumptionLedger = { entries: state.ledgerEntries };
387
+ for (const entry of applied.ledgerEntries ?? []) {
388
+ ledger = recordAssumption(ledger, {
389
+ field: entry.field,
390
+ value: entry.value,
391
+ ...(entry.previousValue !== undefined ? { previousValue: entry.previousValue } : {}),
392
+ ...(entry.source !== undefined ? { source: entry.source } : {}),
393
+ timestamp: entry.timestamp ?? now(),
394
+ actor: entry.actor ?? actor,
395
+ rationale: entry.rationale ?? rationale,
396
+ });
397
+ }
398
+
399
+ return {
400
+ trail: [
401
+ ...state.trail,
402
+ {
403
+ actor,
404
+ ...(actorIdentity !== undefined ? { actorIdentity } : {}),
405
+ stage: gate.stage,
406
+ decision: applied.summary ?? describeDecision(decision),
407
+ rationale,
408
+ skipped: false,
409
+ },
410
+ ],
411
+ ledgerEntries: [...ledger.entries],
412
+ decisions: { ...state.decisions, [gate.id]: decision },
413
+ };
414
+ },
415
+ }),
416
+ );
417
+
418
+ const completeStep = createStep({
419
+ id: COMPLETE_STEP_ID,
420
+ inputSchema: chainStateSchema,
421
+ outputSchema: chainResultSchema,
422
+ execute: async ({ inputData, requestContext }) => {
423
+ const state = normalizeState(inputData);
424
+ // Rebuild through recordAssumption so the returned ledger is frozen and
425
+ // seq-consistent even after a JSON round-trip through snapshot storage.
426
+ let ledger = createLedger();
427
+ for (const entry of state.ledgerEntries) {
428
+ const { seq: _seq, ...fresh } = entry;
429
+ ledger = recordAssumption(ledger, fresh);
430
+ }
431
+ const outcome: JudgmentChainOutcome = { trail: state.trail, ledger };
432
+ await onComplete?.(outcome, {
433
+ requestContext,
434
+ now,
435
+ decisions: state.decisions,
436
+ trail: state.trail,
437
+ });
438
+ return outcome;
439
+ },
440
+ });
441
+
442
+ // The gate list is dynamic, so the step-by-step generic threading
443
+ // createWorkflow's .then() performs cannot be expressed statically; every
444
+ // gate shares the chain-state schema, so runtime shapes stay checked and
445
+ // the cast is confined to this builder.
446
+ interface ChainBuilder {
447
+ then(step: unknown): ChainBuilder;
448
+ commit(): unknown;
449
+ }
450
+ let builder = createWorkflow({
451
+ id,
452
+ inputSchema: chainInputSchema,
453
+ outputSchema: chainResultSchema,
454
+ ...(requestContextSchema ? { requestContextSchema } : {}),
455
+ }) as unknown as ChainBuilder;
456
+ for (const step of gateSteps) builder = builder.then(step);
457
+ return builder.then(completeStep).commit() as JudgmentChainWorkflow;
458
+ }