agentfootprint 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/README.md +293 -247
  2. package/dist/adapters/llm/AnthropicProvider.js +2 -2
  3. package/dist/adapters/llm/AnthropicProvider.js.map +1 -1
  4. package/dist/adapters/llm/BedrockProvider.js +2 -2
  5. package/dist/adapters/llm/BedrockProvider.js.map +1 -1
  6. package/dist/adapters/llm/BrowserAnthropicProvider.js +96 -28
  7. package/dist/adapters/llm/BrowserAnthropicProvider.js.map +1 -1
  8. package/dist/adapters/llm/OpenAIProvider.js +2 -2
  9. package/dist/adapters/llm/OpenAIProvider.js.map +1 -1
  10. package/dist/adapters/memory/agentcore.js +9 -3
  11. package/dist/adapters/memory/agentcore.js.map +1 -1
  12. package/dist/adapters/memory/redis.js +9 -3
  13. package/dist/adapters/memory/redis.js.map +1 -1
  14. package/dist/core/Agent.js +493 -79
  15. package/dist/core/Agent.js.map +1 -1
  16. package/dist/core/flowchartAsTool.js +188 -0
  17. package/dist/core/flowchartAsTool.js.map +1 -0
  18. package/dist/core/outputSchema.js +119 -0
  19. package/dist/core/outputSchema.js.map +1 -0
  20. package/dist/core/slots/buildSystemPromptSlot.js +8 -0
  21. package/dist/core/slots/buildSystemPromptSlot.js.map +1 -1
  22. package/dist/core/slots/buildToolsSlot.js +56 -5
  23. package/dist/core/slots/buildToolsSlot.js.map +1 -1
  24. package/dist/esm/adapters/llm/AnthropicProvider.js +2 -2
  25. package/dist/esm/adapters/llm/AnthropicProvider.js.map +1 -1
  26. package/dist/esm/adapters/llm/BedrockProvider.js +2 -2
  27. package/dist/esm/adapters/llm/BedrockProvider.js.map +1 -1
  28. package/dist/esm/adapters/llm/BrowserAnthropicProvider.js +96 -28
  29. package/dist/esm/adapters/llm/BrowserAnthropicProvider.js.map +1 -1
  30. package/dist/esm/adapters/llm/OpenAIProvider.js +2 -2
  31. package/dist/esm/adapters/llm/OpenAIProvider.js.map +1 -1
  32. package/dist/esm/adapters/memory/agentcore.js +9 -3
  33. package/dist/esm/adapters/memory/agentcore.js.map +1 -1
  34. package/dist/esm/adapters/memory/redis.js +9 -3
  35. package/dist/esm/adapters/memory/redis.js.map +1 -1
  36. package/dist/esm/core/Agent.js +492 -78
  37. package/dist/esm/core/Agent.js.map +1 -1
  38. package/dist/esm/core/flowchartAsTool.js +184 -0
  39. package/dist/esm/core/flowchartAsTool.js.map +1 -0
  40. package/dist/esm/core/outputSchema.js +113 -0
  41. package/dist/esm/core/outputSchema.js.map +1 -0
  42. package/dist/esm/core/slots/buildSystemPromptSlot.js +8 -0
  43. package/dist/esm/core/slots/buildSystemPromptSlot.js.map +1 -1
  44. package/dist/esm/core/slots/buildToolsSlot.js +56 -5
  45. package/dist/esm/core/slots/buildToolsSlot.js.map +1 -1
  46. package/dist/esm/index.js +34 -5
  47. package/dist/esm/index.js.map +1 -1
  48. package/dist/esm/lib/injection-engine/SkillRegistry.js +181 -0
  49. package/dist/esm/lib/injection-engine/SkillRegistry.js.map +1 -0
  50. package/dist/esm/lib/injection-engine/factories/defineSkill.js +35 -0
  51. package/dist/esm/lib/injection-engine/factories/defineSkill.js.map +1 -1
  52. package/dist/esm/lib/injection-engine/index.js +9 -1
  53. package/dist/esm/lib/injection-engine/index.js.map +1 -1
  54. package/dist/esm/lib/injection-engine/skillTools.js +125 -0
  55. package/dist/esm/lib/injection-engine/skillTools.js.map +1 -0
  56. package/dist/esm/lib/injection-engine/types.js +8 -0
  57. package/dist/esm/lib/injection-engine/types.js.map +1 -1
  58. package/dist/esm/lib/lazyRequire.js +33 -0
  59. package/dist/esm/lib/lazyRequire.js.map +1 -0
  60. package/dist/esm/lib/mcp/mcpClient.js +4 -6
  61. package/dist/esm/lib/mcp/mcpClient.js.map +1 -1
  62. package/dist/esm/llm-providers.js +31 -0
  63. package/dist/esm/llm-providers.js.map +1 -0
  64. package/dist/esm/locales/index.js +144 -0
  65. package/dist/esm/locales/index.js.map +1 -0
  66. package/dist/esm/memory-providers.js +44 -0
  67. package/dist/esm/memory-providers.js.map +1 -0
  68. package/dist/esm/providers.js +11 -0
  69. package/dist/esm/providers.js.map +1 -1
  70. package/dist/esm/recorders/core/contextEngineering.js +155 -0
  71. package/dist/esm/recorders/core/contextEngineering.js.map +1 -0
  72. package/dist/esm/recorders/observability/commentary/commentaryTemplates.js +6 -1
  73. package/dist/esm/recorders/observability/commentary/commentaryTemplates.js.map +1 -1
  74. package/dist/esm/security/PermissionPolicy.js +135 -0
  75. package/dist/esm/security/PermissionPolicy.js.map +1 -0
  76. package/dist/esm/security/index.js +38 -0
  77. package/dist/esm/security/index.js.map +1 -0
  78. package/dist/esm/tool-providers/gatedTools.js +52 -0
  79. package/dist/esm/tool-providers/gatedTools.js.map +1 -0
  80. package/dist/esm/tool-providers/index.js +43 -0
  81. package/dist/esm/tool-providers/index.js.map +1 -0
  82. package/dist/esm/tool-providers/skillScopedTools.js +63 -0
  83. package/dist/esm/tool-providers/skillScopedTools.js.map +1 -0
  84. package/dist/esm/tool-providers/staticTools.js +33 -0
  85. package/dist/esm/tool-providers/staticTools.js.map +1 -0
  86. package/dist/esm/tool-providers/types.js +53 -0
  87. package/dist/esm/tool-providers/types.js.map +1 -0
  88. package/dist/index.js +57 -12
  89. package/dist/index.js.map +1 -1
  90. package/dist/lib/injection-engine/SkillRegistry.js +185 -0
  91. package/dist/lib/injection-engine/SkillRegistry.js.map +1 -0
  92. package/dist/lib/injection-engine/factories/defineSkill.js +37 -1
  93. package/dist/lib/injection-engine/factories/defineSkill.js.map +1 -1
  94. package/dist/lib/injection-engine/index.js +14 -1
  95. package/dist/lib/injection-engine/index.js.map +1 -1
  96. package/dist/lib/injection-engine/skillTools.js +130 -0
  97. package/dist/lib/injection-engine/skillTools.js.map +1 -0
  98. package/dist/lib/injection-engine/types.js +8 -0
  99. package/dist/lib/injection-engine/types.js.map +1 -1
  100. package/dist/lib/lazyRequire.js +37 -0
  101. package/dist/lib/lazyRequire.js.map +1 -0
  102. package/dist/lib/mcp/mcpClient.js +4 -6
  103. package/dist/lib/mcp/mcpClient.js.map +1 -1
  104. package/dist/llm-providers.js +47 -0
  105. package/dist/llm-providers.js.map +1 -0
  106. package/dist/locales/index.js +149 -0
  107. package/dist/locales/index.js.map +1 -0
  108. package/dist/memory-providers.js +49 -0
  109. package/dist/memory-providers.js.map +1 -0
  110. package/dist/providers.js +11 -0
  111. package/dist/providers.js.map +1 -1
  112. package/dist/recorders/core/contextEngineering.js +161 -0
  113. package/dist/recorders/core/contextEngineering.js.map +1 -0
  114. package/dist/recorders/observability/commentary/commentaryTemplates.js +6 -1
  115. package/dist/recorders/observability/commentary/commentaryTemplates.js.map +1 -1
  116. package/dist/security/PermissionPolicy.js +139 -0
  117. package/dist/security/PermissionPolicy.js.map +1 -0
  118. package/dist/security/index.js +42 -0
  119. package/dist/security/index.js.map +1 -0
  120. package/dist/tool-providers/gatedTools.js +56 -0
  121. package/dist/tool-providers/gatedTools.js.map +1 -0
  122. package/dist/tool-providers/index.js +51 -0
  123. package/dist/tool-providers/index.js.map +1 -0
  124. package/dist/tool-providers/skillScopedTools.js +67 -0
  125. package/dist/tool-providers/skillScopedTools.js.map +1 -0
  126. package/dist/tool-providers/staticTools.js +37 -0
  127. package/dist/tool-providers/staticTools.js.map +1 -0
  128. package/dist/tool-providers/types.js +54 -0
  129. package/dist/tool-providers/types.js.map +1 -0
  130. package/dist/types/adapters/llm/AnthropicProvider.d.ts.map +1 -1
  131. package/dist/types/adapters/llm/BedrockProvider.d.ts.map +1 -1
  132. package/dist/types/adapters/llm/BrowserAnthropicProvider.d.ts.map +1 -1
  133. package/dist/types/adapters/llm/OpenAIProvider.d.ts.map +1 -1
  134. package/dist/types/adapters/memory/agentcore.d.ts +7 -1
  135. package/dist/types/adapters/memory/agentcore.d.ts.map +1 -1
  136. package/dist/types/adapters/memory/redis.d.ts +7 -1
  137. package/dist/types/adapters/memory/redis.d.ts.map +1 -1
  138. package/dist/types/core/Agent.d.ts +216 -2
  139. package/dist/types/core/Agent.d.ts.map +1 -1
  140. package/dist/types/core/flowchartAsTool.d.ts +161 -0
  141. package/dist/types/core/flowchartAsTool.d.ts.map +1 -0
  142. package/dist/types/core/outputSchema.d.ts +128 -0
  143. package/dist/types/core/outputSchema.d.ts.map +1 -0
  144. package/dist/types/core/slots/buildSystemPromptSlot.d.ts.map +1 -1
  145. package/dist/types/core/slots/buildToolsSlot.d.ts +10 -0
  146. package/dist/types/core/slots/buildToolsSlot.d.ts.map +1 -1
  147. package/dist/types/index.d.ts +8 -4
  148. package/dist/types/index.d.ts.map +1 -1
  149. package/dist/types/lib/injection-engine/SkillRegistry.d.ts +148 -0
  150. package/dist/types/lib/injection-engine/SkillRegistry.d.ts.map +1 -0
  151. package/dist/types/lib/injection-engine/factories/defineSkill.d.ts +99 -0
  152. package/dist/types/lib/injection-engine/factories/defineSkill.d.ts.map +1 -1
  153. package/dist/types/lib/injection-engine/index.d.ts +5 -2
  154. package/dist/types/lib/injection-engine/index.d.ts.map +1 -1
  155. package/dist/types/lib/injection-engine/skillTools.d.ts +73 -0
  156. package/dist/types/lib/injection-engine/skillTools.d.ts.map +1 -0
  157. package/dist/types/lib/injection-engine/types.d.ts +28 -0
  158. package/dist/types/lib/injection-engine/types.d.ts.map +1 -1
  159. package/dist/types/lib/lazyRequire.d.ts +30 -0
  160. package/dist/types/lib/lazyRequire.d.ts.map +1 -0
  161. package/dist/types/lib/mcp/mcpClient.d.ts.map +1 -1
  162. package/dist/types/llm-providers.d.ts +31 -0
  163. package/dist/types/llm-providers.d.ts.map +1 -0
  164. package/dist/types/locales/index.d.ts +133 -0
  165. package/dist/types/locales/index.d.ts.map +1 -0
  166. package/dist/types/memory-providers.d.ts +41 -0
  167. package/dist/types/memory-providers.d.ts.map +1 -0
  168. package/dist/types/providers.d.ts +11 -0
  169. package/dist/types/providers.d.ts.map +1 -1
  170. package/dist/types/recorders/core/contextEngineering.d.ts +137 -0
  171. package/dist/types/recorders/core/contextEngineering.d.ts.map +1 -0
  172. package/dist/types/recorders/observability/commentary/commentaryTemplates.d.ts.map +1 -1
  173. package/dist/types/security/PermissionPolicy.d.ts +125 -0
  174. package/dist/types/security/PermissionPolicy.d.ts.map +1 -0
  175. package/dist/types/security/index.d.ts +40 -0
  176. package/dist/types/security/index.d.ts.map +1 -0
  177. package/dist/types/tool-providers/gatedTools.d.ts +37 -0
  178. package/dist/types/tool-providers/gatedTools.d.ts.map +1 -0
  179. package/dist/types/tool-providers/index.d.ts +42 -0
  180. package/dist/types/tool-providers/index.d.ts.map +1 -0
  181. package/dist/types/tool-providers/skillScopedTools.d.ts +46 -0
  182. package/dist/types/tool-providers/skillScopedTools.d.ts.map +1 -0
  183. package/dist/types/tool-providers/staticTools.d.ts +22 -0
  184. package/dist/types/tool-providers/staticTools.d.ts.map +1 -0
  185. package/dist/types/tool-providers/types.d.ts +103 -0
  186. package/dist/types/tool-providers/types.d.ts.map +1 -0
  187. package/package.json +62 -9
  188. package/README.proposed.md +0 -258
  189. package/dist/instructions.js +0 -21
  190. package/dist/instructions.js.map +0 -1
  191. package/dist/lib/instructions/defineInstruction.js +0 -35
  192. package/dist/lib/instructions/defineInstruction.js.map +0 -1
  193. package/dist/lib/instructions/evaluator.js +0 -38
  194. package/dist/lib/instructions/evaluator.js.map +0 -1
  195. package/dist/lib/instructions/index.js +0 -48
  196. package/dist/lib/instructions/index.js.map +0 -1
  197. package/dist/lib/instructions/types.js +0 -22
  198. package/dist/lib/instructions/types.js.map +0 -1
  199. package/dist/memory/conversationHelpers.js +0 -39
  200. package/dist/memory/conversationHelpers.js.map +0 -1
  201. package/dist/types/instructions.d.ts +0 -5
  202. package/dist/types/instructions.d.ts.map +0 -1
  203. package/dist/types/lib/instructions/defineInstruction.d.ts +0 -22
  204. package/dist/types/lib/instructions/defineInstruction.d.ts.map +0 -1
  205. package/dist/types/lib/instructions/evaluator.d.ts +0 -11
  206. package/dist/types/lib/instructions/evaluator.d.ts.map +0 -1
  207. package/dist/types/lib/instructions/index.d.ts +0 -44
  208. package/dist/types/lib/instructions/index.d.ts.map +0 -1
  209. package/dist/types/lib/instructions/types.d.ts +0 -100
  210. package/dist/types/lib/instructions/types.d.ts.map +0 -1
  211. package/dist/types/memory/conversationHelpers.d.ts +0 -19
  212. package/dist/types/memory/conversationHelpers.d.ts.map +0 -1
@@ -14,6 +14,13 @@
14
14
  * agentfootprint.context.* (via ContextRecorder)
15
15
  */
16
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';
17
24
  import { isPauseRequest } from './pause.js';
18
25
  import { emitCostTick } from './cost.js';
19
26
  import { STAGE_IDS, SUBFLOW_IDS } from '../conventions.js';
@@ -35,8 +42,10 @@ import { buildSystemPromptSlot } from './slots/buildSystemPromptSlot.js';
35
42
  import { buildMessagesSlot } from './slots/buildMessagesSlot.js';
36
43
  import { buildToolsSlot } from './slots/buildToolsSlot.js';
37
44
  import { buildInjectionEngineSubflow } from '../lib/injection-engine/buildInjectionEngineSubflow.js';
45
+ import { buildReadSkillTool } from '../lib/injection-engine/skillTools.js';
46
+ import { defineInstruction } from '../lib/injection-engine/factories/defineInstruction.js';
47
+ import { applyOutputSchema, buildDefaultInstruction, } from './outputSchema.js';
38
48
  import { RunnerBase, makeRunId } from './RunnerBase.js';
39
- import { defineTool } from './tools.js';
40
49
  export class Agent extends RunnerBase {
41
50
  name;
42
51
  id;
@@ -71,6 +80,21 @@ export class Agent extends RunnerBase {
71
80
  runId: 'pending',
72
81
  compositionPath: [],
73
82
  };
83
+ /**
84
+ * Reference to the most recent executor. Set on every `createExecutor()`
85
+ * call (i.e., every `run()` and `resume()`); read by `getLastSnapshot()`
86
+ * / `getLastNarrativeEntries()` so post-run UIs (Lens Trace tab,
87
+ * ExplainableShell) can pull execution state without intercepting the
88
+ * call. `undefined` until the first run.
89
+ */
90
+ lastExecutor;
91
+ /**
92
+ * Reference to the FlowChart compiled for the most recent run. Cached
93
+ * here rather than recomputed via `buildChart()` so `getSpec()` returns
94
+ * the SAME spec the executor traced — important when the spec is used
95
+ * to reconcile `getLastSnapshot()` for ExplainableShell.
96
+ */
97
+ lastFlowChart;
74
98
  /**
75
99
  * Memory subsystems registered via `.memory()`. Each definition mounts
76
100
  * its `read` subflow before the InjectionEngine on every turn; per-id
@@ -78,7 +102,24 @@ export class Agent extends RunnerBase {
78
102
  * collision-free.
79
103
  */
80
104
  memories;
81
- constructor(opts, systemPromptValue, registry, voice, injections = [], memories = []) {
105
+ /**
106
+ * Optional terminal contract. Set via the builder's `.outputSchema()`.
107
+ * When present, `agent.runTyped()` parses + validates the final
108
+ * answer against this parser. `agent.run()` keeps returning the
109
+ * raw string; consumers opt into typed mode explicitly.
110
+ */
111
+ outputSchemaParser;
112
+ /**
113
+ * Optional `ToolProvider` set via the builder's `.toolProvider()`.
114
+ * When present, the Tools slot subflow consults it per iteration
115
+ * (Block A5 follow-up) — the provider's tools land alongside any
116
+ * tools registered statically via `.tool()` / `.tools()`. The
117
+ * tool-call dispatcher also consults it for per-iteration execute
118
+ * lookup so dynamic chains (`gatedTools`, `skillScopedTools`)
119
+ * dispatch correctly when their visible-set changes mid-turn.
120
+ */
121
+ externalToolProvider;
122
+ constructor(opts, systemPromptValue, registry, voice, injections = [], memories = [], outputSchemaParser, toolProvider) {
82
123
  super();
83
124
  this.provider = opts.provider;
84
125
  this.name = opts.name ?? 'Agent';
@@ -91,6 +132,8 @@ export class Agent extends RunnerBase {
91
132
  this.registry = registry;
92
133
  this.injections = injections;
93
134
  this.memories = memories;
135
+ this.outputSchemaParser = outputSchemaParser;
136
+ this.externalToolProvider = toolProvider;
94
137
  // Eager validation: tool names must be unique across .tool() +
95
138
  // every Skill.inject.tools — the LLM dispatches by name. Runs in
96
139
  // constructor so `Agent.build()` throws immediately on collision,
@@ -115,6 +158,77 @@ export class Agent extends RunnerBase {
115
158
  toFlowChart() {
116
159
  return this.buildChart();
117
160
  }
161
+ /**
162
+ * The footprintjs `RuntimeSnapshot` from the most recent `run()` /
163
+ * `resume()`. Feeds Lens's Trace tab (ExplainableShell `runtimeSnapshot`
164
+ * prop) so consumers can scrub the execution timeline post-run without
165
+ * threading a recorder through the call site.
166
+ *
167
+ * Returns `undefined` before the first run completes. Returns the
168
+ * snapshot of the most recent run on every call after — including
169
+ * across multiple turns of the same Agent instance.
170
+ */
171
+ getLastSnapshot() {
172
+ return this.lastExecutor?.getSnapshot();
173
+ }
174
+ /**
175
+ * Structured narrative entries from the most recent run. Pairs with
176
+ * `getLastSnapshot()` for ExplainableShell's `narrativeEntries` prop.
177
+ * Empty array (not `undefined`) when no run has completed — matches
178
+ * the prop's expected shape so consumers can wire it directly without
179
+ * a defensive guard.
180
+ */
181
+ getLastNarrativeEntries() {
182
+ return this.lastExecutor?.getNarrativeEntries() ?? [];
183
+ }
184
+ /**
185
+ * The FlowChart compiled for the most recent run (or a freshly-built
186
+ * one if no run has happened yet). Feeds ExplainableShell's `spec`
187
+ * prop. Returning the cached chart matters: the spec must match what
188
+ * `getLastSnapshot()` traced, otherwise the Trace view's stage tree
189
+ * desyncs from the snapshot's runtime tree.
190
+ */
191
+ getSpec() {
192
+ return this.lastFlowChart ?? this.buildChart();
193
+ }
194
+ /**
195
+ * Parse + validate a raw agent answer against the agent's
196
+ * `outputSchema` parser. Throws `OutputSchemaError` on JSON parse
197
+ * or schema validation failure (the rawOutput is preserved on the
198
+ * error for triage). Throws a plain `Error` if the agent has no
199
+ * outputSchema set.
200
+ *
201
+ * Use this when you need to keep `agent.run()` returning the raw
202
+ * string for logging/observability and validate at a different
203
+ * layer; otherwise prefer `agent.runTyped()`.
204
+ */
205
+ parseOutput(raw) {
206
+ if (!this.outputSchemaParser) {
207
+ throw new Error(`Agent.parseOutput: this agent has no outputSchema. Use ` +
208
+ `Agent.create({...}).outputSchema(parser).build() to enable typed output.`);
209
+ }
210
+ return applyOutputSchema(raw, this.outputSchemaParser);
211
+ }
212
+ /**
213
+ * Run the agent and return the schema-validated typed output.
214
+ * Convenience over `parseOutput(await agent.run({...}))`.
215
+ *
216
+ * Throws `OutputSchemaError` on parse / validation failure.
217
+ * Throws if the agent has no outputSchema set or if the run
218
+ * pauses (use `run()` directly when pauses are expected).
219
+ */
220
+ async runTyped(input, options) {
221
+ if (!this.outputSchemaParser) {
222
+ throw new Error(`Agent.runTyped: this agent has no outputSchema. Use ` +
223
+ `Agent.create({...}).outputSchema(parser).build() to enable typed output.`);
224
+ }
225
+ const out = await this.run(input, options);
226
+ if (typeof out !== 'string') {
227
+ throw new Error('Agent.runTyped: run paused — typed mode does not support pauses. ' +
228
+ 'Use agent.run() + agent.parseOutput(...) after resume.');
229
+ }
230
+ return this.parseOutput(out);
231
+ }
118
232
  async run(input, options) {
119
233
  const executor = this.createExecutor();
120
234
  const result = await executor.run({
@@ -144,6 +258,12 @@ export class Agent extends RunnerBase {
144
258
  };
145
259
  const chart = this.buildChart();
146
260
  const executor = new FlowChartExecutor(chart);
261
+ // Enable structured narrative so `getLastNarrativeEntries()` can
262
+ // hand a populated array to consumer Trace views (ExplainableShell).
263
+ // Cheap when no consumer reads it; the recorder accumulates only.
264
+ executor.enableNarrative();
265
+ this.lastExecutor = executor;
266
+ this.lastFlowChart = chart;
147
267
  const dispatcher = this.getDispatcher();
148
268
  const getRunCtx = () => this.currentRunContext;
149
269
  executor.attachCombinedRecorder(new ContextRecorder({ dispatcher, getRunContext: getRunCtx }));
@@ -249,31 +369,86 @@ export class Agent extends RunnerBase {
249
369
  // ability to call the right tool. Throw early instead of subtly
250
370
  // shadowing.
251
371
  const skills = this.injections.filter((i) => i.flavor === 'skill');
372
+ // Collect skill tools, deduping by name when the SAME Tool reference
373
+ // is shared across skills. Different Tool implementations under the
374
+ // same name throws (already validated upstream by
375
+ // validateToolNameUniqueness) — we keep the runtime check as
376
+ // belt-and-suspenders.
377
+ //
378
+ // Block C runtime — `autoActivate: 'currentSkill'` semantics:
379
+ // When a skill's `defineSkill({ autoActivate: 'currentSkill' })`
380
+ // is set, its tools are EXCLUDED from the static registry. They
381
+ // flow into the LLM's tool list ONLY through `dynamicSchemas`
382
+ // (the buildToolsSlot path that reads activeInjections), which
383
+ // means they're visible ONLY on iterations after the skill is
384
+ // activated by `read_skill('id')`. Without this, the LLM sees
385
+ // every skill's tools on every iteration and the
386
+ // per-skill-narrowing autoActivate promised in `defineSkill`
387
+ // doesn't actually narrow anything. Skills WITHOUT autoActivate
388
+ // keep the v2.4 behavior (tools always visible) for back-compat.
252
389
  const skillToolEntries = [];
390
+ const sharedSkillTools = new Map();
253
391
  for (const skill of skills) {
392
+ const meta = skill.metadata;
393
+ const isAutoActivate = meta?.autoActivate === 'currentSkill';
254
394
  const toolsFromSkill = skill.inject.tools ?? [];
255
395
  for (const tool of toolsFromSkill) {
256
- skillToolEntries.push({ name: tool.schema.name, tool });
396
+ const name = tool.schema.name;
397
+ const existing = sharedSkillTools.get(name);
398
+ if (existing) {
399
+ if (existing !== tool) {
400
+ throw new Error(`Agent: tool name '${name}' is declared by multiple skills with different ` +
401
+ `Tool implementations. Skills MAY share the SAME Tool reference; they may ` +
402
+ `NOT register different functions under the same name.`);
403
+ }
404
+ continue; // dedupe — same reference already added
405
+ }
406
+ sharedSkillTools.set(name, tool);
407
+ // autoActivate skills: their tools come ONLY through
408
+ // dynamicSchemas (buildToolsSlot.ts pulls them from
409
+ // activeInjections.inject.tools when the skill is active).
410
+ // Don't pre-load them in the static registry.
411
+ if (isAutoActivate)
412
+ continue;
413
+ skillToolEntries.push({ name, tool });
257
414
  }
258
415
  }
416
+ // buildReadSkillTool returns undefined when skills is empty; the
417
+ // length check below short-circuits so the non-null assertion is safe.
259
418
  const readSkillEntries = skills.length > 0 ? [{ name: 'read_skill', tool: buildReadSkillTool(skills) }] : [];
260
419
  const augmentedRegistry = [
261
420
  ...registry,
262
421
  ...readSkillEntries,
263
422
  ...skillToolEntries,
264
423
  ];
265
- // Validate: tool names must be unique across registry + skills.
266
- // The LLM dispatches by name; collisions silently shadow.
424
+ // Final cross-source name-uniqueness check: static .tool() vs
425
+ // read_skill vs (deduped) skill tools. After the dedupe above this
426
+ // catches collisions BETWEEN sources (e.g., a static .tool('foo')
427
+ // colliding with a Skill's foo) which are real bugs.
267
428
  const seenNames = new Set();
268
429
  for (const entry of augmentedRegistry) {
269
430
  if (seenNames.has(entry.name)) {
270
431
  throw new Error(`Agent: duplicate tool name '${entry.name}'. Tool names must be unique ` +
271
- `across .tool() registrations and all Skills' inject.tools (the LLM ` +
272
- `dispatches by name; collisions break tool routing).`);
432
+ `across .tool() registrations and Skills' inject.tools (after deduping ` +
433
+ `same-reference shares across skills). The LLM dispatches by name; ` +
434
+ `collisions break tool routing.`);
273
435
  }
274
436
  seenNames.add(entry.name);
275
437
  }
276
438
  const registryByName = new Map(augmentedRegistry.map((e) => [e.name, e.tool]));
439
+ // Block C runtime — autoActivate skill tools live OUTSIDE the LLM-
440
+ // visible registry (so they don't pollute the per-iteration tool
441
+ // list before the skill activates), but they MUST still be findable
442
+ // by the dispatch handler — the LLM calls them by name once the
443
+ // skill is active, and dispatch looks up by name. Add them to the
444
+ // dispatch map so `lookupTool` resolves correctly. Using the Map
445
+ // backing the static registryByName means autoActivate tools share
446
+ // the same `.execute` wiring as normal tools — no special path.
447
+ for (const [name, tool] of sharedSkillTools.entries()) {
448
+ if (!registryByName.has(name)) {
449
+ registryByName.set(name, tool);
450
+ }
451
+ }
277
452
  const toolSchemas = augmentedRegistry.map((e) => e.tool.schema);
278
453
  const injectionEngineSubflow = buildInjectionEngineSubflow({
279
454
  injections: this.injections,
@@ -283,7 +458,10 @@ export class Agent extends RunnerBase {
283
458
  reason: 'Agent.system()',
284
459
  });
285
460
  const messagesSubflow = buildMessagesSlot();
286
- const toolsSubflow = buildToolsSlot({ tools: toolSchemas });
461
+ const toolsSubflow = buildToolsSlot({
462
+ tools: toolSchemas,
463
+ ...(this.externalToolProvider && { toolProvider: this.externalToolProvider }),
464
+ });
287
465
  const iterationStart = (scope) => {
288
466
  typedEmit(scope, 'agentfootprint.agent.iteration_start', {
289
467
  turnIndex: 0,
@@ -292,19 +470,22 @@ export class Agent extends RunnerBase {
292
470
  };
293
471
  const callLLM = async (scope) => {
294
472
  const systemPromptInjections = scope.systemPromptInjections ?? [];
295
- const messagesInjections = scope.messagesInjections ?? [];
473
+ // `scope.messagesInjections` is read by ContextRecorder for
474
+ // observability; the LLM-wire path now reads scope.history
475
+ // directly (see below for rationale).
296
476
  const iteration = scope.iteration;
297
477
  const systemPrompt = systemPromptInjections
298
478
  .map((r) => r.rawContent ?? '')
299
479
  .filter((s) => s.length > 0)
300
480
  .join('\n\n');
301
- const messages = messagesInjections
302
- .map((r) => ({
303
- role: r.asRole ?? 'user',
304
- content: r.rawContent ?? r.contentSummary,
305
- ...(r.sourceId !== undefined && { toolCallId: r.sourceId }),
306
- }))
307
- .filter((m) => m.content.length > 0);
481
+ // Read the LLM message stream from `scope.history` directly.
482
+ // The `messagesInjections` projection is for observability
483
+ // (ContextRecorder, Lens) — it flattens InjectionRecords for
484
+ // event reporting and doesn't carry the full LLM-protocol
485
+ // shape (assistant `toolCalls[]`, etc.). For Anthropic's API
486
+ // contract we need the original LLMMessage with `toolCalls`
487
+ // intact so tool_use → tool_result correlation survives.
488
+ const messages = scope.history ?? [];
308
489
  typedEmit(scope, 'agentfootprint.stream.llm_start', {
309
490
  iteration,
310
491
  provider: provider.name,
@@ -423,8 +604,32 @@ export class Agent extends RunnerBase {
423
604
  ...(toolCalls.length > 0 && { toolCalls }),
424
605
  });
425
606
  }
607
+ // Resolve a tool by name, consulting the external ToolProvider
608
+ // if one was wired via `.toolProvider()` and the static
609
+ // registry doesn't carry the tool. The provider sees the same
610
+ // ctx the Tools slot used, so dispatch + visibility stay
611
+ // consistent within the iteration.
612
+ const externalToolProvider = this.externalToolProvider;
613
+ const lookupTool = (toolName) => {
614
+ const fromRegistry = registryByName.get(toolName);
615
+ if (fromRegistry)
616
+ return fromRegistry;
617
+ if (!externalToolProvider)
618
+ return undefined;
619
+ const activatedIds = scope.activatedInjectionIds ?? [];
620
+ const identity = scope.runIdentity;
621
+ const ctx = {
622
+ iteration: scope.iteration,
623
+ ...(activatedIds.length > 0 && {
624
+ activeSkillId: activatedIds[activatedIds.length - 1],
625
+ }),
626
+ ...(identity && { identity }),
627
+ };
628
+ const visible = externalToolProvider.list(ctx);
629
+ return visible.find((t) => t.schema.name === toolName);
630
+ };
426
631
  for (const tc of toolCalls) {
427
- const tool = registryByName.get(tc.name);
632
+ const tool = lookupTool(tc.name);
428
633
  typedEmit(scope, 'agentfootprint.stream.tool_start', {
429
634
  toolName: tc.name,
430
635
  toolCallId: tc.id,
@@ -688,6 +893,11 @@ export class Agent extends RunnerBase {
688
893
  activeInjections: parent.activeInjections,
689
894
  }),
690
895
  outputMapper: (sf) => ({ systemPromptInjections: sf.systemPromptInjections }),
896
+ // See Tools-subflow comment below — same array-concat hazard.
897
+ // Without Replace, iter N+1's systemPromptInjections gets
898
+ // CONCATENATED with iter N's, multiplying the system prompt
899
+ // each iteration.
900
+ arrayMerge: ArrayMergeMode.Replace,
691
901
  })
692
902
  .addSubFlowChartNext(SUBFLOW_IDS.MESSAGES, messagesSubflow, 'Messages', {
693
903
  inputMapper: (parent) => ({
@@ -696,11 +906,26 @@ export class Agent extends RunnerBase {
696
906
  activeInjections: parent.activeInjections,
697
907
  }),
698
908
  outputMapper: (sf) => ({ messagesInjections: sf.messagesInjections }),
909
+ // Same array-concat hazard. messagesInjections is consumer-
910
+ // facing observability metadata (ContextRecorder, Lens) — must
911
+ // reflect THIS iteration's history, not be appended to last
912
+ // iteration's. CallLLM no longer reads this for the wire
913
+ // request (uses scope.history directly), so the LLM-protocol
914
+ // bug is fixed independently — but consumers of the
915
+ // messagesInjections stream still expect the per-iteration
916
+ // semantics.
917
+ arrayMerge: ArrayMergeMode.Replace,
699
918
  })
700
919
  .addSubFlowChartNext(SUBFLOW_IDS.TOOLS, toolsSubflow, 'Tools', {
701
920
  inputMapper: (parent) => ({
702
921
  iteration: parent.iteration,
703
922
  activeInjections: parent.activeInjections,
923
+ // The slot subflow reads these to build the per-iteration
924
+ // ToolDispatchContext when an external `.toolProvider()` is
925
+ // configured. Without them the provider sees activeSkillId
926
+ // = undefined every iteration, breaking skillScopedTools etc.
927
+ activatedInjectionIds: parent.activatedInjectionIds,
928
+ runIdentity: parent.runIdentity,
704
929
  }),
705
930
  outputMapper: (sf) => ({
706
931
  toolsInjections: sf.toolsInjections,
@@ -708,6 +933,16 @@ export class Agent extends RunnerBase {
708
933
  // back up so callLLM uses the right list for THIS iteration.
709
934
  dynamicToolSchemas: sf.toolSchemas,
710
935
  }),
936
+ // CRITICAL: footprintjs's default `applyOutputMapping`
937
+ // CONCATENATES arrays from subflow output with the parent's
938
+ // existing array values. Without `Replace`, the parent's
939
+ // `dynamicToolSchemas` (carrying the iter N value) gets
940
+ // concatenated with the slot's iter N+1 deduped list,
941
+ // re-introducing duplicate tool names that Anthropic's API
942
+ // rejects with "tools: Tool names must be unique." The slot's
943
+ // toolSchemas IS the authoritative list — replace, don't
944
+ // concatenate.
945
+ arrayMerge: ArrayMergeMode.Replace,
711
946
  })
712
947
  .addFunction('IterationStart', iterationStart, 'iteration-start', 'Iteration begin marker')
713
948
  .addFunction('CallLLM', callLLM, STAGE_IDS.CALL_LLM, 'LLM invocation')
@@ -733,7 +968,18 @@ export class Agent extends RunnerBase {
733
968
  })
734
969
  .setDefault('final')
735
970
  .end()
736
- .loopTo(SUBFLOW_IDS.MESSAGES);
971
+ // Dynamic ReAct: loop back to the InjectionEngine so EVERY iteration
972
+ // re-evaluates triggers (rule predicates, on-tool-return, llm-activated)
973
+ // against the freshest context (the just-appended tool result).
974
+ // Without this, the InjectionEngine runs ONCE per turn and:
975
+ // - on-tool-return predicates never fire on iter 2+
976
+ // - read_skill('X') activations are never picked up next iteration
977
+ // - autoActivate per-skill tool gating is structurally impossible
978
+ // - tools / system-prompt slots stay frozen at iter 1 content
979
+ // The v2.4 default of loopTo(MESSAGES) bypassed all four — quietly
980
+ // breaking the framework's "Dynamic ReAct" claim. v2.5 restores the
981
+ // v1 behavior that documents promise.
982
+ .loopTo(SUBFLOW_IDS.INJECTION_ENGINE);
737
983
  return builder.build();
738
984
  }
739
985
  }
@@ -747,6 +993,29 @@ export class AgentBuilder {
747
993
  registry = [];
748
994
  injectionList = [];
749
995
  memoryList = [];
996
+ /**
997
+ * Optional terminal contract — see `outputSchema()`. Stored on the
998
+ * builder, propagated to the Agent at `.build()` time.
999
+ */
1000
+ outputSchemaParser;
1001
+ /**
1002
+ * Optional `ToolProvider` set via `.toolProvider()`. Propagated to
1003
+ * the Agent's Tools slot subflow + tool-call dispatcher; consulted
1004
+ * per iteration so dynamic chains (`gatedTools`, `skillScopedTools`)
1005
+ * react to current activation state.
1006
+ */
1007
+ toolProviderRef;
1008
+ /**
1009
+ * Optional override for `AgentOptions.maxIterations`. When set via
1010
+ * the `.maxIterations()` builder method, takes precedence over the
1011
+ * value passed to `Agent.create({ maxIterations })`.
1012
+ */
1013
+ maxIterationsOverride;
1014
+ /**
1015
+ * Recorders collected via `.recorder()`. Attached to the built Agent
1016
+ * before `build()` returns (each via `agent.attach(rec)`).
1017
+ */
1018
+ recorderList = [];
750
1019
  // Voice config — defaults until the consumer calls .appName() /
751
1020
  // .commentaryTemplates() / .thinkingTemplates(). Stored as plain
752
1021
  // dicts (Record<string, string>) so the builder doesn't depend on
@@ -781,6 +1050,76 @@ export class AgentBuilder {
781
1050
  this.tool(t);
782
1051
  return this;
783
1052
  }
1053
+ /**
1054
+ * Wire a chainable `ToolProvider` (from `agentfootprint/tool-providers`)
1055
+ * as the agent's per-iteration tool source.
1056
+ *
1057
+ * The provider is consulted EVERY iteration via `provider.list(ctx)`
1058
+ * with `ctx = { iteration, activeSkillId, identity }`. Tools the
1059
+ * provider emits flow into the Tools slot alongside any static
1060
+ * tools registered via `.tool()` / `.tools()`. The tool-call
1061
+ * dispatcher also consults the provider so dynamic chains
1062
+ * (`gatedTools`, `skillScopedTools`) dispatch correctly when their
1063
+ * visible-set changes mid-turn.
1064
+ *
1065
+ * Throws if called more than once on the same builder (avoids
1066
+ * silent override surprises).
1067
+ *
1068
+ * @example Permission-gated baseline
1069
+ * import { gatedTools, staticTools } from 'agentfootprint/tool-providers';
1070
+ * import { PermissionPolicy } from 'agentfootprint/security';
1071
+ *
1072
+ * const policy = PermissionPolicy.fromRoles({
1073
+ * readonly: ['lookup', 'list_skills', 'read_skill'],
1074
+ * admin: ['lookup', 'list_skills', 'read_skill', 'delete'],
1075
+ * }, 'readonly');
1076
+ *
1077
+ * const provider = gatedTools(
1078
+ * staticTools(allTools),
1079
+ * (toolName) => policy.isAllowed(toolName),
1080
+ * );
1081
+ *
1082
+ * const agent = Agent.create({ provider: llm, model })
1083
+ * .system('You answer.')
1084
+ * .toolProvider(provider)
1085
+ * .build();
1086
+ */
1087
+ toolProvider(provider) {
1088
+ if (this.toolProviderRef) {
1089
+ throw new Error('AgentBuilder.toolProvider: already set. Each agent has at most one external ToolProvider.');
1090
+ }
1091
+ this.toolProviderRef = provider;
1092
+ return this;
1093
+ }
1094
+ /**
1095
+ * Override the ReAct iteration cap set via `Agent.create({
1096
+ * maxIterations })`. Convenience for builder-style code that prefers
1097
+ * fluent setters over constructor opts. Last call wins.
1098
+ *
1099
+ * Throws if `n` is not a positive integer or exceeds the hard cap
1100
+ * (`clampIterations`'s upper bound).
1101
+ */
1102
+ maxIterations(n) {
1103
+ if (!Number.isInteger(n) || n <= 0) {
1104
+ throw new Error(`AgentBuilder.maxIterations: expected a positive integer, got ${n}.`);
1105
+ }
1106
+ this.maxIterationsOverride = n;
1107
+ return this;
1108
+ }
1109
+ /**
1110
+ * Attach a footprintjs `CombinedRecorder` to the built Agent. Wired
1111
+ * via `agent.attach(rec)` immediately after construction, so the
1112
+ * recorder sees every event from the very first run.
1113
+ *
1114
+ * Equivalent to calling `agent.attach(rec)` post-build; the builder
1115
+ * method is a convenience for codebases that prefer fully-fluent
1116
+ * agent assembly. Multiple recorders are supported (each gets its
1117
+ * own `attach()` call).
1118
+ */
1119
+ recorder(rec) {
1120
+ this.recorderList.push(rec);
1121
+ return this;
1122
+ }
784
1123
  /**
785
1124
  * Set the agent's display name — substituted as `{{appName}}` in
786
1125
  * commentary + thinking templates. Same place to brand a tenant
@@ -841,6 +1180,22 @@ export class AgentBuilder {
841
1180
  skill(injection) {
842
1181
  return this.injection(injection);
843
1182
  }
1183
+ /**
1184
+ * Bulk-register every Skill in a `SkillRegistry`. Use for shared
1185
+ * skill catalogs across multiple Agents — register skills once on
1186
+ * the registry; attach the same registry to every consumer Agent.
1187
+ *
1188
+ * @example
1189
+ * const registry = new SkillRegistry();
1190
+ * registry.register(billingSkill).register(refundSkill);
1191
+ * const supportAgent = Agent.create({ provider }).skills(registry).build();
1192
+ * const escalationAgent = Agent.create({ provider }).skills(registry).build();
1193
+ */
1194
+ skills(registry) {
1195
+ for (const skill of registry.list())
1196
+ this.injection(skill);
1197
+ return this;
1198
+ }
844
1199
  /**
845
1200
  * Register a Steering doc — always-on system-prompt rule.
846
1201
  * Use for invariant guidance: output format, persona, safety policies.
@@ -856,6 +1211,18 @@ export class AgentBuilder {
856
1211
  instruction(injection) {
857
1212
  return this.injection(injection);
858
1213
  }
1214
+ /**
1215
+ * Bulk-register many instructions at once. Convenience for consumer
1216
+ * code that organizes its instruction set in a flat array (`const
1217
+ * instructions = [outputFormat, dataRouting, ...]`). Each element
1218
+ * is registered via `.instruction()` so duplicate-id checks still
1219
+ * fire per-entry.
1220
+ */
1221
+ instructions(injections) {
1222
+ for (const i of injections)
1223
+ this.instruction(i);
1224
+ return this;
1225
+ }
859
1226
  /**
860
1227
  * Register a Fact — developer-supplied data the LLM should see.
861
1228
  * User profile, env info, computed summary, current time, …
@@ -916,6 +1283,53 @@ export class AgentBuilder {
916
1283
  rag(definition) {
917
1284
  return this.memory(definition);
918
1285
  }
1286
+ /**
1287
+ * Declarative terminal contract. The agent's final answer must be
1288
+ * JSON matching `parser`. Auto-injects a system-prompt instruction
1289
+ * telling the LLM the shape, and exposes `agent.runTyped()` /
1290
+ * `agent.parseOutput()` for parse + validate at the call site.
1291
+ *
1292
+ * The `parser` is duck-typed: any object with a `parse(unknown): T`
1293
+ * method works (Zod, Valibot, ArkType, hand-written). The optional
1294
+ * `description` field on the parser drives the auto-generated
1295
+ * instruction; consumers can also override via `opts.instruction`.
1296
+ *
1297
+ * Throws if called more than once on the same builder (avoids
1298
+ * silent override surprises).
1299
+ *
1300
+ * @param parser Validation strategy that throws on shape failure.
1301
+ * @param opts Optional `{ name, instruction }` to customize.
1302
+ *
1303
+ * @example
1304
+ * import { z } from 'zod';
1305
+ * const Output = z.object({
1306
+ * status: z.enum(['ok', 'err']),
1307
+ * items: z.array(z.string()),
1308
+ * }).describe('A status enum + an array of strings.');
1309
+ *
1310
+ * const agent = Agent.create({...})
1311
+ * .outputSchema(Output)
1312
+ * .build();
1313
+ *
1314
+ * const typed = await agent.runTyped({ message: '...' });
1315
+ * typed.status; // narrowed to 'ok' | 'err'
1316
+ */
1317
+ outputSchema(parser, opts) {
1318
+ if (this.outputSchemaParser) {
1319
+ throw new Error('AgentBuilder.outputSchema: already set. Each agent has at most one terminal contract.');
1320
+ }
1321
+ this.outputSchemaParser = parser;
1322
+ const instructionText = opts?.instruction ?? buildDefaultInstruction(parser);
1323
+ const id = opts?.name ?? 'output-schema';
1324
+ // Always-on system-slot instruction. Activates every iteration so
1325
+ // long runs keep the contract present (recency-first redundancy).
1326
+ this.injectionList.push(defineInstruction({
1327
+ id,
1328
+ activeWhen: () => true,
1329
+ prompt: instructionText,
1330
+ }));
1331
+ return this;
1332
+ }
919
1333
  build() {
920
1334
  // Resolve the voice config: bundled defaults + consumer overrides.
921
1335
  // Templates flow through the same barrel exports the rest of the
@@ -925,7 +1339,17 @@ export class AgentBuilder {
925
1339
  commentaryTemplates: { ...defaultCommentaryTemplates, ...this.commentaryOverrides },
926
1340
  thinkingTemplates: { ...defaultThinkingTemplates, ...this.thinkingOverrides },
927
1341
  };
928
- return new Agent(this.opts, this.systemPromptValue, this.registry, voice, this.injectionList, this.memoryList);
1342
+ const opts = this.maxIterationsOverride !== undefined
1343
+ ? { ...this.opts, maxIterations: this.maxIterationsOverride }
1344
+ : this.opts;
1345
+ const agent = new Agent(opts, this.systemPromptValue, this.registry, voice, this.injectionList, this.memoryList, this.outputSchemaParser, this.toolProviderRef);
1346
+ // Attach builder-collected recorders so they receive events from
1347
+ // the very first run. Mirrors what consumers would do post-build
1348
+ // via `agent.attach(rec)`; the builder method is purely sugar.
1349
+ for (const rec of this.recorderList) {
1350
+ agent.attach(rec);
1351
+ }
1352
+ return agent;
929
1353
  }
930
1354
  }
931
1355
  function validateMemoryIdUniqueness(memories) {
@@ -957,72 +1381,62 @@ function clampIterations(n) {
957
1381
  * with consumer tools throw.
958
1382
  */
959
1383
  function validateToolNameUniqueness(registry, injections) {
960
- const seen = new Set();
961
- const claim = (name, sourceLabel) => {
962
- if (seen.has(name)) {
963
- throw new Error(`Agent: duplicate tool name '${name}' (${sourceLabel}). Tool names must be ` +
964
- `unique across .tool() registrations and all Skills' inject.tools — the LLM ` +
965
- `dispatches by name; collisions break tool routing.`);
1384
+ // Static registry: unique within itself. The Agent.tool() builder
1385
+ // method already throws on per-call duplicates; this is the
1386
+ // belt-and-suspenders check at build time.
1387
+ const staticNames = new Set();
1388
+ for (const entry of registry) {
1389
+ if (staticNames.has(entry.name)) {
1390
+ throw new Error(`Agent: duplicate tool name '${entry.name}' in .tool() registry. ` +
1391
+ `Tool names must be unique within the static registry.`);
966
1392
  }
967
- seen.add(name);
968
- };
969
- for (const entry of registry)
970
- claim(entry.name, '.tool()');
1393
+ staticNames.add(entry.name);
1394
+ }
1395
+ // `read_skill` is reserved when any Skill is registered. Collisions
1396
+ // with consumer-supplied tools break the auto-attach path.
971
1397
  const skills = injections.filter((i) => i.flavor === 'skill');
972
- if (skills.length > 0)
973
- claim('read_skill', 'auto-attached for Skills');
1398
+ if (skills.length > 0 && staticNames.has('read_skill')) {
1399
+ throw new Error(`Agent: tool name 'read_skill' is reserved when ≥1 Skill is registered. ` +
1400
+ `Rename your custom 'read_skill' tool or unregister it.`);
1401
+ }
1402
+ // Per-skill check: a skill's `inject.tools` array must be internally
1403
+ // unique (no duplicate names within the same skill — that's a
1404
+ // skill authoring bug). Across skills, sharing a Tool reference is
1405
+ // EXPECTED and supported — common tools (e.g., a `flogi_lookup`
1406
+ // used by multiple investigation skills) appear in multiple skills'
1407
+ // tool arrays. Only one skill is active at a time (or, when several
1408
+ // are active, deduped by name + reference at runtime). Sharing the
1409
+ // same Tool object across skills is the supported pattern; sharing
1410
+ // a Tool NAME with a DIFFERENT execute function is the actual bug —
1411
+ // we detect that here too.
1412
+ const seenByName = new Map();
974
1413
  for (const skill of skills) {
1414
+ const intraSkill = new Set();
975
1415
  for (const tool of skill.inject.tools ?? []) {
976
- claim(tool.schema.name, `from Skill '${skill.id}'`);
1416
+ const name = tool.schema.name;
1417
+ if (intraSkill.has(name)) {
1418
+ throw new Error(`Agent: skill '${skill.id}' lists tool '${name}' more than once in its ` +
1419
+ `inject.tools array. Each skill's tools must be unique within itself.`);
1420
+ }
1421
+ intraSkill.add(name);
1422
+ // Skill tools collide with the static .tool() registry → ambiguous dispatch
1423
+ if (staticNames.has(name)) {
1424
+ throw new Error(`Agent: skill '${skill.id}' tool '${name}' collides with the static .tool() ` +
1425
+ `registry. Either rename the skill's tool or remove the static registration.`);
1426
+ }
1427
+ // Same name across skills with DIFFERENT Tool objects = ambiguous when
1428
+ // both skills active. Same name + SAME Tool reference = supported sharing.
1429
+ const prior = seenByName.get(name);
1430
+ if (prior && prior !== tool) {
1431
+ throw new Error(`Agent: tool name '${name}' is declared by multiple skills with different ` +
1432
+ `Tool implementations. Skills MAY share the SAME Tool reference across ` +
1433
+ `their inject.tools arrays (deduped at dispatch); they may NOT register ` +
1434
+ `different functions under the same name (ambiguous dispatch).`);
1435
+ }
1436
+ seenByName.set(name, tool);
977
1437
  }
978
1438
  }
979
1439
  }
980
- /**
981
- * Build the auto-attached `read_skill` tool from a list of Skill
982
- * Injections. The LLM picks WHICH skill via the `id` argument.
983
- *
984
- * Tool execute() does the bookkeeping: appends the requested skill id
985
- * to `scope.activatedInjectionIds`. The next iteration's
986
- * InjectionEngine matches Skills with `trigger.kind: 'llm-activated'`
987
- * by id and includes them in the active set; slot subflows then
988
- * inject the body + tools.
989
- *
990
- * The tool's description lists each Skill's `id` + `description` so
991
- * the LLM can choose meaningfully.
992
- */
993
- function buildReadSkillTool(skills) {
994
- const skillIds = skills.map((s) => s.id);
995
- const skillCatalog = skills
996
- .map((s) => ` - ${s.id}: ${s.description ?? '(no description)'}`)
997
- .join('\n');
998
- return defineTool({
999
- name: 'read_skill',
1000
- description: `Activate a skill for the next iteration. Available skills:\n${skillCatalog}\n\n` +
1001
- `Pass the skill's id. The skill's body becomes part of the system prompt and any ` +
1002
- `gated tools become available on the next call.`,
1003
- inputSchema: {
1004
- type: 'object',
1005
- properties: {
1006
- id: {
1007
- type: 'string',
1008
- enum: skillIds,
1009
- description: 'The skill id to activate.',
1010
- },
1011
- },
1012
- required: ['id'],
1013
- },
1014
- execute: ({ id }) => {
1015
- // Bookkeeping is handled by the Agent's tool-calls subflow,
1016
- // which inspects `read_skill` returns and updates
1017
- // `scope.activatedInjectionIds` before the next iteration.
1018
- // The tool itself returns a confirmation string for the LLM.
1019
- if (!skillIds.includes(id)) {
1020
- return `Unknown skill '${id}'. Available: ${skillIds.join(', ')}`;
1021
- }
1022
- return `Skill '${id}' activated for the next iteration.`;
1023
- },
1024
- });
1025
- }
1026
1440
  /**
1027
1441
  * JSON.stringify with circular-ref protection. Tool results are untrusted —
1028
1442
  * a hostile/buggy tool returning a cyclic object must not crash the run.