@actuarial-ts/agents 0.5.0 → 0.6.1

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 CHANGED
@@ -17,7 +17,11 @@
17
17
  * keep their code; everything else gets the fallback.
18
18
  */
19
19
 
20
- import { createTool } from "@mastra/core/tools";
20
+ import { createTool, type Tool } from "@mastra/core/tools";
21
+ import {
22
+ toStandardSchema,
23
+ type StandardSchemaWithJSON,
24
+ } from "@mastra/core/schema";
21
25
  import type { z } from "zod";
22
26
  import { AgentsError } from "./errors.js";
23
27
 
@@ -26,17 +30,51 @@ import { AgentsError } from "./errors.js";
26
30
 
27
31
  /** The uniform tool-failure shape: agents branch on success, hosts log code. */
28
32
  export type ToolEnvelopeFailure = {
29
- success: false;
30
- error: { code: string; message: string };
33
+ readonly success: false;
34
+ readonly error: { readonly code: string; readonly message: string };
31
35
  };
32
36
 
37
+ const TOOL_INPUT_INVALID: ToolEnvelopeFailure = Object.freeze({
38
+ success: false,
39
+ error: Object.freeze({
40
+ code: "TOOL_INPUT_INVALID",
41
+ message: "Tool input failed schema validation",
42
+ }),
43
+ });
44
+ const TOOL_OUTPUT_INVALID: ToolEnvelopeFailure = Object.freeze({
45
+ success: false,
46
+ error: Object.freeze({
47
+ code: "TOOL_OUTPUT_INVALID",
48
+ message: "Tool output failed schema validation",
49
+ }),
50
+ });
51
+
52
+ function readonlyFailure(code: string, message: string): ToolEnvelopeFailure {
53
+ return Object.freeze({
54
+ success: false,
55
+ error: Object.freeze({ code, message }),
56
+ });
57
+ }
58
+
59
+ function deepFreezeResult<T>(value: T, seen = new WeakSet<object>()): T {
60
+ if (value === null || typeof value !== "object" || seen.has(value))
61
+ return value;
62
+ seen.add(value);
63
+ for (const child of Object.values(value as Record<string, unknown>))
64
+ deepFreezeResult(child, seen);
65
+ return Object.freeze(value);
66
+ }
67
+
33
68
  /**
34
69
  * Converts anything thrown by a tool into the failure envelope. Never throws.
35
70
  * Error-like values with a non-empty string "code" property (HttpError,
36
71
  * AgentsError, ComplianceError) keep their code; everything else gets
37
72
  * fallbackCode.
38
73
  */
39
- export function envelopeFailure(err: unknown, fallbackCode = "TOOL_ERROR"): ToolEnvelopeFailure {
74
+ export function envelopeFailure(
75
+ err: unknown,
76
+ fallbackCode = "TOOL_ERROR",
77
+ ): ToolEnvelopeFailure {
40
78
  let code = fallbackCode;
41
79
  let message = "Unknown error";
42
80
  try {
@@ -60,7 +98,7 @@ export function envelopeFailure(err: unknown, fallbackCode = "TOOL_ERROR"): Tool
60
98
  } catch {
61
99
  // A hostile getter must not break the envelope; keep the fallbacks.
62
100
  }
63
- return { success: false, error: { code, message } };
101
+ return readonlyFailure(code, message);
64
102
  }
65
103
 
66
104
  // ---------------------------------------------------------------------------
@@ -81,7 +119,10 @@ export interface TenantToolContext {
81
119
  * a defineActuarialTool execute the wrapper converts that throw into the
82
120
  * failure envelope, so the model sees a recoverable error, never a crash.
83
121
  */
84
- export function tenantOf(context: TenantToolContext | undefined, key = "projectId"): string {
122
+ export function tenantOf(
123
+ context: TenantToolContext | undefined,
124
+ key = "projectId",
125
+ ): string {
85
126
  return resolveTenant(context, { source: "request-context", key });
86
127
  }
87
128
 
@@ -100,7 +141,9 @@ export interface McpContextLike {
100
141
  * the transport-by-transport story). Returns undefined when no auth info is
101
142
  * present — the caller decides that is fatal.
102
143
  */
103
- export function resolveMcpAuthInfo(context: McpContextLike | undefined): McpAuthInfoLike | undefined {
144
+ export function resolveMcpAuthInfo(
145
+ context: McpContextLike | undefined,
146
+ ): McpAuthInfoLike | undefined {
104
147
  if (!context) return undefined;
105
148
 
106
149
  // Primary: the streamable-HTTP call path passes the transport extra at
@@ -116,9 +159,11 @@ export function resolveMcpAuthInfo(context: McpContextLike | undefined): McpAuth
116
159
  | undefined;
117
160
  if (proxiedExtra?.authInfo) return proxiedExtra.authInfo;
118
161
 
119
- // Installed @mastra/mcp 1.14.0: createProxiedRequestContext copies each
162
+ // Lock-tested @mastra/mcp 1.17.3: createProxiedRequestContext copies each
120
163
  // extra key onto the RequestContext verbatim, so authInfo is top-level.
121
- const topLevelAuthInfo = requestContext.get("authInfo") as McpAuthInfoLike | undefined;
164
+ const topLevelAuthInfo = requestContext.get("authInfo") as
165
+ | McpAuthInfoLike
166
+ | undefined;
122
167
  if (topLevelAuthInfo) return topLevelAuthInfo;
123
168
  }
124
169
  return undefined;
@@ -175,7 +220,9 @@ export function resolveTenant(
175
220
  const TENANT_KEY_PATTERN = /^(project|tenant)[_-]?id$/i;
176
221
 
177
222
  /** 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 {
223
+ export function zodObjectShape(
224
+ schema: unknown,
225
+ ): Record<string, unknown> | null {
179
226
  if (typeof schema !== "object" || schema === null) return null;
180
227
  const def = (schema as { _def?: { typeName?: unknown } })._def;
181
228
  if (def?.typeName !== "ZodObject") return null;
@@ -281,7 +328,15 @@ function assertNoTenantKeys(
281
328
  }
282
329
  stack.add(schema);
283
330
  try {
284
- lintSchemaNode(schema, toolId, path, allowUninspected, usedAllowances, stack, depth);
331
+ lintSchemaNode(
332
+ schema,
333
+ toolId,
334
+ path,
335
+ allowUninspected,
336
+ usedAllowances,
337
+ stack,
338
+ depth,
339
+ );
285
340
  } finally {
286
341
  stack.delete(schema);
287
342
  }
@@ -322,7 +377,15 @@ function lintSchemaNode(
322
377
  `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`,
323
378
  );
324
379
  }
325
- assertNoTenantKeys(value, toolId, `${path}.${key}`, allowUninspected, usedAllowances, stack, depth + 1);
380
+ assertNoTenantKeys(
381
+ value,
382
+ toolId,
383
+ `${path}.${key}`,
384
+ allowUninspected,
385
+ usedAllowances,
386
+ stack,
387
+ depth + 1,
388
+ );
326
389
  }
327
390
  return;
328
391
  }
@@ -348,13 +411,21 @@ function lintSchemaNode(
348
411
  throw new AgentsError(
349
412
  "BAD_INPUT_SCHEMA",
350
413
  `Tool "${toolId}": the ${typeName} at "${path}" admits values the tenant lint cannot ` +
351
- 'inspect. Either declare the shape with typed keys, or — if this input is validated ' +
352
- 'downstream (parseDocument etc.) — name the exact path in `allowUninspected` so the ' +
414
+ "inspect. Either declare the shape with typed keys, or — if this input is validated " +
415
+ "downstream (parseDocument etc.) — name the exact path in `allowUninspected` so the " +
353
416
  "exception is deliberate and greppable",
354
417
  );
355
418
  }
356
419
  if (typeName === "ZodArray") {
357
- assertNoTenantKeys(def.type, toolId, `${path}[]`, allowUninspected, usedAllowances, stack, depth + 1);
420
+ assertNoTenantKeys(
421
+ def.type,
422
+ toolId,
423
+ `${path}[]`,
424
+ allowUninspected,
425
+ usedAllowances,
426
+ stack,
427
+ depth + 1,
428
+ );
358
429
  return;
359
430
  }
360
431
  if (
@@ -364,49 +435,146 @@ function lintSchemaNode(
364
435
  typeName === "ZodCatch" ||
365
436
  typeName === "ZodReadonly"
366
437
  ) {
367
- assertNoTenantKeys(def.innerType, toolId, path, allowUninspected, usedAllowances, stack, depth + 1);
438
+ assertNoTenantKeys(
439
+ def.innerType,
440
+ toolId,
441
+ path,
442
+ allowUninspected,
443
+ usedAllowances,
444
+ stack,
445
+ depth + 1,
446
+ );
368
447
  return;
369
448
  }
370
449
  if (typeName === "ZodPromise" || typeName === "ZodBranded") {
371
- assertNoTenantKeys(def.type, toolId, path, allowUninspected, usedAllowances, stack, depth + 1);
450
+ assertNoTenantKeys(
451
+ def.type,
452
+ toolId,
453
+ path,
454
+ allowUninspected,
455
+ usedAllowances,
456
+ stack,
457
+ depth + 1,
458
+ );
372
459
  return;
373
460
  }
374
461
  if (typeName === "ZodEffects") {
375
- assertNoTenantKeys(def.schema, toolId, path, allowUninspected, usedAllowances, stack, depth + 1);
462
+ assertNoTenantKeys(
463
+ def.schema,
464
+ toolId,
465
+ path,
466
+ allowUninspected,
467
+ usedAllowances,
468
+ stack,
469
+ depth + 1,
470
+ );
376
471
  return;
377
472
  }
378
473
  if (typeName === "ZodUnion" || typeName === "ZodDiscriminatedUnion") {
379
- for (const opt of def.options ?? []) assertNoTenantKeys(opt, toolId, path, allowUninspected, usedAllowances, stack, depth + 1);
474
+ for (const opt of def.options ?? [])
475
+ assertNoTenantKeys(
476
+ opt,
477
+ toolId,
478
+ path,
479
+ allowUninspected,
480
+ usedAllowances,
481
+ stack,
482
+ depth + 1,
483
+ );
380
484
  return;
381
485
  }
382
486
  if (typeName === "ZodTuple") {
383
487
  for (const [index, item] of (def.items ?? []).entries()) {
384
- assertNoTenantKeys(item, toolId, `${path}[${index}]`, allowUninspected, usedAllowances, stack, depth + 1);
488
+ assertNoTenantKeys(
489
+ item,
490
+ toolId,
491
+ `${path}[${index}]`,
492
+ allowUninspected,
493
+ usedAllowances,
494
+ stack,
495
+ depth + 1,
496
+ );
385
497
  }
386
498
  if (def.rest !== undefined && def.rest !== null) {
387
- assertNoTenantKeys(def.rest, toolId, `${path}[rest]`, allowUninspected, usedAllowances, stack, depth + 1);
499
+ assertNoTenantKeys(
500
+ def.rest,
501
+ toolId,
502
+ `${path}[rest]`,
503
+ allowUninspected,
504
+ usedAllowances,
505
+ stack,
506
+ depth + 1,
507
+ );
388
508
  }
389
509
  return;
390
510
  }
391
511
  if (typeName === "ZodIntersection") {
392
- assertNoTenantKeys(def.left, toolId, path, allowUninspected, usedAllowances, stack, depth + 1);
393
- assertNoTenantKeys(def.right, toolId, path, allowUninspected, usedAllowances, stack, depth + 1);
512
+ assertNoTenantKeys(
513
+ def.left,
514
+ toolId,
515
+ path,
516
+ allowUninspected,
517
+ usedAllowances,
518
+ stack,
519
+ depth + 1,
520
+ );
521
+ assertNoTenantKeys(
522
+ def.right,
523
+ toolId,
524
+ path,
525
+ allowUninspected,
526
+ usedAllowances,
527
+ stack,
528
+ depth + 1,
529
+ );
394
530
  return;
395
531
  }
396
532
  if (typeName === "ZodPipeline") {
397
- assertNoTenantKeys(def.in, toolId, path, allowUninspected, usedAllowances, stack, depth + 1);
398
- assertNoTenantKeys(def.out, toolId, path, allowUninspected, usedAllowances, stack, depth + 1);
533
+ assertNoTenantKeys(
534
+ def.in,
535
+ toolId,
536
+ path,
537
+ allowUninspected,
538
+ usedAllowances,
539
+ stack,
540
+ depth + 1,
541
+ );
542
+ assertNoTenantKeys(
543
+ def.out,
544
+ toolId,
545
+ path,
546
+ allowUninspected,
547
+ usedAllowances,
548
+ stack,
549
+ depth + 1,
550
+ );
399
551
  return;
400
552
  }
401
553
  if (typeName === "ZodSet") {
402
- assertNoTenantKeys(def.valueType, toolId, `${path}[]`, allowUninspected, usedAllowances, stack, depth + 1);
554
+ assertNoTenantKeys(
555
+ def.valueType,
556
+ toolId,
557
+ `${path}[]`,
558
+ allowUninspected,
559
+ usedAllowances,
560
+ stack,
561
+ depth + 1,
562
+ );
403
563
  return;
404
564
  }
405
565
  if (typeName === "ZodLazy") {
406
566
  // Resolve once. A self-referential lazy resolves to a node already on the
407
567
  // recursion stack and is caught by the cycle guard in assertNoTenantKeys;
408
568
  // a generative lazy (fresh node per resolution) hits the frame budget.
409
- assertNoTenantKeys(def.getter?.(), toolId, path, allowUninspected, usedAllowances, stack, depth + 1);
569
+ assertNoTenantKeys(
570
+ def.getter?.(),
571
+ toolId,
572
+ path,
573
+ allowUninspected,
574
+ usedAllowances,
575
+ stack,
576
+ depth + 1,
577
+ );
410
578
  return;
411
579
  }
412
580
 
@@ -433,7 +601,7 @@ export type ActuarialToolKind = "read" | "action";
433
601
  */
434
602
  export type ActuarialToolContext = TenantToolContext;
435
603
 
436
- interface DefineActuarialToolCommon<TShape extends z.ZodRawShape> {
604
+ interface DefineActuarialToolCommon<TShape extends z.ZodRawShape, TResult> {
437
605
  /**
438
606
  * Exact schema paths (the lint's dot notation, rooted at "input") where an
439
607
  * uninspectable type — z.unknown(), z.any(), z.map() — is INTENTIONAL
@@ -456,6 +624,15 @@ interface DefineActuarialToolCommon<TShape extends z.ZodRawShape> {
456
624
  * AgentsError("TENANT_IN_SCHEMA") at definition time if it does.
457
625
  */
458
626
  inputSchema: z.ZodObject<TShape>;
627
+ /**
628
+ * Optional observable-result schema. It must admit the complete success /
629
+ * failure union because validation errors are ordinary tool results.
630
+ */
631
+ outputSchema?: z.ZodType<
632
+ TResult | ToolEnvelopeFailure,
633
+ z.ZodTypeDef,
634
+ unknown
635
+ >;
459
636
  }
460
637
 
461
638
  /**
@@ -474,7 +651,7 @@ interface DefineActuarialToolCommon<TShape extends z.ZodRawShape> {
474
651
  * reviewable at the definition site.
475
652
  */
476
653
  export type DefineActuarialToolOptions<TShape extends z.ZodRawShape, TResult> =
477
- | (DefineActuarialToolCommon<TShape> & {
654
+ | (DefineActuarialToolCommon<TShape, TResult> & {
478
655
  tenant: "required";
479
656
  /** Trusted source for the tenant id. Default "request-context". */
480
657
  tenantSource?: TenantSource;
@@ -487,27 +664,82 @@ export type DefineActuarialToolOptions<TShape extends z.ZodRawShape, TResult> =
487
664
  * envelope, never an exception.
488
665
  */
489
666
  execute: (
490
- input: z.infer<z.ZodObject<TShape>>,
667
+ input: z.output<z.ZodObject<TShape>>,
491
668
  tenant: string,
492
669
  context: ActuarialToolContext,
493
670
  ) => Promise<TResult>;
494
671
  })
495
- | (DefineActuarialToolCommon<TShape> & {
672
+ | (DefineActuarialToolCommon<TShape, TResult> & {
496
673
  tenant: "none";
497
674
  execute: (
498
- input: z.infer<z.ZodObject<TShape>>,
675
+ input: z.output<z.ZodObject<TShape>>,
499
676
  tenant: null,
500
677
  context: ActuarialToolContext,
501
678
  ) => Promise<TResult>;
502
679
  });
503
680
 
681
+ /**
682
+ * A Mastra-compatible tool whose direct execute boundary is fully owned by
683
+ * this SDK. Mastra metadata stays intentionally unknown: the real domain
684
+ * schemas are retained privately so framework validation cannot run their
685
+ * transforms a second time.
686
+ */
687
+ export type DefinedActuarialTool<TInput, TOutput> = Omit<
688
+ Tool<unknown, unknown>,
689
+ "execute"
690
+ > & {
691
+ readonly kind: ActuarialToolKind;
692
+ execute: (input: TInput, context: ActuarialToolContext) => Promise<TOutput>;
693
+ };
694
+
695
+ function metadataBridge(
696
+ schema: z.ZodTypeAny,
697
+ ): StandardSchemaWithJSON<unknown, unknown> {
698
+ const real = toStandardSchema(schema);
699
+ return {
700
+ "~standard": {
701
+ version: 1,
702
+ vendor: "actuarial-ts-metadata-bridge",
703
+ validate: (value: unknown) => ({ value }),
704
+ jsonSchema: {
705
+ input: (options) => real["~standard"].jsonSchema.input(options),
706
+ output: (options) => real["~standard"].jsonSchema.output(options),
707
+ },
708
+ },
709
+ };
710
+ }
711
+
712
+ function isFailure(value: unknown): value is ToolEnvelopeFailure {
713
+ if (value === null || typeof value !== "object") return false;
714
+ const candidate = value as { success?: unknown; error?: unknown };
715
+ if (
716
+ candidate.success !== false ||
717
+ candidate.error === null ||
718
+ typeof candidate.error !== "object"
719
+ )
720
+ return false;
721
+ const error = candidate.error as { code?: unknown; message?: unknown };
722
+ return typeof error.code === "string" && typeof error.message === "string";
723
+ }
724
+
725
+ function sameJson(left: unknown, right: unknown): boolean {
726
+ try {
727
+ return JSON.stringify(left) === JSON.stringify(right);
728
+ } catch {
729
+ return false;
730
+ }
731
+ }
732
+
504
733
  /**
505
734
  * Wraps Mastra's createTool with the envelope + tenant-seam guarantees and
506
735
  * tags the result with its kind for toolRegistry classification.
507
736
  */
508
737
  export function defineActuarialTool<TShape extends z.ZodRawShape, TResult>(
509
738
  options: DefineActuarialToolOptions<TShape, TResult>,
510
- ) {
739
+ ): DefinedActuarialTool<
740
+ z.input<z.ZodObject<TShape>>,
741
+ TResult | ToolEnvelopeFailure
742
+ > {
511
743
  // FAIL CLOSED: a schema the seam cannot inspect is not definable, and the
512
744
  // tenant-key lint recurses through every container the model could reach.
513
745
  const shape = zodObjectShape(options.inputSchema);
@@ -519,7 +751,13 @@ export function defineActuarialTool<TShape extends z.ZodRawShape, TResult>(
519
751
  }
520
752
  const allowUninspected = new Set(options.allowUninspected ?? []);
521
753
  const usedAllowances = new Set<string>();
522
- assertNoTenantKeys(options.inputSchema, options.id, "input", allowUninspected, usedAllowances);
754
+ assertNoTenantKeys(
755
+ options.inputSchema,
756
+ options.id,
757
+ "input",
758
+ allowUninspected,
759
+ usedAllowances,
760
+ );
523
761
  for (const declared of allowUninspected) {
524
762
  if (!usedAllowances.has(declared)) {
525
763
  throw new AgentsError(
@@ -538,28 +776,87 @@ export function defineActuarialTool<TShape extends z.ZodRawShape, TResult>(
538
776
  "relationship to the tenant seam explicitly",
539
777
  );
540
778
  }
779
+
780
+ if (options.outputSchema !== undefined) {
781
+ let probe;
782
+ try {
783
+ probe = options.outputSchema.safeParse(TOOL_OUTPUT_INVALID);
784
+ } catch {
785
+ throw new AgentsError(
786
+ "BAD_OUTPUT_SCHEMA",
787
+ `Tool "${options.id}": outputSchema threw while validating the required failure envelope`,
788
+ );
789
+ }
790
+ if (!probe.success || !sameJson(probe.data, TOOL_OUTPUT_INVALID)) {
791
+ throw new AgentsError(
792
+ "BAD_OUTPUT_SCHEMA",
793
+ `Tool "${options.id}": outputSchema must preserve the complete tool failure envelope`,
794
+ );
795
+ }
796
+ }
797
+
541
798
  const tool = createTool({
542
799
  id: options.id,
543
800
  description: options.description,
544
- inputSchema: options.inputSchema,
545
- execute: async (input: z.infer<z.ZodObject<TShape>>, context: unknown) => {
546
- try {
547
- if (options.tenant === "required") {
548
- // Resolve BEFORE the body runs: an unauthenticated call fails closed
549
- // here, and execute never sees it.
550
- const tenant = resolveTenant(context as ActuarialToolContext, {
551
- source: options.tenantSource,
552
- key: options.tenantKey,
553
- });
554
- return await options.execute(input, tenant, context as ActuarialToolContext);
555
- }
556
- return await options.execute(input, null, context as ActuarialToolContext);
557
- } catch (err) {
558
- return envelopeFailure(err);
559
- }
560
- },
801
+ inputSchema: metadataBridge(options.inputSchema),
802
+ ...(options.outputSchema === undefined
803
+ ? {}
804
+ : { outputSchema: metadataBridge(options.outputSchema) }),
561
805
  });
562
- return Object.assign(tool, { kind: options.kind });
806
+
807
+ const execute = async (
808
+ rawInput: z.input<z.ZodObject<TShape>>,
809
+ context: ActuarialToolContext,
810
+ ): Promise<TResult | ToolEnvelopeFailure> => {
811
+ let parsedInput;
812
+ try {
813
+ parsedInput = options.inputSchema.safeParse(rawInput);
814
+ } catch {
815
+ return TOOL_INPUT_INVALID;
816
+ }
817
+ if (!parsedInput.success) return TOOL_INPUT_INVALID;
818
+
819
+ let rawOutput: TResult | ToolEnvelopeFailure;
820
+ try {
821
+ if (options.tenant === "required") {
822
+ const tenant = resolveTenant(context, {
823
+ source: options.tenantSource,
824
+ key: options.tenantKey,
825
+ });
826
+ rawOutput = await options.execute(parsedInput.data, tenant, context);
827
+ } else {
828
+ rawOutput = await options.execute(parsedInput.data, null, context);
829
+ }
830
+ } catch (err) {
831
+ rawOutput = envelopeFailure(err);
832
+ }
833
+
834
+ if (options.outputSchema === undefined) {
835
+ return rawOutput === undefined ? TOOL_OUTPUT_INVALID : rawOutput;
836
+ }
837
+ let parsedOutput;
838
+ try {
839
+ parsedOutput = options.outputSchema.safeParse(rawOutput);
840
+ } catch {
841
+ return TOOL_OUTPUT_INVALID;
842
+ }
843
+ if (!parsedOutput.success) return TOOL_OUTPUT_INVALID;
844
+ if (isFailure(rawOutput) && !sameJson(parsedOutput.data, rawOutput))
845
+ return TOOL_OUTPUT_INVALID;
846
+ return deepFreezeResult(parsedOutput.data);
847
+ };
848
+
849
+ // The metadata bridges deliberately erase domain inference on the inherited
850
+ // Mastra surface. This is the single convergence assertion: the adapter
851
+ // above is the only executor and has the exact public input/output contract.
852
+ const defined = Object.assign(tool, {
853
+ execute,
854
+ kind: options.kind,
855
+ }) as DefinedActuarialTool<
856
+ z.input<z.ZodObject<TShape>>,
857
+ TResult | ToolEnvelopeFailure
858
+ >;
859
+ return defined;
563
860
  }
564
861
 
565
862
  // ---------------------------------------------------------------------------
@@ -586,10 +883,10 @@ export interface ActuarialToolRegistry<T extends RegistrableActuarialTool> {
586
883
  export function toolRegistry<T extends RegistrableActuarialTool>(
587
884
  tools: readonly T[],
588
885
  ): ActuarialToolRegistry<T> {
589
- const record: Record<string, T> = {};
886
+ const record = Object.create(null) as Record<string, T>;
590
887
  const actionToolIds = new Set<string>();
591
888
  for (const tool of tools) {
592
- if (record[tool.id]) {
889
+ if (Object.prototype.hasOwnProperty.call(record, tool.id)) {
593
890
  throw new AgentsError(
594
891
  "DUPLICATE_TOOL_ID",
595
892
  `Two tools share the id "${tool.id}"; tool ids must be unique within a registry`,