agentfootprint 2.11.0 → 2.11.2

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.
Files changed (110) hide show
  1. package/README.md +2 -1
  2. package/dist/core/Agent.js +89 -1341
  3. package/dist/core/Agent.js.map +1 -1
  4. package/dist/core/agent/AgentBuilder.js +489 -0
  5. package/dist/core/agent/AgentBuilder.js.map +1 -0
  6. package/dist/core/agent/buildAgentChart.js +227 -0
  7. package/dist/core/agent/buildAgentChart.js.map +1 -0
  8. package/dist/core/agent/buildToolRegistry.js +115 -0
  9. package/dist/core/agent/buildToolRegistry.js.map +1 -0
  10. package/dist/core/agent/stages/breakFinal.js +28 -0
  11. package/dist/core/agent/stages/breakFinal.js.map +1 -0
  12. package/dist/core/agent/stages/callLLM.js +129 -0
  13. package/dist/core/agent/stages/callLLM.js.map +1 -0
  14. package/dist/core/agent/stages/iterationStart.js +24 -0
  15. package/dist/core/agent/stages/iterationStart.js.map +1 -0
  16. package/dist/core/agent/stages/prepareFinal.js +45 -0
  17. package/dist/core/agent/stages/prepareFinal.js.map +1 -0
  18. package/dist/core/agent/stages/route.js +36 -0
  19. package/dist/core/agent/stages/route.js.map +1 -0
  20. package/dist/core/agent/stages/seed.js +95 -0
  21. package/dist/core/agent/stages/seed.js.map +1 -0
  22. package/dist/core/agent/stages/toolCalls.js +250 -0
  23. package/dist/core/agent/stages/toolCalls.js.map +1 -0
  24. package/dist/core/agent/types.js +12 -0
  25. package/dist/core/agent/types.js.map +1 -0
  26. package/dist/core/agent/validators.js +131 -0
  27. package/dist/core/agent/validators.js.map +1 -0
  28. package/dist/esm/core/Agent.js +87 -1338
  29. package/dist/esm/core/Agent.js.map +1 -1
  30. package/dist/esm/core/agent/AgentBuilder.js +485 -0
  31. package/dist/esm/core/agent/AgentBuilder.js.map +1 -0
  32. package/dist/esm/core/agent/buildAgentChart.js +223 -0
  33. package/dist/esm/core/agent/buildAgentChart.js.map +1 -0
  34. package/dist/esm/core/agent/buildToolRegistry.js +111 -0
  35. package/dist/esm/core/agent/buildToolRegistry.js.map +1 -0
  36. package/dist/esm/core/agent/stages/breakFinal.js +24 -0
  37. package/dist/esm/core/agent/stages/breakFinal.js.map +1 -0
  38. package/dist/esm/core/agent/stages/callLLM.js +125 -0
  39. package/dist/esm/core/agent/stages/callLLM.js.map +1 -0
  40. package/dist/esm/core/agent/stages/iterationStart.js +20 -0
  41. package/dist/esm/core/agent/stages/iterationStart.js.map +1 -0
  42. package/dist/esm/core/agent/stages/prepareFinal.js +41 -0
  43. package/dist/esm/core/agent/stages/prepareFinal.js.map +1 -0
  44. package/dist/esm/core/agent/stages/route.js +32 -0
  45. package/dist/esm/core/agent/stages/route.js.map +1 -0
  46. package/dist/esm/core/agent/stages/seed.js +91 -0
  47. package/dist/esm/core/agent/stages/seed.js.map +1 -0
  48. package/dist/esm/core/agent/stages/toolCalls.js +246 -0
  49. package/dist/esm/core/agent/stages/toolCalls.js.map +1 -0
  50. package/dist/esm/core/agent/types.js +11 -0
  51. package/dist/esm/core/agent/types.js.map +1 -0
  52. package/dist/esm/core/agent/validators.js +124 -0
  53. package/dist/esm/core/agent/validators.js.map +1 -0
  54. package/dist/esm/reliability/CircuitBreaker.js +156 -0
  55. package/dist/esm/reliability/CircuitBreaker.js.map +1 -0
  56. package/dist/esm/reliability/buildReliabilityGateChart.js +359 -0
  57. package/dist/esm/reliability/buildReliabilityGateChart.js.map +1 -0
  58. package/dist/esm/reliability/classifyError.js +56 -0
  59. package/dist/esm/reliability/classifyError.js.map +1 -0
  60. package/dist/esm/reliability/index.js +36 -0
  61. package/dist/esm/reliability/index.js.map +1 -0
  62. package/dist/esm/reliability/types.js +44 -0
  63. package/dist/esm/reliability/types.js.map +1 -0
  64. package/dist/reliability/CircuitBreaker.js +165 -0
  65. package/dist/reliability/CircuitBreaker.js.map +1 -0
  66. package/dist/reliability/buildReliabilityGateChart.js +363 -0
  67. package/dist/reliability/buildReliabilityGateChart.js.map +1 -0
  68. package/dist/reliability/classifyError.js +60 -0
  69. package/dist/reliability/classifyError.js.map +1 -0
  70. package/dist/reliability/index.js +42 -0
  71. package/dist/reliability/index.js.map +1 -0
  72. package/dist/reliability/types.js +48 -0
  73. package/dist/reliability/types.js.map +1 -0
  74. package/dist/types/core/Agent.d.ts +7 -400
  75. package/dist/types/core/Agent.d.ts.map +1 -1
  76. package/dist/types/core/agent/AgentBuilder.d.ts +348 -0
  77. package/dist/types/core/agent/AgentBuilder.d.ts.map +1 -0
  78. package/dist/types/core/agent/buildAgentChart.d.ts +74 -0
  79. package/dist/types/core/agent/buildAgentChart.d.ts.map +1 -0
  80. package/dist/types/core/agent/buildToolRegistry.d.ts +62 -0
  81. package/dist/types/core/agent/buildToolRegistry.d.ts.map +1 -0
  82. package/dist/types/core/agent/stages/breakFinal.d.ts +23 -0
  83. package/dist/types/core/agent/stages/breakFinal.d.ts.map +1 -0
  84. package/dist/types/core/agent/stages/callLLM.d.ts +54 -0
  85. package/dist/types/core/agent/stages/callLLM.d.ts.map +1 -0
  86. package/dist/types/core/agent/stages/iterationStart.d.ts +16 -0
  87. package/dist/types/core/agent/stages/iterationStart.d.ts.map +1 -0
  88. package/dist/types/core/agent/stages/prepareFinal.d.ts +20 -0
  89. package/dist/types/core/agent/stages/prepareFinal.d.ts.map +1 -0
  90. package/dist/types/core/agent/stages/route.d.ts +19 -0
  91. package/dist/types/core/agent/stages/route.d.ts.map +1 -0
  92. package/dist/types/core/agent/stages/seed.d.ts +54 -0
  93. package/dist/types/core/agent/stages/seed.d.ts.map +1 -0
  94. package/dist/types/core/agent/stages/toolCalls.d.ts +50 -0
  95. package/dist/types/core/agent/stages/toolCalls.d.ts.map +1 -0
  96. package/dist/types/core/agent/types.d.ts +154 -0
  97. package/dist/types/core/agent/types.d.ts.map +1 -0
  98. package/dist/types/core/agent/validators.d.ts +48 -0
  99. package/dist/types/core/agent/validators.d.ts.map +1 -0
  100. package/dist/types/reliability/CircuitBreaker.d.ts +76 -0
  101. package/dist/types/reliability/CircuitBreaker.d.ts.map +1 -0
  102. package/dist/types/reliability/buildReliabilityGateChart.d.ts +54 -0
  103. package/dist/types/reliability/buildReliabilityGateChart.d.ts.map +1 -0
  104. package/dist/types/reliability/classifyError.d.ts +29 -0
  105. package/dist/types/reliability/classifyError.d.ts.map +1 -0
  106. package/dist/types/reliability/index.d.ts +34 -0
  107. package/dist/types/reliability/index.d.ts.map +1 -0
  108. package/dist/types/reliability/types.d.ts +256 -0
  109. package/dist/types/reliability/types.d.ts.map +1 -0
  110. package/package.json +1 -1
@@ -13,22 +13,10 @@
13
13
  * agentfootprint.stream.tool_start / tool_end
14
14
  * agentfootprint.context.* (via ContextRecorder)
15
15
  */
16
- import { FlowChartExecutor, flowChart, } from 'footprintjs';
17
- // ArrayMergeMode lives on footprintjs's `advanced` subpath, not its
18
- // main barrel. Used to set `arrayMerge: Replace` on subflow output
19
- // mapping for the Tools slot — the slot's deduped tool list must
20
- // REPLACE the parent's `dynamicToolSchemas` rather than concatenate
21
- // with it (default behavior re-introduces duplicate tool names that
22
- // LLM providers reject).
23
- import { ArrayMergeMode } from 'footprintjs/advanced';
16
+ import { FlowChartExecutor, } from 'footprintjs';
24
17
  import { cacheDecisionSubflow } from '../cache/CacheDecisionSubflow.js';
25
18
  import { cacheGateDecide, updateSkillHistory as updateSkillHistoryStage, } from '../cache/CacheGateDecider.js';
26
19
  import { getDefaultCacheStrategy } from '../cache/strategyRegistry.js';
27
- import { isPauseRequest } from './pause.js';
28
- import { emitCostTick } from './cost.js';
29
- import { STAGE_IDS, SUBFLOW_IDS } from '../conventions.js';
30
- import { defaultCommentaryTemplates } from '../recorders/observability/commentary/commentaryTemplates.js';
31
- import { defaultThinkingTemplates } from '../recorders/observability/thinking/thinkingTemplates.js';
32
20
  import { ContextRecorder } from '../recorders/core/ContextRecorder.js';
33
21
  import { streamRecorder } from '../recorders/core/StreamRecorder.js';
34
22
  import { agentRecorder } from '../recorders/core/AgentRecorder.js';
@@ -37,20 +25,27 @@ import { permissionRecorder } from '../recorders/core/PermissionRecorder.js';
37
25
  import { evalRecorder } from '../recorders/core/EvalRecorder.js';
38
26
  import { memoryRecorder } from '../recorders/core/MemoryRecorder.js';
39
27
  import { skillRecorder } from '../recorders/core/SkillRecorder.js';
40
- import { typedEmit } from '../recorders/core/typedEmit.js';
41
- import { memoryInjectionKey } from '../memory/define.types.js';
42
- import { unwrapMemoryFlowChart } from '../memory/define.js';
43
- import { mountMemoryRead, mountMemoryWrite } from '../memory/wire/mountMemoryPipeline.js';
44
28
  import { buildSystemPromptSlot } from './slots/buildSystemPromptSlot.js';
45
29
  import { buildMessagesSlot } from './slots/buildMessagesSlot.js';
46
30
  import { buildToolsSlot } from './slots/buildToolsSlot.js';
47
31
  import { buildInjectionEngineSubflow } from '../lib/injection-engine/buildInjectionEngineSubflow.js';
48
- import { buildReadSkillTool } from '../lib/injection-engine/skillTools.js';
49
- import { defineInstruction } from '../lib/injection-engine/factories/defineInstruction.js';
50
- import { applyOutputFallback, validateCannedAgainstSchema, } from './outputFallback.js';
32
+ import { applyOutputFallback } from './outputFallback.js';
51
33
  import { buildCheckpoint, classifyFailurePhase, RunCheckpointError, validateCheckpoint, } from './runCheckpoint.js';
52
- import { applyOutputSchema, buildDefaultInstruction, OutputSchemaError, } from './outputSchema.js';
34
+ import { applyOutputSchema, OutputSchemaError } from './outputSchema.js';
53
35
  import { RunnerBase, makeRunId } from './RunnerBase.js';
36
+ import { clampIterations, validateMemoryIdUniqueness, validateToolNameUniqueness, } from './agent/validators.js';
37
+ import { iterationStartStage } from './agent/stages/iterationStart.js';
38
+ import { routeDeciderStage } from './agent/stages/route.js';
39
+ import { buildSeedStage } from './agent/stages/seed.js';
40
+ import { buildCallLLMStage } from './agent/stages/callLLM.js';
41
+ import { buildToolCallsHandler } from './agent/stages/toolCalls.js';
42
+ import { buildAgentChart } from './agent/buildAgentChart.js';
43
+ import { buildToolRegistry } from './agent/buildToolRegistry.js';
44
+ import { AgentBuilder } from './agent/AgentBuilder.js';
45
+ export { AgentBuilder };
46
+ // Public types (AgentOptions, AgentInput, AgentOutput) extracted to
47
+ // ./agent/types.ts and re-exported above (v2.11.1).
48
+ // AgentState extracted to ./agent/types.ts (v2.11.1).
54
49
  export class Agent extends RunnerBase {
55
50
  name;
56
51
  id;
@@ -505,168 +500,33 @@ export class Agent extends RunnerBase {
505
500
  const systemPromptCachePolicy = this.systemPromptCachePolicy;
506
501
  const cachingDisabled = this.cachingDisabledValue;
507
502
  const cacheStrategy = this.cacheStrategy;
508
- const seed = (scope) => {
509
- const args = scope.$getArgs();
510
- scope.userMessage = args.message;
511
- // If `resumeOnError(...)` set the side channel, restore the
512
- // checkpointed conversation history. The next iteration sees
513
- // the prior messages and continues from the failure point.
514
- // We always clear the field after reading so subsequent runs
515
- // (without resumeOnError) start fresh.
516
- if (this.pendingResumeHistory && this.pendingResumeHistory.length > 0) {
517
- scope.history = [...this.pendingResumeHistory];
503
+ // seed extracted to ./agent/stages/seed.ts (v2.11.2). Factory takes
504
+ // chart-build-time constants + per-run mutable accessors so the
505
+ // resume side-channel and current run id remain dynamic.
506
+ // toolSchemas is finalized further down; pass a getter that reads
507
+ // the eventual const at stage-execution time.
508
+ let toolSchemasResolved = [];
509
+ const seed = buildSeedStage({
510
+ maxIterations,
511
+ cachingDisabled,
512
+ get toolSchemas() {
513
+ return toolSchemasResolved;
514
+ },
515
+ consumePendingResumeHistory: () => {
516
+ const h = this.pendingResumeHistory;
518
517
  this.pendingResumeHistory = undefined;
519
- }
520
- else {
521
- scope.history = [{ role: 'user', content: args.message }];
522
- }
523
- // Default identity uses the runId so multi-run isolation works
524
- // without consumer changes; explicit identity (multi-tenant)
525
- // overrides via `agent.run({ identity })`.
526
- scope.runIdentity = args.identity ?? {
527
- conversationId: this.currentRunContext?.runId ?? 'default',
528
- };
529
- scope.newMessages = [];
530
- scope.turnNumber = 1;
531
- // Permissive default — explicit cap will land when PricingTable
532
- // gets a context-window field. Memory pickByBudget treats anything
533
- // ≥ minimumTokens as "fits", so this just enables the budget path.
534
- scope.contextTokensRemaining = 32_000;
535
- scope.iteration = 1;
536
- scope.maxIterations = maxIterations;
537
- scope.finalContent = '';
538
- scope.totalInputTokens = 0;
539
- scope.totalOutputTokens = 0;
540
- scope.turnStartMs = Date.now();
541
- scope.systemPromptInjections = [];
542
- scope.messagesInjections = [];
543
- scope.toolsInjections = [];
544
- scope.llmLatestContent = '';
545
- scope.llmLatestToolCalls = [];
546
- scope.pausedToolCallId = '';
547
- scope.pausedToolName = '';
548
- scope.pausedToolStartMs = 0;
549
- scope.cumTokensInput = 0;
550
- scope.cumTokensOutput = 0;
551
- scope.cumEstimatedUsd = 0;
552
- scope.costBudgetHit = false;
553
- scope.activeInjections = [];
554
- scope.activatedInjectionIds = [];
555
- scope.dynamicToolSchemas = toolSchemas;
556
- // Cache layer state (v2.6) — initialized to inert defaults.
557
- // CacheDecision subflow populates `cacheMarkers` per iteration;
558
- // UpdateSkillHistory + CacheGate consume `cachingDisabled`,
559
- // `recentHitRate`, `skillHistory`. Empty defaults mean the
560
- // CacheGate falls through to 'apply-markers' on iter 1 (no
561
- // history yet → no churn detected; recentHitRate undefined →
562
- // hit-rate floor doesn't fire).
563
- scope.cacheMarkers = [];
564
- scope.cachingDisabled = cachingDisabled;
565
- scope.recentHitRate = undefined;
566
- scope.skillHistory = [];
567
- typedEmit(scope, 'agentfootprint.agent.turn_start', {
568
- turnIndex: 0,
569
- userPrompt: args.message,
570
- });
571
- };
572
- // Tool registry composition — three sources:
573
- //
574
- // 1. Static registry: tools registered via `.tool()`. Always
575
- // visible to the LLM; always executable.
576
- // 2. `read_skill` (auto-attached when ≥1 Skill is registered):
577
- // activation tool for LLM-guided Skills.
578
- // 3. Skill-supplied tools (`Skill.inject.tools[]`): visible only
579
- // when the Skill is active (filtered by tools slot subflow);
580
- // MUST always be in the executor registry so when the LLM
581
- // calls one, the tool-calls handler can dispatch.
582
- //
583
- // Tool-name uniqueness is enforced across all three sources at
584
- // build time. The LLM only sees `tool.schema.name` (no ids), so
585
- // names ARE the runtime dispatch key — collisions break the LLM's
586
- // ability to call the right tool. Throw early instead of subtly
587
- // shadowing.
588
- const skills = this.injections.filter((i) => i.flavor === 'skill');
589
- // Collect skill tools, deduping by name when the SAME Tool reference
590
- // is shared across skills. Different Tool implementations under the
591
- // same name throws (already validated upstream by
592
- // validateToolNameUniqueness) — we keep the runtime check as
593
- // belt-and-suspenders.
594
- //
595
- // Block C runtime — `autoActivate: 'currentSkill'` semantics:
596
- // When a skill's `defineSkill({ autoActivate: 'currentSkill' })`
597
- // is set, its tools are EXCLUDED from the static registry. They
598
- // flow into the LLM's tool list ONLY through `dynamicSchemas`
599
- // (the buildToolsSlot path that reads activeInjections), which
600
- // means they're visible ONLY on iterations after the skill is
601
- // activated by `read_skill('id')`. Without this, the LLM sees
602
- // every skill's tools on every iteration and the
603
- // per-skill-narrowing autoActivate promised in `defineSkill`
604
- // doesn't actually narrow anything. Skills WITHOUT autoActivate
605
- // keep the v2.4 behavior (tools always visible) for back-compat.
606
- const skillToolEntries = [];
607
- const sharedSkillTools = new Map();
608
- for (const skill of skills) {
609
- const meta = skill.metadata;
610
- const isAutoActivate = meta?.autoActivate === 'currentSkill';
611
- const toolsFromSkill = skill.inject.tools ?? [];
612
- for (const tool of toolsFromSkill) {
613
- const name = tool.schema.name;
614
- const existing = sharedSkillTools.get(name);
615
- if (existing) {
616
- if (existing !== tool) {
617
- throw new Error(`Agent: tool name '${name}' is declared by multiple skills with different ` +
618
- `Tool implementations. Skills MAY share the SAME Tool reference; they may ` +
619
- `NOT register different functions under the same name.`);
620
- }
621
- continue; // dedupe — same reference already added
622
- }
623
- sharedSkillTools.set(name, tool);
624
- // autoActivate skills: their tools come ONLY through
625
- // dynamicSchemas (buildToolsSlot.ts pulls them from
626
- // activeInjections.inject.tools when the skill is active).
627
- // Don't pre-load them in the static registry.
628
- if (isAutoActivate)
629
- continue;
630
- skillToolEntries.push({ name, tool });
631
- }
632
- }
633
- // buildReadSkillTool returns undefined when skills is empty; the
634
- // length check below short-circuits so the non-null assertion is safe.
635
- const readSkillEntries = skills.length > 0 ? [{ name: 'read_skill', tool: buildReadSkillTool(skills) }] : [];
636
- const augmentedRegistry = [
637
- ...registry,
638
- ...readSkillEntries,
639
- ...skillToolEntries,
640
- ];
641
- // Final cross-source name-uniqueness check: static .tool() vs
642
- // read_skill vs (deduped) skill tools. After the dedupe above this
643
- // catches collisions BETWEEN sources (e.g., a static .tool('foo')
644
- // colliding with a Skill's foo) which are real bugs.
645
- const seenNames = new Set();
646
- for (const entry of augmentedRegistry) {
647
- if (seenNames.has(entry.name)) {
648
- throw new Error(`Agent: duplicate tool name '${entry.name}'. Tool names must be unique ` +
649
- `across .tool() registrations and Skills' inject.tools (after deduping ` +
650
- `same-reference shares across skills). The LLM dispatches by name; ` +
651
- `collisions break tool routing.`);
652
- }
653
- seenNames.add(entry.name);
654
- }
655
- const registryByName = new Map(augmentedRegistry.map((e) => [e.name, e.tool]));
656
- // Block C runtime — autoActivate skill tools live OUTSIDE the LLM-
657
- // visible registry (so they don't pollute the per-iteration tool
658
- // list before the skill activates), but they MUST still be findable
659
- // by the dispatch handler — the LLM calls them by name once the
660
- // skill is active, and dispatch looks up by name. Add them to the
661
- // dispatch map so `lookupTool` resolves correctly. Using the Map
662
- // backing the static registryByName means autoActivate tools share
663
- // the same `.execute` wiring as normal tools — no special path.
664
- for (const [name, tool] of sharedSkillTools.entries()) {
665
- if (!registryByName.has(name)) {
666
- registryByName.set(name, tool);
667
- }
668
- }
669
- const toolSchemas = augmentedRegistry.map((e) => e.tool.schema);
518
+ return h;
519
+ },
520
+ getCurrentRunId: () => this.currentRunContext?.runId,
521
+ });
522
+ // Tool registry composition extracted to ./agent/buildToolRegistry.ts.
523
+ // Composes static .tool() registry + auto-attached read_skill +
524
+ // skill-supplied tools (with autoActivate scoping); validates
525
+ // name uniqueness; produces the dispatch map.
526
+ const { registryByName, toolSchemas } = buildToolRegistry(registry, this.injections);
527
+ // Late-bind toolSchemas into the seed stage's deps (the factory was
528
+ // built earlier with a getter; this resolves the actual value).
529
+ toolSchemasResolved = toolSchemas;
670
530
  const injectionEngineSubflow = buildInjectionEngineSubflow({
671
531
  injections: this.injections,
672
532
  });
@@ -679,1163 +539,52 @@ export class Agent extends RunnerBase {
679
539
  tools: toolSchemas,
680
540
  ...(this.externalToolProvider && { toolProvider: this.externalToolProvider }),
681
541
  });
682
- const iterationStart = (scope) => {
683
- typedEmit(scope, 'agentfootprint.agent.iteration_start', {
684
- turnIndex: 0,
685
- iterIndex: scope.iteration,
686
- });
687
- };
688
- const callLLM = async (scope) => {
689
- const systemPromptInjections = scope.systemPromptInjections ?? [];
690
- // `scope.messagesInjections` is read by ContextRecorder for
691
- // observability; the LLM-wire path now reads scope.history
692
- // directly (see below for rationale).
693
- const iteration = scope.iteration;
694
- const systemPrompt = systemPromptInjections
695
- .map((r) => r.rawContent ?? '')
696
- .filter((s) => s.length > 0)
697
- .join('\n\n');
698
- // Read the LLM message stream from `scope.history` directly.
699
- // The `messagesInjections` projection is for observability
700
- // (ContextRecorder, Lens) — it flattens InjectionRecords for
701
- // event reporting and doesn't carry the full LLM-protocol
702
- // shape (assistant `toolCalls[]`, etc.). For Anthropic's API
703
- // contract we need the original LLMMessage with `toolCalls`
704
- // intact so tool_use → tool_result correlation survives.
705
- const messages = scope.history ?? [];
706
- typedEmit(scope, 'agentfootprint.stream.llm_start', {
707
- iteration,
708
- provider: provider.name,
709
- model,
710
- systemPromptChars: systemPrompt.length,
711
- messagesCount: messages.length,
712
- toolsCount: toolSchemas.length,
713
- ...(temperature !== undefined && { temperature }),
714
- });
715
- const startMs = Date.now();
716
- // Use dynamic schemas — registry tools + injection-supplied
717
- // tools (Skills' `inject.tools` when their Injection is active).
718
- // Falls back to the static schemas at startup before the tools
719
- // slot has run for the first time.
720
- const activeToolSchemas = scope.dynamicToolSchemas ?? toolSchemas;
721
- const baseRequest = {
722
- ...(systemPrompt.length > 0 && { systemPrompt }),
723
- messages,
724
- ...(activeToolSchemas.length > 0 && { tools: activeToolSchemas }),
725
- model,
726
- ...(temperature !== undefined && { temperature }),
727
- ...(maxTokens !== undefined && { maxTokens }),
728
- };
729
- // v2.6+ — call cache strategy to attach provider-specific cache
730
- // hints. CacheGate has already routed (apply-markers / no-markers)
731
- // and populated scope.cacheMarkers accordingly. Strategy.prepareRequest
732
- // is a pass-through for empty markers.
733
- const cacheMarkers = scope.cacheMarkers ?? [];
734
- const cachePrepared = await cacheStrategy.prepareRequest(baseRequest, cacheMarkers, {
735
- iteration,
736
- iterationsRemaining: Math.max(0, maxIterations - iteration),
737
- recentHitRate: scope.recentHitRate,
738
- cachingDisabled: scope.cachingDisabled ?? false,
739
- });
740
- const llmRequest = cachePrepared.request;
741
- // Streaming-first: when the provider implements `stream()` we
742
- // consume chunk-by-chunk so consumers (Lens commentary, chat
743
- // UIs) see tokens as they arrive instead of waiting for the
744
- // full LLM call to finish. Each non-terminal chunk fires
745
- // `agentfootprint.stream.token` with the token text + index.
746
- //
747
- // The terminal chunk SHOULD carry the authoritative
748
- // `LLMResponse` (toolCalls + usage + stopReason); when it does
749
- // we use it directly. When it doesn't (older providers, partial
750
- // implementations) we fall back to `complete()` for the
751
- // authoritative payload — keeping the ReAct loop deterministic.
752
- let response;
753
- if (provider.stream) {
754
- for await (const chunk of provider.stream(llmRequest)) {
755
- if (chunk.done) {
756
- if (chunk.response)
757
- response = chunk.response;
758
- break;
759
- }
760
- if (chunk.content.length > 0) {
761
- typedEmit(scope, 'agentfootprint.stream.token', {
762
- iteration,
763
- tokenIndex: chunk.tokenIndex,
764
- content: chunk.content,
765
- });
766
- }
767
- }
768
- }
769
- if (!response) {
770
- // No `stream()` OR stream finished without a response payload.
771
- response = await provider.complete(llmRequest);
772
- }
773
- const durationMs = Date.now() - startMs;
774
- scope.totalInputTokens = scope.totalInputTokens + response.usage.input;
775
- scope.totalOutputTokens = scope.totalOutputTokens + response.usage.output;
776
- scope.llmLatestContent = response.content;
777
- scope.llmLatestToolCalls = response.toolCalls;
778
- typedEmit(scope, 'agentfootprint.stream.llm_end', {
779
- iteration,
780
- content: response.content,
781
- toolCallCount: response.toolCalls.length,
782
- usage: response.usage,
783
- stopReason: response.stopReason,
784
- durationMs,
785
- });
786
- emitCostTick(scope, pricingTable, costBudget, model, response.usage);
787
- };
788
- /** Decides the next branch: 'tool-calls' or 'final'. */
789
- const routeDecider = (scope) => {
790
- const toolCalls = scope.llmLatestToolCalls;
791
- const iteration = scope.iteration;
792
- const chosen = toolCalls.length > 0 && iteration < scope.maxIterations ? 'tool-calls' : 'final';
793
- typedEmit(scope, 'agentfootprint.agent.route_decided', {
794
- turnIndex: 0,
795
- iterIndex: iteration,
796
- chosen,
797
- rationale: chosen === 'tool-calls'
798
- ? `LLM requested ${toolCalls.length} tool call(s)`
799
- : iteration >= scope.maxIterations
800
- ? 'maxIterations reached — forcing final'
801
- : 'LLM produced no tool calls — final answer',
802
- });
803
- return chosen;
804
- };
805
- /**
806
- * Pausable tool-call handler.
807
- *
808
- * `execute` iterates the LLM-requested tool calls. If a tool throws
809
- * `PauseRequest` via `pauseHere()`, we save the remaining work into
810
- * scope and return the pause data — footprintjs captures a checkpoint
811
- * and bubbles it up. The outer `Agent.run()` surfaces it as a
812
- * `RunnerPauseOutcome`.
813
- *
814
- * `resume` is called when the consumer provides the human's answer.
815
- * We treat that answer as the paused tool's result and append it to
816
- * history, then continue the ReAct iteration loop.
817
- */
818
- const toolCallsHandler = {
819
- execute: async (scope) => {
820
- const toolCalls = scope.llmLatestToolCalls;
821
- const iteration = scope.iteration;
822
- const newHistory = [...scope.history];
823
- // ALWAYS push the assistant turn when there are tool calls — even
824
- // if the content was empty — so providers (Anthropic, OpenAI) can
825
- // round-trip the tool_use blocks via `LLMMessage.toolCalls`.
826
- // Without this, the next iteration's request lacks the assistant
827
- // turn that initiated the tool call, and the API rejects the
828
- // following tool_result with "preceding tool_use missing".
829
- if (scope.llmLatestContent || toolCalls.length > 0) {
830
- newHistory.push({
831
- role: 'assistant',
832
- content: scope.llmLatestContent ?? '',
833
- ...(toolCalls.length > 0 && { toolCalls }),
834
- });
835
- }
836
- // Resolve a tool by name, consulting the external ToolProvider
837
- // if one was wired via `.toolProvider()` and the static
838
- // registry doesn't carry the tool. The provider sees the same
839
- // ctx the Tools slot used, so dispatch + visibility stay
840
- // consistent within the iteration.
841
- const externalToolProvider = this.externalToolProvider;
842
- const lookupTool = (toolName) => {
843
- const fromRegistry = registryByName.get(toolName);
844
- if (fromRegistry)
845
- return fromRegistry;
846
- if (!externalToolProvider)
847
- return undefined;
848
- const activatedIds = scope.activatedInjectionIds ?? [];
849
- const identity = scope.runIdentity;
850
- const ctx = {
851
- iteration: scope.iteration,
852
- ...(activatedIds.length > 0 && {
853
- activeSkillId: activatedIds[activatedIds.length - 1],
854
- }),
855
- ...(identity && { identity }),
856
- };
857
- const visible = externalToolProvider.list(ctx);
858
- return visible.find((t) => t.schema.name === toolName);
859
- };
860
- for (const tc of toolCalls) {
861
- const tool = lookupTool(tc.name);
862
- typedEmit(scope, 'agentfootprint.stream.tool_start', {
863
- toolName: tc.name,
864
- toolCallId: tc.id,
865
- args: tc.args,
866
- ...(toolCalls.length > 1 && { parallelCount: toolCalls.length }),
867
- });
868
- const startMs = Date.now();
869
- let result;
870
- let error;
871
- // Permission gate — when a checker is configured, evaluate BEFORE
872
- // executing the tool. Emits `permission.check` with the decision.
873
- // On 'deny', the tool is not executed and its result is a
874
- // synthetic denial string; on 'allow'/'gate_open', execution
875
- // proceeds normally (the gate is informational — the consumer's
876
- // checker is responsible for any gate-open side effects).
877
- let denied = false;
878
- if (permissionChecker) {
879
- try {
880
- const decision = await permissionChecker.check({
881
- capability: 'tool_call',
882
- actor: 'agent',
883
- target: tc.name,
884
- context: tc.args,
885
- });
886
- typedEmit(scope, 'agentfootprint.permission.check', {
887
- capability: 'tool_call',
888
- actor: 'agent',
889
- target: tc.name,
890
- result: decision.result,
891
- ...(decision.policyRuleId !== undefined && { policyRuleId: decision.policyRuleId }),
892
- ...(decision.rationale !== undefined && { rationale: decision.rationale }),
893
- });
894
- if (decision.result === 'deny') {
895
- denied = true;
896
- result = `[permission denied: ${decision.rationale ?? 'policy'}]`;
897
- }
898
- }
899
- catch (permErr) {
900
- // A checker that throws is treated as deny-by-default. The
901
- // denial message records the thrown error so consumers can
902
- // debug policy-adapter failures without losing the run.
903
- denied = true;
904
- const msg = permErr instanceof Error ? permErr.message : String(permErr);
905
- typedEmit(scope, 'agentfootprint.permission.check', {
906
- capability: 'tool_call',
907
- actor: 'agent',
908
- target: tc.name,
909
- result: 'deny',
910
- rationale: `permission-checker threw: ${msg}`,
911
- });
912
- result = `[permission denied: checker error: ${msg}]`;
913
- }
914
- }
915
- if (!denied) {
916
- try {
917
- if (!tool)
918
- throw new Error(`Unknown tool: ${tc.name}`);
919
- result = await tool.execute(tc.args, {
920
- toolCallId: tc.id,
921
- iteration,
922
- });
923
- }
924
- catch (err) {
925
- if (isPauseRequest(err)) {
926
- // Commit partial state so resume() can find history intact.
927
- scope.history = newHistory;
928
- scope.pausedToolCallId = tc.id;
929
- scope.pausedToolName = tc.name;
930
- scope.pausedToolStartMs = startMs;
931
- // Returning a defined value triggers footprintjs pause —
932
- // the returned object becomes the checkpoint's pauseData.
933
- return {
934
- toolCallId: tc.id,
935
- toolName: tc.name,
936
- ...(typeof err.data === 'object' && err.data !== null
937
- ? err.data
938
- : { data: err.data }),
939
- };
940
- }
941
- error = true;
942
- result = err instanceof Error ? err.message : String(err);
943
- }
944
- }
945
- const durationMs = Date.now() - startMs;
946
- typedEmit(scope, 'agentfootprint.stream.tool_end', {
947
- toolCallId: tc.id,
948
- result,
949
- durationMs,
950
- ...(error === true && { error: true }),
951
- });
952
- const resultStr = typeof result === 'string' ? result : safeStringify(result);
953
- newHistory.push({
954
- role: 'tool',
955
- content: resultStr,
956
- toolCallId: tc.id,
957
- toolName: tc.name,
958
- });
959
- // ── Dynamic ReAct wiring ───────────────────────────────
960
- //
961
- // (1) `lastToolResult` drives `on-tool-return` Injection
962
- // triggers — the InjectionEngine's NEXT pass will see
963
- // this and activate any matching Instructions.
964
- scope.lastToolResult = { toolName: tc.name, result: resultStr };
965
- // (2) `read_skill` is the auto-attached activation tool.
966
- // When the LLM calls it with a valid Skill id, append
967
- // to `activatedInjectionIds` so the InjectionEngine's
968
- // NEXT pass activates that Skill (lifetime: turn — stays
969
- // active until the turn ends).
970
- if (tc.name === 'read_skill' && !error && !denied) {
971
- const requestedId = tc.args.id;
972
- if (typeof requestedId === 'string' && requestedId.length > 0) {
973
- const current = scope.activatedInjectionIds;
974
- if (!current.includes(requestedId)) {
975
- scope.activatedInjectionIds = [...current, requestedId];
976
- }
977
- }
978
- }
979
- }
980
- scope.history = newHistory;
981
- typedEmit(scope, 'agentfootprint.agent.iteration_end', {
982
- turnIndex: 0,
983
- iterIndex: iteration,
984
- toolCallCount: toolCalls.length,
985
- history: scope.history,
986
- });
987
- scope.iteration = iteration + 1;
988
- return undefined; // explicit: no pause, flow continues to loopTo
989
- },
990
- resume: (scope, input) => {
991
- // Consumer-supplied resume input becomes the paused tool's result.
992
- // The subflow's pre-pause scope is restored automatically by
993
- // footprintjs 4.17.0 via `checkpoint.subflowStates`, so
994
- // `scope.history` and `scope.pausedToolCallId` read back cleanly
995
- // across same-executor AND cross-executor resume.
996
- const toolCallId = scope.pausedToolCallId;
997
- const toolName = scope.pausedToolName;
998
- const startMs = scope.pausedToolStartMs;
999
- const resultStr = typeof input === 'string' ? input : safeStringify(input);
1000
- const newHistory = [
1001
- ...scope.history,
1002
- {
1003
- role: 'tool',
1004
- content: resultStr,
1005
- toolCallId,
1006
- toolName,
1007
- },
1008
- ];
1009
- scope.history = newHistory;
1010
- typedEmit(scope, 'agentfootprint.stream.tool_end', {
1011
- toolCallId,
1012
- result: input,
1013
- durationMs: Date.now() - startMs,
1014
- });
1015
- const iteration = scope.iteration;
1016
- typedEmit(scope, 'agentfootprint.agent.iteration_end', {
1017
- turnIndex: 0,
1018
- iterIndex: iteration,
1019
- toolCallCount: 1,
1020
- history: scope.history,
1021
- });
1022
- scope.iteration = iteration + 1;
1023
- // Clear pause checkpoint fields.
1024
- scope.pausedToolCallId = '';
1025
- scope.pausedToolName = '';
1026
- scope.pausedToolStartMs = 0;
1027
- },
1028
- };
1029
- // Final branch is split so memory-write subflows can mount BETWEEN
1030
- // setting `finalContent` and breaking the ReAct loop. PrepareFinal
1031
- // captures the turn payload; BreakFinal terminates the loop.
1032
- const prepareFinalStage = (scope) => {
1033
- const iteration = scope.iteration;
1034
- scope.finalContent = scope.llmLatestContent;
1035
- // The turn payload memory writes persist: the user's message
1036
- // paired with the agent's final answer.
1037
- scope.newMessages = [
1038
- { role: 'user', content: scope.userMessage },
1039
- { role: 'assistant', content: scope.finalContent },
1040
- ];
1041
- typedEmit(scope, 'agentfootprint.agent.iteration_end', {
1042
- turnIndex: 0,
1043
- iterIndex: iteration,
1044
- toolCallCount: 0,
1045
- });
1046
- typedEmit(scope, 'agentfootprint.agent.turn_end', {
1047
- turnIndex: 0,
1048
- finalContent: scope.finalContent,
1049
- totalInputTokens: scope.totalInputTokens,
1050
- totalOutputTokens: scope.totalOutputTokens,
1051
- iterationCount: iteration,
1052
- durationMs: Date.now() - scope.turnStartMs,
1053
- });
1054
- };
1055
- const breakFinalStage = (scope) => {
1056
- // $break terminates the flow before loopTo fires, ending the
1057
- // ReAct iteration once memory writes (if any) have persisted.
1058
- scope.$break();
1059
- return scope.finalContent;
1060
- };
1061
- // Compose the final branch as its own subflow so memory write
1062
- // subflows mount as visible siblings in narrative + Lens.
1063
- let finalBranchBuilder = flowChart('PrepareFinal', prepareFinalStage, 'prepare-final', undefined, 'Capture turn payload (finalContent + newMessages)');
1064
- for (const m of this.memories) {
1065
- if (m.write) {
1066
- finalBranchBuilder = mountMemoryWrite(finalBranchBuilder, {
1067
- pipeline: {
1068
- read: unwrapMemoryFlowChart(m.read),
1069
- write: unwrapMemoryFlowChart(m.write),
1070
- },
1071
- identityKey: 'runIdentity',
1072
- turnNumberKey: 'turnNumber',
1073
- contextTokensKey: 'contextTokensRemaining',
1074
- newMessagesKey: 'newMessages',
1075
- writeSubflowId: `sf-memory-write-${m.id}`,
1076
- });
1077
- }
1078
- }
1079
- const finalBranchChart = finalBranchBuilder
1080
- .addFunction('BreakFinal', breakFinalStage, 'break-final', 'Terminate the ReAct loop')
1081
- .build();
1082
- // Description prefix `Agent:` is a taxonomy marker — consumers
1083
- // (Lens + FlowchartRecorder) detect Agent-primitive subflows via
1084
- // this prefix and flag them as true agent boundaries (separate
1085
- // from LLMCall subflows which use `LLMCall:` prefix).
1086
- let builder = flowChart('Seed', seed, STAGE_IDS.SEED, undefined, 'Agent: ReAct loop');
1087
- // Memory READ subflows — mounted between Seed and InjectionEngine
1088
- // for TURN_START timing (default). Each memory writes to its own
1089
- // scope key (`memoryInjection_${id}`) so multiple `.memory()`
1090
- // registrations layer without colliding.
1091
- for (const m of this.memories) {
1092
- builder = mountMemoryRead(builder, {
1093
- pipeline: {
1094
- read: unwrapMemoryFlowChart(m.read),
1095
- ...(m.write !== undefined && { write: unwrapMemoryFlowChart(m.write) }),
1096
- },
1097
- identityKey: 'runIdentity',
1098
- turnNumberKey: 'turnNumber',
1099
- contextTokensKey: 'contextTokensRemaining',
1100
- injectionKey: memoryInjectionKey(m.id),
1101
- readSubflowId: `sf-memory-read-${m.id}`,
1102
- });
1103
- }
1104
- builder = builder
1105
- // Injection Engine — evaluates every Injection's trigger once
1106
- // per iteration; writes activeInjections[] to parent scope for
1107
- // the slot subflows to consume. Skipped if no injections were
1108
- // registered (no observable difference, just one more no-op
1109
- // subflow boundary).
1110
- .addSubFlowChartNext(SUBFLOW_IDS.INJECTION_ENGINE, injectionEngineSubflow, 'Injection Engine', {
1111
- inputMapper: (parent) => ({
1112
- iteration: parent.iteration,
1113
- userMessage: parent.userMessage,
1114
- history: parent.history,
1115
- lastToolResult: parent.lastToolResult,
1116
- activatedInjectionIds: parent.activatedInjectionIds ?? [],
1117
- }),
1118
- outputMapper: (sf) => ({ activeInjections: sf.activeInjections }),
1119
- // CRITICAL: footprintjs's default `applyOutputMapping`
1120
- // CONCATENATES arrays from subflow output with the parent's
1121
- // existing array values. Without `Replace`, the parent's
1122
- // `activeInjections` from iter N gets CONCATENATED with the
1123
- // subflow's iter N+1 fresh evaluation — producing
1124
- // 8 → 16 → 24 → 32 cumulative injections per turn instead of
1125
- // the intended ~8-per-iter.
1126
- //
1127
- // The slot subflows below (SystemPrompt, Messages, Tools) all
1128
- // read `activeInjections` and render every entry, so without
1129
- // Replace the system prompt grows linearly with iteration
1130
- // count. This was the root-cause of Dynamic-mode costing
1131
- // ~2x more input tokens than Classic in the v2.5.0 Neo
1132
- // benchmarks — the InjectionEngine's intended per-iter
1133
- // recomposition wasn't happening; it was per-iter ACCUMULATION.
1134
- arrayMerge: ArrayMergeMode.Replace,
1135
- })
1136
- .addSubFlowChartNext(SUBFLOW_IDS.SYSTEM_PROMPT, systemPromptSubflow, 'System Prompt', {
1137
- inputMapper: (parent) => ({
1138
- userMessage: parent.userMessage,
1139
- iteration: parent.iteration,
1140
- activeInjections: parent.activeInjections,
1141
- }),
1142
- outputMapper: (sf) => ({ systemPromptInjections: sf.systemPromptInjections }),
1143
- // See Tools-subflow comment below — same array-concat hazard.
1144
- // Without Replace, iter N+1's systemPromptInjections gets
1145
- // CONCATENATED with iter N's, multiplying the system prompt
1146
- // each iteration.
1147
- arrayMerge: ArrayMergeMode.Replace,
1148
- })
1149
- .addSubFlowChartNext(SUBFLOW_IDS.MESSAGES, messagesSubflow, 'Messages', {
1150
- inputMapper: (parent) => ({
1151
- messages: parent.history,
1152
- iteration: parent.iteration,
1153
- activeInjections: parent.activeInjections,
1154
- }),
1155
- outputMapper: (sf) => ({ messagesInjections: sf.messagesInjections }),
1156
- // Same array-concat hazard. messagesInjections is consumer-
1157
- // facing observability metadata (ContextRecorder, Lens) — must
1158
- // reflect THIS iteration's history, not be appended to last
1159
- // iteration's. CallLLM no longer reads this for the wire
1160
- // request (uses scope.history directly), so the LLM-protocol
1161
- // bug is fixed independently — but consumers of the
1162
- // messagesInjections stream still expect the per-iteration
1163
- // semantics.
1164
- arrayMerge: ArrayMergeMode.Replace,
1165
- })
1166
- .addSubFlowChartNext(SUBFLOW_IDS.TOOLS, toolsSubflow, 'Tools', {
1167
- inputMapper: (parent) => ({
1168
- iteration: parent.iteration,
1169
- activeInjections: parent.activeInjections,
1170
- // The slot subflow reads these to build the per-iteration
1171
- // ToolDispatchContext when an external `.toolProvider()` is
1172
- // configured. Without them the provider sees activeSkillId
1173
- // = undefined every iteration, breaking skillScopedTools etc.
1174
- activatedInjectionIds: parent.activatedInjectionIds,
1175
- runIdentity: parent.runIdentity,
1176
- }),
1177
- outputMapper: (sf) => ({
1178
- toolsInjections: sf.toolsInjections,
1179
- // Pass merged tool schemas (registry + injection-supplied)
1180
- // back up so callLLM uses the right list for THIS iteration.
1181
- dynamicToolSchemas: sf.toolSchemas,
1182
- }),
1183
- // CRITICAL: footprintjs's default `applyOutputMapping`
1184
- // CONCATENATES arrays from subflow output with the parent's
1185
- // existing array values. Without `Replace`, the parent's
1186
- // `dynamicToolSchemas` (carrying the iter N value) gets
1187
- // concatenated with the slot's iter N+1 deduped list,
1188
- // re-introducing duplicate tool names that Anthropic's API
1189
- // rejects with "tools: Tool names must be unique." The slot's
1190
- // toolSchemas IS the authoritative list — replace, don't
1191
- // concatenate.
1192
- arrayMerge: ArrayMergeMode.Replace,
1193
- })
1194
- // ── Cache layer (v2.6) ─────────────────────────────────────
1195
- // CacheDecision subflow walks `activeInjections` + evaluates
1196
- // each `cache:` directive, emits provider-agnostic
1197
- // `CacheMarker[]` to scope. Pure transform; no IO.
1198
- //
1199
- // CRITICAL: arrayMerge: ArrayMergeMode.Replace — same lesson
1200
- // as the v2.5.1 InjectionEngine fix. The default footprintjs
1201
- // behavior CONCATENATES arrays from child to parent;
1202
- // `cacheMarkers` MUST replace each iteration, not accumulate.
1203
- .addSubFlowChartNext(SUBFLOW_IDS.CACHE_DECISION, cacheDecisionSubflow, 'CacheDecision', {
1204
- inputMapper: (parent) => ({
1205
- activeInjections: parent.activeInjections ?? [],
1206
- iteration: parent.iteration ?? 1,
1207
- maxIterations: parent.maxIterations ?? maxIterations,
1208
- userMessage: parent.userMessage ?? '',
1209
- ...(parent.lastToolResult !== undefined && {
1210
- lastToolName: parent.lastToolResult?.toolName,
1211
- }),
1212
- cumulativeInputTokens: parent.totalInputTokens ?? 0,
1213
- systemPromptCachePolicy,
1214
- cachingDisabled: parent.cachingDisabled ?? false,
1215
- }),
1216
- outputMapper: (sf) => ({ cacheMarkers: sf.cacheMarkers }),
1217
- arrayMerge: ArrayMergeMode.Replace,
1218
- })
1219
- .addFunction('UpdateSkillHistory', updateSkillHistoryStage, STAGE_IDS.UPDATE_SKILL_HISTORY, 'Update skill-history rolling window for CacheGate churn detection')
1220
- .addDeciderFunction('CacheGate', cacheGateDecide, STAGE_IDS.CACHE_GATE, 'Gate cache-marker application: kill switch / hit-rate / skill-churn')
1221
- .addFunctionBranch(STAGE_IDS.APPLY_MARKERS, 'ApplyMarkers',
1222
- // Pass-through stage — markers stay in scope as-is.
1223
- // BuildLLMRequest (Phase 7+) reads them on the next stage.
1224
- () => undefined, 'Proceed with cache markers from CacheDecision')
1225
- .addFunctionBranch(STAGE_IDS.SKIP_CACHING, 'SkipCaching',
1226
- // Clear markers so BuildLLMRequest sees an empty list and
1227
- // makes the request unmodified.
1228
- (scope) => {
1229
- scope.cacheMarkers = [];
1230
- }, 'Skip caching this iteration')
1231
- .end()
1232
- .addFunction('IterationStart', iterationStart, 'iteration-start', 'Iteration begin marker')
1233
- .addFunction('CallLLM', callLLM, STAGE_IDS.CALL_LLM, 'LLM invocation')
1234
- .addDeciderFunction('Route', routeDecider, SUBFLOW_IDS.ROUTE, 'ReAct routing')
1235
- .addPausableFunctionBranch('tool-calls', 'ToolCalls', toolCallsHandler, 'Tool execution (pausable via pauseHere)')
1236
- .addSubFlowChartBranch('final', finalBranchChart, 'Final', {
1237
- // Pass through the read-only state the sub-chart needs;
1238
- // OMIT keys the sub-chart writes (finalContent, newMessages)
1239
- // — passing those via inputMapper would freeze them as args.
1240
- inputMapper: (parent) => {
1241
- const { finalContent: _f, newMessages: _nm, ...rest } = parent;
1242
- void _f;
1243
- void _nm;
1244
- return rest;
542
+ // iterationStart extracted to ./agent/stages/iterationStart.ts (v2.11.2).
543
+ const iterationStart = iterationStartStage;
544
+ // callLLM extracted to ./agent/stages/callLLM.ts (v2.11.2). Same
545
+ // late-binding pattern as seed for toolSchemas (computed below).
546
+ const callLLM = buildCallLLMStage({
547
+ provider,
548
+ model,
549
+ ...(temperature !== undefined && { temperature }),
550
+ ...(maxTokens !== undefined && { maxTokens }),
551
+ ...(pricingTable !== undefined && { pricingTable }),
552
+ ...(costBudget !== undefined && { costBudget }),
553
+ maxIterations,
554
+ cacheStrategy,
555
+ get toolSchemas() {
556
+ return toolSchemasResolved;
1245
557
  },
1246
- outputMapper: (sf) => ({
1247
- finalContent: sf.finalContent,
1248
- }),
1249
- // BreakFinal's $break() must reach the outer loopTo so the
1250
- // ReAct iteration terminates; without this the inner break
1251
- // only exits the sub-chart and the outer loop continues.
1252
- propagateBreak: true,
1253
- })
1254
- .setDefault('final')
1255
- .end()
1256
- // Dynamic ReAct: loop back to the InjectionEngine so EVERY iteration
1257
- // re-evaluates triggers (rule predicates, on-tool-return, llm-activated)
1258
- // against the freshest context (the just-appended tool result).
1259
- // Without this, the InjectionEngine runs ONCE per turn and:
1260
- // - on-tool-return predicates never fire on iter 2+
1261
- // - read_skill('X') activations are never picked up next iteration
1262
- // - autoActivate per-skill tool gating is structurally impossible
1263
- // - tools / system-prompt slots stay frozen at iter 1 content
1264
- // The v2.4 default of loopTo(MESSAGES) bypassed all four — quietly
1265
- // breaking the framework's "Dynamic ReAct" claim. v2.5 restores the
1266
- // v1 behavior that documents promise.
1267
- .loopTo(SUBFLOW_IDS.INJECTION_ENGINE);
1268
- return builder.build();
1269
- }
1270
- }
1271
- /**
1272
- * Fluent builder. `tool()` accepts any Tool<TArgs, TResult> and registers
1273
- * it by its schema.name. Duplicate names throw at build time.
1274
- */
1275
- export class AgentBuilder {
1276
- opts;
1277
- systemPromptValue = '';
1278
- /**
1279
- * Cache policy for the base system prompt. Set via the optional
1280
- * 2nd argument to `.system(text, { cache })`. Default `'always'` —
1281
- * the base prompt is stable per-turn and an ideal cache anchor.
1282
- */
1283
- systemPromptCachePolicy = 'always';
1284
- /**
1285
- * Global cache kill switch. Set via `Agent.create({ caching: 'off' })`
1286
- * (handled in `AgentOptions` propagation). Defaults to `false`
1287
- * (caching enabled). When `true`, the CacheGate decider routes to
1288
- * `'no-markers'` every iteration regardless of other rules.
1289
- */
1290
- cachingDisabledValue = false;
1291
- /**
1292
- * Optional explicit CacheStrategy override. Default: undefined,
1293
- * which means the agent auto-resolves from
1294
- * `getDefaultCacheStrategy(provider.name)` at construction. Power
1295
- * users override here for custom backends or test mocks.
1296
- */
1297
- cacheStrategyOverride;
1298
- registry = [];
1299
- injectionList = [];
1300
- memoryList = [];
1301
- /**
1302
- * Optional terminal contract — see `outputSchema()`. Stored on the
1303
- * builder, propagated to the Agent at `.build()` time.
1304
- */
1305
- outputSchemaParser;
1306
- /** 3-tier output fallback chain — set via `.outputFallback({...})`.
1307
- * Optional; absent = current throw-on-validation-failure behavior. */
1308
- outputFallbackCfg;
1309
- /**
1310
- * Optional `ToolProvider` set via `.toolProvider()`. Propagated to
1311
- * the Agent's Tools slot subflow + tool-call dispatcher; consulted
1312
- * per iteration so dynamic chains (`gatedTools`, `skillScopedTools`)
1313
- * react to current activation state.
1314
- */
1315
- toolProviderRef;
1316
- /**
1317
- * Optional override for `AgentOptions.maxIterations`. When set via
1318
- * the `.maxIterations()` builder method, takes precedence over the
1319
- * value passed to `Agent.create({ maxIterations })`.
1320
- */
1321
- maxIterationsOverride;
1322
- /**
1323
- * Recorders collected via `.recorder()`. Attached to the built Agent
1324
- * before `build()` returns (each via `agent.attach(rec)`).
1325
- */
1326
- recorderList = [];
1327
- // Voice config — defaults until the consumer calls .appName() /
1328
- // .commentaryTemplates() / .thinkingTemplates(). Stored as plain
1329
- // dicts (Record<string, string>) so the builder doesn't depend on
1330
- // the template-engine modules at compile time; the runtime types
1331
- // come from the agentfootprint barrel exports.
1332
- appNameValue = 'Chatbot';
1333
- commentaryOverrides = {};
1334
- thinkingOverrides = {};
1335
- constructor(opts) {
1336
- this.opts = opts;
1337
- // Cache layer: opts.caching === 'off' propagates to scope's
1338
- // `cachingDisabled` kill switch read by CacheGate. opts.cacheStrategy
1339
- // overrides the registry-resolved default.
1340
- if (opts.caching === 'off')
1341
- this.cachingDisabledValue = true;
1342
- if (opts.cacheStrategy !== undefined)
1343
- this.cacheStrategyOverride = opts.cacheStrategy;
1344
- }
1345
- /**
1346
- * Set the base system prompt.
1347
- *
1348
- * @param prompt - The system prompt text. Stable per-turn.
1349
- * @param options - Optional config. `cache` controls how the
1350
- * CacheDecision subflow treats this prompt block:
1351
- * - `'always'` (default) — cache the base prompt as a stable
1352
- * prefix anchor. Highest cache-hit rate; recommended for
1353
- * production agents whose system prompt rarely changes.
1354
- * - `'never'` — skip caching. Use if the prompt contains volatile
1355
- * content (timestamps, per-request user IDs).
1356
- * - `'while-active'` — semantically equivalent to `'always'` for
1357
- * the base prompt (it's always active by definition).
1358
- * - `{ until }` — conditional invalidation (e.g., flush after iter 5).
1359
- */
1360
- system(prompt, options) {
1361
- this.systemPromptValue = prompt;
1362
- if (options?.cache !== undefined) {
1363
- this.systemPromptCachePolicy = options.cache;
1364
- }
1365
- return this;
1366
- }
1367
- tool(tool) {
1368
- const name = tool.schema.name;
1369
- if (this.registry.some((e) => e.name === name)) {
1370
- throw new Error(`Agent.tool(): duplicate tool name '${name}'`);
1371
- }
1372
- this.registry.push({ name, tool: tool });
1373
- return this;
1374
- }
1375
- /**
1376
- * Register many tools at once. Convenience for tool sources that
1377
- * return a list (e.g., `await mcpClient(...).tools()`). Each tool
1378
- * is registered via `.tool()` so duplicate-name validation still
1379
- * fires per-entry.
1380
- */
1381
- tools(tools) {
1382
- for (const t of tools)
1383
- this.tool(t);
1384
- return this;
1385
- }
1386
- /**
1387
- * Wire a chainable `ToolProvider` (from `agentfootprint/tool-providers`)
1388
- * as the agent's per-iteration tool source.
1389
- *
1390
- * The provider is consulted EVERY iteration via `provider.list(ctx)`
1391
- * with `ctx = { iteration, activeSkillId, identity }`. Tools the
1392
- * provider emits flow into the Tools slot alongside any static
1393
- * tools registered via `.tool()` / `.tools()`. The tool-call
1394
- * dispatcher also consults the provider so dynamic chains
1395
- * (`gatedTools`, `skillScopedTools`) dispatch correctly when their
1396
- * visible-set changes mid-turn.
1397
- *
1398
- * Throws if called more than once on the same builder (avoids
1399
- * silent override surprises).
1400
- *
1401
- * @example Permission-gated baseline
1402
- * import { gatedTools, staticTools } from 'agentfootprint/tool-providers';
1403
- * import { PermissionPolicy } from 'agentfootprint/security';
1404
- *
1405
- * const policy = PermissionPolicy.fromRoles({
1406
- * readonly: ['lookup', 'list_skills', 'read_skill'],
1407
- * admin: ['lookup', 'list_skills', 'read_skill', 'delete'],
1408
- * }, 'readonly');
1409
- *
1410
- * const provider = gatedTools(
1411
- * staticTools(allTools),
1412
- * (toolName) => policy.isAllowed(toolName),
1413
- * );
1414
- *
1415
- * const agent = Agent.create({ provider: llm, model })
1416
- * .system('You answer.')
1417
- * .toolProvider(provider)
1418
- * .build();
1419
- */
1420
- toolProvider(provider) {
1421
- if (this.toolProviderRef) {
1422
- throw new Error('AgentBuilder.toolProvider: already set. Each agent has at most one external ToolProvider.');
1423
- }
1424
- this.toolProviderRef = provider;
1425
- return this;
1426
- }
1427
- /**
1428
- * Override the ReAct iteration cap set via `Agent.create({
1429
- * maxIterations })`. Convenience for builder-style code that prefers
1430
- * fluent setters over constructor opts. Last call wins.
1431
- *
1432
- * Throws if `n` is not a positive integer or exceeds the hard cap
1433
- * (`clampIterations`'s upper bound).
1434
- */
1435
- maxIterations(n) {
1436
- if (!Number.isInteger(n) || n <= 0) {
1437
- throw new Error(`AgentBuilder.maxIterations: expected a positive integer, got ${n}.`);
1438
- }
1439
- this.maxIterationsOverride = n;
1440
- return this;
1441
- }
1442
- /**
1443
- * Attach a footprintjs `CombinedRecorder` to the built Agent. Wired
1444
- * via `agent.attach(rec)` immediately after construction, so the
1445
- * recorder sees every event from the very first run.
1446
- *
1447
- * Equivalent to calling `agent.attach(rec)` post-build; the builder
1448
- * method is a convenience for codebases that prefer fully-fluent
1449
- * agent assembly. Multiple recorders are supported (each gets its
1450
- * own `attach()` call).
1451
- */
1452
- recorder(rec) {
1453
- this.recorderList.push(rec);
1454
- return this;
1455
- }
1456
- /**
1457
- * Set the agent's display name — substituted as `{{appName}}` in
1458
- * commentary + thinking templates. Same place to brand a tenant
1459
- * ("Acme Bot"), distinguish multi-agent roles ("Triage" vs
1460
- * "Reviewer"), or localize ("Asistente"). Default: `'Chatbot'`.
1461
- */
1462
- appName(name) {
1463
- this.appNameValue = name;
1464
- return this;
1465
- }
1466
- /**
1467
- * Override agentfootprint's bundled commentary templates. Spread on
1468
- * top of `defaultCommentaryTemplates`; missing keys fall back. Same
1469
- * `Record<string, string>` shape with `{{vars}}` substitution as
1470
- * the bundled defaults — see `defaultCommentaryTemplates` for the
1471
- * full key list.
1472
- *
1473
- * Use cases: i18n (`'agent.turn_start': 'El usuario...'`), brand
1474
- * voice ("You: {{userPrompt}}"), per-tenant customization.
1475
- */
1476
- commentaryTemplates(templates) {
1477
- this.commentaryOverrides = { ...this.commentaryOverrides, ...templates };
1478
- return this;
1479
- }
1480
- /**
1481
- * Override agentfootprint's bundled thinking templates. Same
1482
- * contract shape as commentary; different vocabulary — first-person
1483
- * status the chat bubble shows mid-call. Per-tool overrides go via
1484
- * `tool.<toolName>` keys (e.g., `'tool.weather': 'Looking up the
1485
- * weather…'`). See `defaultThinkingTemplates` for the full key list.
1486
- */
1487
- thinkingTemplates(templates) {
1488
- this.thinkingOverrides = { ...this.thinkingOverrides, ...templates };
1489
- return this;
1490
- }
1491
- // ─── Injection sugar — context engineering surface ───────────
1492
- //
1493
- // ALL of these push into the same `injectionList`. The Injection
1494
- // primitive is identical across flavors; the methods are just
1495
- // narrative-friendly aliases. Duplicate ids throw at build time.
1496
- /**
1497
- * Register any `Injection`. Use this for power-user / custom flavors;
1498
- * for built-in flavors use the typed sugar (`.skill`, `.steering`,
1499
- * `.instruction`, `.fact`).
1500
- */
1501
- injection(injection) {
1502
- if (this.injectionList.some((i) => i.id === injection.id)) {
1503
- throw new Error(`Agent.injection(): duplicate id '${injection.id}'`);
1504
- }
1505
- this.injectionList.push(injection);
1506
- return this;
1507
- }
1508
- /**
1509
- * Register a Skill — LLM-activated, system-prompt + tools.
1510
- * Auto-attaches the `read_skill` activation tool to the agent.
1511
- * Skill stays active for the rest of the turn once activated.
1512
- */
1513
- skill(injection) {
1514
- return this.injection(injection);
1515
- }
1516
- /**
1517
- * Bulk-register every Skill in a `SkillRegistry`. Use for shared
1518
- * skill catalogs across multiple Agents — register skills once on
1519
- * the registry; attach the same registry to every consumer Agent.
1520
- *
1521
- * @example
1522
- * const registry = new SkillRegistry();
1523
- * registry.register(billingSkill).register(refundSkill);
1524
- * const supportAgent = Agent.create({ provider }).skills(registry).build();
1525
- * const escalationAgent = Agent.create({ provider }).skills(registry).build();
1526
- */
1527
- skills(registry) {
1528
- for (const skill of registry.list())
1529
- this.injection(skill);
1530
- return this;
1531
- }
1532
- /**
1533
- * Register a Steering doc — always-on system-prompt rule.
1534
- * Use for invariant guidance: output format, persona, safety policies.
1535
- */
1536
- steering(injection) {
1537
- return this.injection(injection);
1538
- }
1539
- /**
1540
- * Register an Instruction — rule-based system-prompt guidance.
1541
- * Predicate runs each iteration. Use for context-dependent rules
1542
- * including the "Dynamic ReAct" `on-tool-return` pattern.
1543
- */
1544
- instruction(injection) {
1545
- return this.injection(injection);
1546
- }
1547
- /**
1548
- * Bulk-register many instructions at once. Convenience for consumer
1549
- * code that organizes its instruction set in a flat array (`const
1550
- * instructions = [outputFormat, dataRouting, ...]`). Each element
1551
- * is registered via `.instruction()` so duplicate-id checks still
1552
- * fire per-entry.
1553
- */
1554
- instructions(injections) {
1555
- for (const i of injections)
1556
- this.instruction(i);
1557
- return this;
1558
- }
1559
- /**
1560
- * Register a Fact — developer-supplied data the LLM should see.
1561
- * User profile, env info, computed summary, current time, …
1562
- * Distinct from Skills (LLM-activated guidance) and Steering
1563
- * (always-on rules) in INTENT — the engine treats them all alike.
1564
- */
1565
- fact(injection) {
1566
- return this.injection(injection);
1567
- }
1568
- /**
1569
- * Register a Memory subsystem — load/persist conversation context,
1570
- * facts, narrative beats, or causal snapshots across runs.
1571
- *
1572
- * The `MemoryDefinition` is produced by `defineMemory({ type, strategy,
1573
- * store })`. Multiple memories layer cleanly via per-id scope keys
1574
- * (`memoryInjection_${id}`):
1575
- *
1576
- * ```ts
1577
- * Agent.create({ provider })
1578
- * .memory(defineMemory({ id: 'short', type: MEMORY_TYPES.EPISODIC,
1579
- * strategy: { kind: MEMORY_STRATEGIES.WINDOW, size: 10 },
1580
- * store }))
1581
- * .memory(defineMemory({ id: 'facts', type: MEMORY_TYPES.SEMANTIC,
1582
- * strategy: { kind: MEMORY_STRATEGIES.EXTRACT,
1583
- * extractor: 'pattern' }, store }))
1584
- * .build();
1585
- * ```
1586
- *
1587
- * The READ subflow runs at the configured `timing` (default
1588
- * `MEMORY_TIMING.TURN_START`) and writes its formatted output to the
1589
- * `memoryInjection_${id}` scope key for the slot subflows to consume.
1590
- */
1591
- memory(definition) {
1592
- if (this.memoryList.some((m) => m.id === definition.id)) {
1593
- throw new Error(`Agent.memory(): duplicate id '${definition.id}' — each memory needs a unique id ` +
1594
- 'to keep its scope key (`memoryInjection_${id}`) collision-free.');
1595
- }
1596
- this.memoryList.push(definition);
1597
- return this;
1598
- }
1599
- /**
1600
- * Register a RAG retriever — semantic search over a vector-indexed
1601
- * corpus. Identical plumbing to `.memory()` (RAG resolves to a
1602
- * `MemoryDefinition` produced by `defineRAG()`); this alias exists
1603
- * so the consumer's intent reads clearly:
1604
- *
1605
- * ```ts
1606
- * agent
1607
- * .memory(shortTermConversation) // remembers what the USER said
1608
- * .rag(productDocs) // retrieves what the CORPUS says
1609
- * .build();
1610
- * ```
1611
- *
1612
- * Both end up as memory subflows, but the alias separates "user
1613
- * conversation memory" from "document corpus retrieval" in code
1614
- * intent, ids, and Lens chips.
1615
- */
1616
- rag(definition) {
1617
- return this.memory(definition);
1618
- }
1619
- /**
1620
- * Declarative terminal contract. The agent's final answer must be
1621
- * JSON matching `parser`. Auto-injects a system-prompt instruction
1622
- * telling the LLM the shape, and exposes `agent.runTyped()` /
1623
- * `agent.parseOutput()` for parse + validate at the call site.
1624
- *
1625
- * The `parser` is duck-typed: any object with a `parse(unknown): T`
1626
- * method works (Zod, Valibot, ArkType, hand-written). The optional
1627
- * `description` field on the parser drives the auto-generated
1628
- * instruction; consumers can also override via `opts.instruction`.
1629
- *
1630
- * Throws if called more than once on the same builder (avoids
1631
- * silent override surprises).
1632
- *
1633
- * @param parser Validation strategy that throws on shape failure.
1634
- * @param opts Optional `{ name, instruction }` to customize.
1635
- *
1636
- * @example
1637
- * import { z } from 'zod';
1638
- * const Output = z.object({
1639
- * status: z.enum(['ok', 'err']),
1640
- * items: z.array(z.string()),
1641
- * }).describe('A status enum + an array of strings.');
1642
- *
1643
- * const agent = Agent.create({...})
1644
- * .outputSchema(Output)
1645
- * .build();
1646
- *
1647
- * const typed = await agent.runTyped({ message: '...' });
1648
- * typed.status; // narrowed to 'ok' | 'err'
1649
- */
1650
- outputSchema(parser, opts) {
1651
- if (this.outputSchemaParser) {
1652
- throw new Error('AgentBuilder.outputSchema: already set. Each agent has at most one terminal contract.');
1653
- }
1654
- this.outputSchemaParser = parser;
1655
- const instructionText = opts?.instruction ?? buildDefaultInstruction(parser);
1656
- const id = opts?.name ?? 'output-schema';
1657
- // Always-on system-slot instruction. Activates every iteration so
1658
- // long runs keep the contract present (recency-first redundancy).
1659
- this.injectionList.push(defineInstruction({
1660
- id,
1661
- activeWhen: () => true,
1662
- prompt: instructionText,
1663
- }));
1664
- return this;
1665
- }
1666
- /**
1667
- * 3-tier degradation for output-schema validation failures. Pairs
1668
- * with `.outputSchema()` — calling `.outputFallback()` without an
1669
- * `outputSchema` first throws (the fallback has nothing to validate).
1670
- *
1671
- * Three tiers:
1672
- *
1673
- * 1. **Primary** — LLM emitted schema-valid JSON. Caller gets it.
1674
- * 2. **Fallback** — `OutputSchemaError` thrown. The async
1675
- * `fallback(error, raw)` runs; its return is re-validated.
1676
- * 3. **Canned** — static safety-net value. NEVER throws when set.
1677
- *
1678
- * `canned` is validated against the schema at builder time —
1679
- * fail-fast on misconfig (a `canned` that doesn't validate would
1680
- * defeat the fail-open guarantee).
1681
- *
1682
- * Two typed events fire on tier transitions for observability:
1683
- * - `agentfootprint.resilience.output_fallback_triggered`
1684
- * - `agentfootprint.resilience.output_canned_used`
1685
- *
1686
- * @example
1687
- * ```ts
1688
- * import { z } from 'zod';
1689
- * const Refund = z.object({ amount: z.number(), reason: z.string() });
1690
- *
1691
- * const agent = Agent.create({...})
1692
- * .outputSchema(Refund)
1693
- * .outputFallback({
1694
- * fallback: async (err, raw) => ({ amount: 0, reason: 'manual review' }),
1695
- * canned: { amount: 0, reason: 'unable to process' },
1696
- * })
1697
- * .build();
1698
- * ```
1699
- */
1700
- outputFallback(options) {
1701
- if (!this.outputSchemaParser) {
1702
- throw new Error('AgentBuilder.outputFallback: call .outputSchema(parser) FIRST. ' +
1703
- 'outputFallback supplements outputSchema; one without the other is incoherent.');
1704
- }
1705
- if (this.outputFallbackCfg) {
1706
- throw new Error('AgentBuilder.outputFallback: already set. Each agent has at most one fallback chain.');
1707
- }
1708
- // Build-time validation — canned MUST satisfy the schema.
1709
- if (options.canned !== undefined) {
1710
- validateCannedAgainstSchema(options.canned, this.outputSchemaParser);
1711
- }
1712
- this.outputFallbackCfg = {
1713
- fallback: options.fallback,
1714
- ...(options.canned !== undefined && { canned: options.canned }),
1715
- hasCanned: options.canned !== undefined,
1716
- };
1717
- return this;
1718
- }
1719
- build() {
1720
- // Resolve the voice config: bundled defaults + consumer overrides.
1721
- // Templates flow through the same barrel exports the rest of the
1722
- // library uses, so a future locale-pack swap is a single import.
1723
- const voice = {
1724
- appName: this.appNameValue,
1725
- commentaryTemplates: { ...defaultCommentaryTemplates, ...this.commentaryOverrides },
1726
- thinkingTemplates: { ...defaultThinkingTemplates, ...this.thinkingOverrides },
1727
- };
1728
- const opts = this.maxIterationsOverride !== undefined
1729
- ? { ...this.opts, maxIterations: this.maxIterationsOverride }
1730
- : this.opts;
1731
- const agent = new Agent(opts, this.systemPromptValue, this.registry, voice, this.injectionList, this.memoryList, this.outputSchemaParser, this.toolProviderRef, this.systemPromptCachePolicy, this.cachingDisabledValue, this.cacheStrategyOverride, this.outputFallbackCfg);
1732
- // Attach builder-collected recorders so they receive events from
1733
- // the very first run. Mirrors what consumers would do post-build
1734
- // via `agent.attach(rec)`; the builder method is purely sugar.
1735
- for (const rec of this.recorderList) {
1736
- agent.attach(rec);
1737
- }
1738
- return agent;
1739
- }
1740
- }
1741
- function validateMemoryIdUniqueness(memories) {
1742
- const seen = new Set();
1743
- for (const m of memories) {
1744
- if (seen.has(m.id)) {
1745
- throw new Error(`Agent: duplicate memory id '${m.id}'. Each memory needs a unique id to keep ` +
1746
- 'its scope key (`memoryInjection_${id}`) collision-free.');
1747
- }
1748
- seen.add(m.id);
1749
- }
1750
- }
1751
- function clampIterations(n) {
1752
- if (!Number.isInteger(n) || n < 1)
1753
- return 1;
1754
- if (n > 50)
1755
- return 50;
1756
- return n;
1757
- }
1758
- /**
1759
- * Validate tool-name uniqueness across `.tool()`-registered tools +
1760
- * every Skill's `inject.tools[]`. The LLM dispatches by `tool.schema.name`
1761
- * (the wire format), so any collision silently shadows execution.
1762
- *
1763
- * Called eagerly in the Agent constructor so `Agent.build()` throws
1764
- * immediately, not on first `run()`.
1765
- *
1766
- * `read_skill` is reserved when ≥1 Skill is registered — collisions
1767
- * with consumer tools throw.
1768
- */
1769
- function validateToolNameUniqueness(registry, injections) {
1770
- // Static registry: unique within itself. The Agent.tool() builder
1771
- // method already throws on per-call duplicates; this is the
1772
- // belt-and-suspenders check at build time.
1773
- const staticNames = new Set();
1774
- for (const entry of registry) {
1775
- if (staticNames.has(entry.name)) {
1776
- throw new Error(`Agent: duplicate tool name '${entry.name}' in .tool() registry. ` +
1777
- `Tool names must be unique within the static registry.`);
1778
- }
1779
- staticNames.add(entry.name);
1780
- }
1781
- // `read_skill` is reserved when any Skill is registered. Collisions
1782
- // with consumer-supplied tools break the auto-attach path.
1783
- const skills = injections.filter((i) => i.flavor === 'skill');
1784
- if (skills.length > 0 && staticNames.has('read_skill')) {
1785
- throw new Error(`Agent: tool name 'read_skill' is reserved when ≥1 Skill is registered. ` +
1786
- `Rename your custom 'read_skill' tool or unregister it.`);
1787
- }
1788
- // Per-skill check: a skill's `inject.tools` array must be internally
1789
- // unique (no duplicate names within the same skill — that's a
1790
- // skill authoring bug). Across skills, sharing a Tool reference is
1791
- // EXPECTED and supported — common tools (e.g., a `flogi_lookup`
1792
- // used by multiple investigation skills) appear in multiple skills'
1793
- // tool arrays. Only one skill is active at a time (or, when several
1794
- // are active, deduped by name + reference at runtime). Sharing the
1795
- // same Tool object across skills is the supported pattern; sharing
1796
- // a Tool NAME with a DIFFERENT execute function is the actual bug —
1797
- // we detect that here too.
1798
- const seenByName = new Map();
1799
- for (const skill of skills) {
1800
- const intraSkill = new Set();
1801
- for (const tool of skill.inject.tools ?? []) {
1802
- const name = tool.schema.name;
1803
- if (intraSkill.has(name)) {
1804
- throw new Error(`Agent: skill '${skill.id}' lists tool '${name}' more than once in its ` +
1805
- `inject.tools array. Each skill's tools must be unique within itself.`);
1806
- }
1807
- intraSkill.add(name);
1808
- // Skill tools collide with the static .tool() registry → ambiguous dispatch
1809
- if (staticNames.has(name)) {
1810
- throw new Error(`Agent: skill '${skill.id}' tool '${name}' collides with the static .tool() ` +
1811
- `registry. Either rename the skill's tool or remove the static registration.`);
1812
- }
1813
- // Same name across skills with DIFFERENT Tool objects = ambiguous when
1814
- // both skills active. Same name + SAME Tool reference = supported sharing.
1815
- const prior = seenByName.get(name);
1816
- if (prior && prior !== tool) {
1817
- throw new Error(`Agent: tool name '${name}' is declared by multiple skills with different ` +
1818
- `Tool implementations. Skills MAY share the SAME Tool reference across ` +
1819
- `their inject.tools arrays (deduped at dispatch); they may NOT register ` +
1820
- `different functions under the same name (ambiguous dispatch).`);
1821
- }
1822
- seenByName.set(name, tool);
1823
- }
1824
- }
1825
- }
1826
- /**
1827
- * JSON.stringify with circular-ref protection. Tool results are untrusted —
1828
- * a hostile/buggy tool returning a cyclic object must not crash the run.
1829
- * Falls back to '[unstringifiable: <reason>]' so the LLM still sees that
1830
- * the tool ran and produced something unusable.
1831
- */
1832
- function safeStringify(value) {
1833
- try {
1834
- return JSON.stringify(value);
1835
- }
1836
- catch (err) {
1837
- const reason = err instanceof Error ? err.message : String(err);
1838
- return `[unstringifiable: ${reason}]`;
558
+ });
559
+ // routeDecider extracted to ./agent/stages/route.ts (v2.11.2).
560
+ const routeDecider = routeDeciderStage;
561
+ // toolCallsHandler extracted to ./agent/stages/toolCalls.ts (v2.11.2).
562
+ const toolCallsHandler = buildToolCallsHandler({
563
+ registryByName,
564
+ ...(this.externalToolProvider && { externalToolProvider: this.externalToolProvider }),
565
+ ...(permissionChecker && { permissionChecker }),
566
+ });
567
+ // Chart composition extracted to ./agent/buildAgentChart.ts (v2.11.2).
568
+ return buildAgentChart({
569
+ memories: this.memories,
570
+ systemPromptCachePolicy,
571
+ maxIterations,
572
+ seed,
573
+ iterationStart,
574
+ callLLM,
575
+ routeDecider,
576
+ toolCallsHandler,
577
+ injectionEngineSubflow,
578
+ systemPromptSubflow,
579
+ messagesSubflow,
580
+ toolsSubflow,
581
+ cacheDecisionSubflow,
582
+ updateSkillHistoryStage,
583
+ cacheGateDecide,
584
+ });
1839
585
  }
1840
586
  }
587
+ // AgentBuilder extracted to ./agent/AgentBuilder.ts (v2.11.2).
588
+ // Re-export so the 28+ existing import sites continue to work unchanged.
589
+ // Validators + helpers extracted to ./agent/validators.ts (v2.11.1).
1841
590
  //# sourceMappingURL=Agent.js.map