@ixo/oracle-runtime-workers 0.38.0 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +5 -4
  2. package/src/action-interactions/routes.ts +78 -0
  3. package/src/action-interactions/store.ts +1038 -0
  4. package/src/channels/replies.ts +8 -4
  5. package/src/core/capability-expiry.ts +62 -0
  6. package/src/core/capability-router.ts +126 -95
  7. package/src/core/context-budget.ts +116 -4
  8. package/src/core/domain-context/document.ts +74 -2
  9. package/src/core/domain-context/index.ts +1 -0
  10. package/src/core/domain-context/resolver.ts +9 -6
  11. package/src/core/domain-context/turn-context.ts +62 -13
  12. package/src/core/domain-context/types.ts +17 -0
  13. package/src/core/env.ts +21 -2
  14. package/src/core/index.ts +4 -0
  15. package/src/core/llm.ts +29 -0
  16. package/src/core/main-agent-types.ts +45 -4
  17. package/src/core/main-agent.ts +210 -122
  18. package/src/core/manifest.ts +109 -24
  19. package/src/core/mcp-result.ts +56 -0
  20. package/src/core/meta-tools.ts +258 -110
  21. package/src/core/middlewares/capability-gate.ts +77 -28
  22. package/src/core/middlewares/context-guard.ts +154 -21
  23. package/src/core/middlewares/result-cap.ts +1 -1
  24. package/src/core/middlewares/summarization.ts +9 -0
  25. package/src/core/middlewares/tool-marks.ts +11 -2
  26. package/src/core/middlewares/tool-repetition-guard.ts +61 -1
  27. package/src/core/middlewares/turn-boundary.ts +22 -12
  28. package/src/core/operating-guides.ts +126 -0
  29. package/src/core/plugins/skills/skills-tools.ts +6 -31
  30. package/src/core/plugins/skills/skills.plugin.ts +4 -3
  31. package/src/core/plugins/weather/weather.plugin.ts +1 -0
  32. package/src/core/prompt-composer.ts +188 -111
  33. package/src/core/read-result-tool.ts +48 -14
  34. package/src/core/registries.ts +13 -5
  35. package/src/core/state.ts +4 -3
  36. package/src/core/sub-agent-fallback.ts +1 -0
  37. package/src/core/subagent-as-tool.ts +4 -1
  38. package/src/core/tool-access.ts +3 -32
  39. package/src/core/wrap-plugin-tool.ts +2 -1
  40. package/src/do/contracts.ts +45 -1
  41. package/src/do/transcript.ts +40 -11
  42. package/src/do/turn-metadata.ts +16 -4
  43. package/src/do/user-oracle-do.ts +358 -62
  44. package/src/index.ts +1 -0
  45. package/src/memory/session-history-indexer.ts +60 -14
  46. package/src/plugin-api/oracle-plugin.ts +4 -5
  47. package/src/plugin-api/types.ts +32 -3
  48. package/src/plugins/action-proposals/action-proposals.plugin.ts +7 -2
  49. package/src/plugins/agui/agui.plugin.ts +1 -0
  50. package/src/plugins/compact-schema.ts +80 -0
  51. package/src/plugins/composio/composio.plugin.ts +1 -0
  52. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +4 -3
  53. package/src/plugins/editor/editor.plugin.ts +4 -0
  54. package/src/plugins/firecrawl/firecrawl-agent.ts +2 -0
  55. package/src/plugins/firecrawl/firecrawl.plugin.ts +1 -0
  56. package/src/plugins/flows/flows.plugin.ts +37 -6
  57. package/src/plugins/flows/prompts.ts +1 -1
  58. package/src/plugins/flows/tools/authoring.ts +5 -7
  59. package/src/plugins/flows/tools/discovery.ts +1 -4
  60. package/src/plugins/flows/tools/forms.ts +2 -8
  61. package/src/plugins/flows/tools/linkage.ts +1 -4
  62. package/src/plugins/flows/tools/settings.ts +1 -4
  63. package/src/plugins/flows/translator.ts +16 -5
  64. package/src/plugins/flows/types.ts +27 -48
  65. package/src/plugins/hub-actions/hub-actions.plugin.ts +30 -4
  66. package/src/plugins/index.ts +2 -2
  67. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +29 -5
  68. package/src/plugins/ixo-chain/kinds/core.ts +2 -2
  69. package/src/plugins/ixo-chain/kinds/types.ts +34 -5
  70. package/src/plugins/ixo-chain/queries.ts +10 -1
  71. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +1 -0
  72. package/src/plugins/matrix-group-chats/index.ts +1 -0
  73. package/src/plugins/memory/memory-tools.ts +4 -4
  74. package/src/plugins/memory/memory.plugin.ts +27 -1
  75. package/src/plugins/memory/oracle-document-tools.ts +24 -49
  76. package/src/plugins/pod-creator/pod-creator.plugin.ts +1 -0
  77. package/src/plugins/portal/portal.plugin.ts +3 -1
  78. package/src/plugins/sandbox/sandbox-write-blob.ts +3 -15
  79. package/src/plugins/sandbox/sandbox.plugin.ts +19 -2
  80. package/src/plugins/tasks/manifest.ts +1 -0
  81. package/src/plugins/tasks/tasks-tools.ts +34 -39
  82. package/src/plugins/user-preferences/user-preferences-tool.ts +11 -37
  83. package/src/plugins/user-preferences/user-preferences.plugin.ts +4 -0
  84. package/src/plugins/vfs/vfs-tools.ts +6 -6
  85. package/src/plugins/vfs/vfs.plugin.ts +4 -5
  86. package/src/plugins/yoma/yoma.plugin.ts +1 -0
  87. package/src/shell/app.ts +241 -6
  88. package/src/sqlite/serialization.ts +12 -0
  89. package/src/sqlite/sqlite-saver.ts +17 -0
  90. package/src/tasks/schedule-input.ts +5 -22
@@ -5,11 +5,13 @@ import { tool } from '../plugin-api/tool-helper';
5
5
  import type { PluginManifest, PluginTool } from '../plugin-api/types';
6
6
  import {
7
7
  describeRequirements,
8
+ effectiveToolVisibility,
8
9
  unmetRequirements,
9
10
  type CapabilityRequirement,
10
11
  } from './manifest';
11
12
  import type { ManifestRegistry, ToolSummarySource } from './registries';
12
13
  import { withoutWithheldExamples, type TurnToolAccess } from './tool-access';
14
+ import { USAGE_CARRIED_KWARG } from './operating-guides';
13
15
  import { acquireToolLock } from './utils';
14
16
 
15
17
  export const LOAD_CAPABILITY_TOOL_NAME = 'load_capability';
@@ -27,14 +29,43 @@ export const META_TOOL_NAMES: readonly string[] = [
27
29
  export type MetaToolAccess = Pick<
28
30
  TurnToolAccess<never>,
29
31
  'withheldToolNames' | 'hiddenPlugins'
30
- > & {
31
- /**
32
- * Plugins available this turn only through the capability router's
33
- * preload: usable, but their operating guide is not in the system prompt,
34
- * so `load_capability` still returns it.
35
- */
36
- preloadedOnly?: ReadonlySet<string>;
37
- };
32
+ >;
33
+
34
+ // ── Shared loading rules ────────────────────────────────────────────────────
35
+
36
+ /** Whether `manifest`'s plugin still has a tool behind `load_capability`. */
37
+ function hasGatedTools(
38
+ pluginName: string,
39
+ manifest: PluginManifest,
40
+ toolSource: ToolSummarySource,
41
+ access: MetaToolAccess,
42
+ ): boolean {
43
+ return toolSource
44
+ .toolSummariesForPlugin(pluginName)
45
+ .some(
46
+ (tool) =>
47
+ !access.withheldToolNames.has(tool.name) &&
48
+ effectiveToolVisibility(tool, manifest) === 'on-demand',
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Whether the plugin has nothing left to load: the thread loaded it, or it is
54
+ * `always` visible with no tool behind `load_capability`.
55
+ */
56
+ function fullyBound(
57
+ pluginName: string,
58
+ manifest: PluginManifest,
59
+ loaded: ReadonlySet<string> | undefined,
60
+ toolSource: ToolSummarySource,
61
+ access: MetaToolAccess,
62
+ ): boolean {
63
+ return (
64
+ loaded?.has(pluginName) === true ||
65
+ (manifest.visibility === 'always' &&
66
+ !hasGatedTools(pluginName, manifest, toolSource, access))
67
+ );
68
+ }
38
69
 
39
70
  // ── list_capabilities ───────────────────────────────────────────────────────
40
71
 
@@ -68,13 +99,15 @@ interface CapabilityListing {
68
99
  * Build the `list_capabilities` meta-tool.
69
100
  *
70
101
  * Returns one entry per loaded plugin, with a `loaded` flag that combines
71
- * the boot-time always-on plugins (`visibility === 'always'`) with the set
72
- * of plugins the agent has explicitly loaded for this thread. A plugin left
73
- * with nothing by the turn's tool planes (`access.hiddenPlugins`) is not
74
- * listed at all; one whose `requires` are unmet is listed as `unavailable`.
102
+ * the boot-time always-on plugins (`visibility === 'always'`, with no tool
103
+ * behind `load_capability`) with the set of plugins the agent has explicitly
104
+ * loaded for this thread. A plugin left with nothing by the turn's tool
105
+ * planes (`access.hiddenPlugins`) is not listed at all; one whose `requires`
106
+ * are unmet is listed as `unavailable`.
75
107
  */
76
108
  export function buildListCapabilitiesTool(
77
109
  manifestRegistry: ManifestRegistry,
110
+ toolSource: ToolSummarySource,
78
111
  access: MetaToolAccess,
79
112
  ): PluginTool {
80
113
  return tool(
@@ -101,7 +134,7 @@ export function buildListCapabilitiesTool(
101
134
  visibility,
102
135
  loaded:
103
136
  missing.length === 0 &&
104
- (visibility === 'always' || loadedSet.has(pluginName)),
137
+ fullyBound(pluginName, manifest, loadedSet, toolSource, access),
105
138
  category: manifest.category,
106
139
  tags: manifest.tags ?? [],
107
140
  ...(missing.length > 0 ? { unavailable: { missing } } : {}),
@@ -130,37 +163,154 @@ const loadCapabilitySchema = z.object({
130
163
  .array(z.string())
131
164
  .min(1)
132
165
  .describe(
133
- 'One or more capability names to load, as returned by list_capabilities.',
166
+ 'One or more capability names to load, from the on-demand capability list.',
134
167
  ),
135
168
  });
136
169
 
137
- interface ToolDetail {
138
- name: string;
139
- description: string;
140
- }
170
+ /** The note a no-op load carries, in place of the plugin's manifest. */
171
+ const ALREADY_BOUND_NOTE =
172
+ 'Nothing to load: these tools are already in your tool list. Call them directly.';
141
173
 
142
- interface LoadCapabilityResult extends PluginManifest {
174
+ interface LoadCapabilityResult extends Partial<
175
+ Pick<PluginManifest, 'summary' | 'whenToUse' | 'whenNotToUse' | 'examples'>
176
+ > {
177
+ name: string;
143
178
  /**
144
- * `true` when the plugin was already loaded (or has `visibility: 'always'`),
145
- * `false` when this call moved it into the loaded set.
179
+ * `true` when the plugin had nothing left to load, `false` when this call
180
+ * moved it into the loaded set (or refused it).
146
181
  */
147
182
  alreadyAvailable: boolean;
148
- /** One entry per tool the plugin contributes. */
149
- tools: ToolDetail[];
183
+ /**
184
+ * The plugin's tool names. Their definitions reach the model in its tool
185
+ * list on the next step, so repeating the descriptions here would pay for
186
+ * them twice, and for the rest of the conversation.
187
+ */
188
+ tools: string[];
189
+ /** Present on a no-op load: says there is nothing to do. */
190
+ note?: string;
150
191
  /**
151
192
  * The plugin's working rules (`OraclePlugin.operatingGuide`), present when
152
- * this call loaded it or the router only preloaded it, once per turn. A
153
- * plugin loaded on the thread (or `always` visible) has it in the system
154
- * prompt, so it is not repeated.
193
+ * this call loaded it, once per turn, unless the system prompt carries them
194
+ * (`operating-guides.ts`).
155
195
  */
156
196
  operatingGuide?: string;
157
197
  /**
158
- * Present when the plugin was NOT loaded because the user's authorization
159
- * does not grant what it requires (`manifest.requires`).
198
+ * Present when the plugin was NOT loaded: it does not exist, or the user's
199
+ * authorization does not grant what it requires (`manifest.requires`, the
200
+ * `missing` list).
160
201
  */
161
202
  refused?: { missing: CapabilityRequirement[]; reason: string };
162
203
  }
163
204
 
205
+ /**
206
+ * What a load tells the model, shared by `load_capability` and the capability
207
+ * gate's load on a hidden tool call. Built per turn: it remembers what the
208
+ * turn already loaded, which the tool context cannot show (a state update
209
+ * made earlier in the turn is not in it).
210
+ */
211
+ export interface CapabilityLoader {
212
+ /** The plugin's tools the turn does not withhold. */
213
+ toolNames(pluginName: string): string[];
214
+ /**
215
+ * What the model needs to use the plugin: how to use it, the tool names,
216
+ * and its operating guide (once per turn) unless the prompt carries it.
217
+ * Records the plugin as loaded this turn.
218
+ */
219
+ describe(
220
+ pluginName: string,
221
+ manifest: PluginManifest,
222
+ guideInPrompt: boolean,
223
+ ): LoadCapabilityResult;
224
+ /** Whether this turn already loaded the plugin. */
225
+ loadedThisTurn(pluginName: string): boolean;
226
+ /** The gate's load: `describe` as a tool result. */
227
+ describeForGate(pluginName: string): GateLoadResult;
228
+ }
229
+
230
+ /** What the capability gate's refusal adds to its tool message. */
231
+ export interface GateLoadResult {
232
+ text: string;
233
+ additional_kwargs: Record<string, unknown>;
234
+ }
235
+
236
+ /** What marks a message as carrying the usage of `plugins`. */
237
+ function usageMark(plugins: readonly string[]): Record<string, unknown> {
238
+ return plugins.length > 0 ? { [USAGE_CARRIED_KWARG]: plugins } : {};
239
+ }
240
+
241
+ export function createCapabilityLoader(
242
+ manifestRegistry: ManifestRegistry,
243
+ toolSource: ToolSummarySource,
244
+ access: MetaToolAccess,
245
+ ): CapabilityLoader {
246
+ const loadedThisTurn = new Set<string>();
247
+ const guidesReturned = new Set<string>();
248
+
249
+ const toolNames = (pluginName: string): string[] =>
250
+ toolSource
251
+ .toolSummariesForPlugin(pluginName)
252
+ .filter((tool) => !access.withheldToolNames.has(tool.name))
253
+ .map((tool) => tool.name);
254
+
255
+ const describe: CapabilityLoader['describe'] = (
256
+ pluginName,
257
+ manifest,
258
+ guideInPrompt,
259
+ ) => {
260
+ loadedThisTurn.add(pluginName);
261
+ const { summary, whenToUse, whenNotToUse, examples } =
262
+ withoutWithheldExamples(manifest, access.withheldToolNames);
263
+ const guide =
264
+ guideInPrompt ||
265
+ manifest.visibility === 'always' ||
266
+ guidesReturned.has(pluginName)
267
+ ? undefined
268
+ : manifestRegistry.operatingGuide(pluginName);
269
+ if (guide) guidesReturned.add(pluginName);
270
+ return {
271
+ name: pluginName,
272
+ alreadyAvailable: false,
273
+ tools: toolNames(pluginName),
274
+ summary,
275
+ whenToUse,
276
+ whenNotToUse,
277
+ examples,
278
+ ...(guide ? { operatingGuide: guide } : {}),
279
+ };
280
+ };
281
+
282
+ return {
283
+ toolNames,
284
+ describe,
285
+ loadedThisTurn: (pluginName) => loadedThisTurn.has(pluginName),
286
+ describeForGate: (pluginName) => {
287
+ const entry = manifestRegistry
288
+ .collect()
289
+ .find((m) => m.pluginName === pluginName);
290
+ if (!entry) return { text: '', additional_kwargs: {} };
291
+ return {
292
+ text: JSON.stringify(describe(pluginName, entry.manifest, false)),
293
+ additional_kwargs: usageMark([pluginName]),
294
+ };
295
+ },
296
+ };
297
+ }
298
+
299
+ /** The names the error for an unknown capability offers. */
300
+ function loadableNames(
301
+ manifestRegistry: ManifestRegistry,
302
+ access: MetaToolAccess,
303
+ ): string[] {
304
+ return manifestRegistry
305
+ .collect()
306
+ .filter(
307
+ ({ pluginName, manifest }) =>
308
+ manifest.visibility !== 'silent' &&
309
+ !access.hiddenPlugins.has(pluginName),
310
+ )
311
+ .map(({ pluginName }) => pluginName);
312
+ }
313
+
164
314
  /**
165
315
  * Build the `load_capability` meta-tool.
166
316
  *
@@ -168,22 +318,25 @@ interface LoadCapabilityResult extends PluginManifest {
168
318
  * capabilities into a single call. A per-session lock ensures the tool
169
319
  * cannot be invoked in parallel — concurrent calls throw immediately.
170
320
  *
171
- * Behavior per name:
321
+ * Every name is judged before any is loaded, and a name that cannot be loaded
322
+ * never stops the rest of the batch. Behavior per name:
172
323
  * - Unknown plugin, or one the turn's tool planes left with nothing
173
324
  * (`access.hiddenPlugins`, answered exactly like an unknown one) →
174
- * throws, instructing the agent to call `list_capabilities` first.
175
- * - `silent` plugin → throws (silent plugins are not agent-loadable).
325
+ * `refused`, naming the capabilities that can be loaded.
326
+ * - `silent` plugin → `refused` (silent plugins are not agent-loadable).
176
327
  * - Plugin whose `requires` the user's delegation does not grant →
177
- * included in the result with `refused` (what is missing) and not
178
- * loaded; the rest of the batch is unaffected.
179
- * - Plugin already loaded, or visibility is `always` → included in result
180
- * with `alreadyAvailable: true` (no state change for that plugin).
328
+ * `refused`, with what is missing.
329
+ * - Plugin with nothing left to load: loaded on the thread or earlier this
330
+ * turn, or `always` visible with no tool behind `load_capability` → a
331
+ * no-op entry with `alreadyAvailable: true`, its tool names and a note
332
+ * saying so (no state change for that plugin). An `always` plugin with
333
+ * `on-demand` tools does load, once.
181
334
  * - Otherwise → added to the `loadedPlugins` state update.
182
335
  *
183
- * The tool list and `examples` leave out the tools the turn withholds
184
- * (`access.withheldToolNames`). A plugin this call loads, or one the router
185
- * only preloaded, comes with its `operatingGuide` when it has one — once per
186
- * turn; a refused one never does.
336
+ * The tool names and `examples` leave out the tools the turn withholds
337
+ * (`access.withheldToolNames`). A plugin this call loads comes with its usage
338
+ * and its `operatingGuide` when it has one, once per turn, and the result is
339
+ * marked as carrying them; a refused one never does.
187
340
  *
188
341
  * Return value:
189
342
  * - If all requested plugins were already available: returns the result
@@ -196,89 +349,80 @@ export function buildLoadCapabilityTool(
196
349
  manifestRegistry: ManifestRegistry,
197
350
  toolSource: ToolSummarySource,
198
351
  access: MetaToolAccess,
352
+ loader: CapabilityLoader = createCapabilityLoader(
353
+ manifestRegistry,
354
+ toolSource,
355
+ access,
356
+ ),
199
357
  ): PluginTool {
200
- // Guides this tool already returned: the tool is built per turn, and the
201
- // context it reads does not see a load made earlier in the same turn.
202
- const guidesReturned = new Set<string>();
203
358
  return tool(
204
359
  async (args, ctx) => {
205
360
  const { names } = loadCapabilitySchema.parse(args);
206
361
 
207
362
  const releaseLock = acquireToolLock(`${ctx.session.id}:load_capability`);
208
363
  try {
209
- const results: LoadCapabilityResult[] = [];
210
- const newToLoad: string[] = [];
364
+ const refused = (
365
+ name: string,
366
+ reason: string,
367
+ missing: CapabilityRequirement[] = [],
368
+ ): LoadCapabilityResult => ({
369
+ name,
370
+ alreadyAvailable: false,
371
+ tools: [],
372
+ refused: { missing, reason },
373
+ });
374
+ const nothingToLoad = (name: string): LoadCapabilityResult => ({
375
+ name,
376
+ alreadyAvailable: true,
377
+ tools: loader.toolNames(name),
378
+ note: ALREADY_BOUND_NOTE,
379
+ });
211
380
 
212
- for (const name of names) {
381
+ // Judged first: `describe` records the load, so it runs only for what
382
+ // this call really loads.
383
+ const judged = names.map((name) => {
213
384
  const entry = manifestRegistry
214
385
  .collect()
215
386
  .find((m) => m.pluginName === name);
216
-
217
- if (!entry || access.hiddenPlugins.has(name)) {
218
- throw new Error(
219
- `Capability "${name}" does not exist. Call list_capabilities first to discover available plugins.`,
387
+ if (!entry || access.hiddenPlugins.has(name))
388
+ return refused(
389
+ name,
390
+ `Capability "${name}" does not exist. Loadable capabilities: ${loadableNames(manifestRegistry, access).join(', ')}.`,
220
391
  );
221
- }
222
-
223
- if (entry.manifest.visibility === 'silent') {
224
- throw new Error(
225
- `Capability "${name}" is internal and cannot be loaded by the agent. Call list_capabilities first to discover loadable plugins.`,
392
+ if (entry.manifest.visibility === 'silent')
393
+ return refused(
394
+ name,
395
+ `Capability "${name}" is internal and cannot be loaded by the agent. Loadable capabilities: ${loadableNames(manifestRegistry, access).join(', ')}.`,
226
396
  );
227
- }
228
-
229
397
  const missing = unmetRequirements(
230
398
  entry.manifest,
231
399
  ctx.ucan.hasCapability,
232
400
  );
233
- if (missing.length > 0) {
234
- results.push({
235
- ...withoutWithheldExamples(
236
- entry.manifest,
237
- access.withheldToolNames,
238
- ),
239
- alreadyAvailable: false,
240
- tools: [],
241
- refused: {
242
- missing,
243
- reason: `The user's authorization for this oracle does not grant ${describeRequirements(missing)}, which "${name}" requires, so it was not loaded. Tell the user; they can re-authorize the oracle with that permission and ask again.`,
244
- },
245
- });
246
- continue;
247
- }
248
-
249
- const tools: ToolDetail[] = toolSource
250
- .toolSummariesForPlugin(name)
251
- .filter((t) => !access.withheldToolNames.has(t.name))
252
- .map((t) => ({
253
- name: t.name,
254
- description: t.description,
255
- }));
256
-
257
- const alreadyLoaded = ctx.loadedPlugins?.has(name) === true;
258
- const alwaysVisible = entry.manifest.visibility === 'always';
259
- const alreadyAvailable = alreadyLoaded || alwaysVisible;
260
-
261
- const guideInPrompt =
262
- alreadyAvailable && access.preloadedOnly?.has(name) !== true;
263
- const guide =
264
- guideInPrompt || guidesReturned.has(name)
265
- ? undefined
266
- : manifestRegistry.operatingGuide(name);
267
- if (guide) guidesReturned.add(name);
268
- results.push({
269
- ...withoutWithheldExamples(
270
- entry.manifest,
271
- access.withheldToolNames,
272
- ),
273
- alreadyAvailable,
274
- tools,
275
- ...(guide ? { operatingGuide: guide } : {}),
276
- });
277
-
278
- if (!alreadyAvailable) {
279
- newToLoad.push(name);
280
- }
281
- }
401
+ if (missing.length > 0)
402
+ return refused(
403
+ name,
404
+ `The user's authorization for this oracle does not grant ${describeRequirements(missing)}, which "${name}" requires, so it was not loaded. Tell the user; they can re-authorize the oracle with that permission and ask again.`,
405
+ missing,
406
+ );
407
+ return fullyBound(
408
+ name,
409
+ entry.manifest,
410
+ ctx.loadedPlugins,
411
+ toolSource,
412
+ access,
413
+ )
414
+ ? nothingToLoad(name)
415
+ : entry.manifest;
416
+ });
417
+
418
+ const newToLoad: string[] = [];
419
+ const results = judged.map((verdict, index) => {
420
+ if ('alreadyAvailable' in verdict) return verdict;
421
+ const name = names[index] ?? '';
422
+ if (loader.loadedThisTurn(name)) return nothingToLoad(name);
423
+ newToLoad.push(name);
424
+ return loader.describe(name, verdict, false);
425
+ });
282
426
 
283
427
  // Text, never the bare array: LangChain would pass an array of
284
428
  // objects through as the message's content blocks.
@@ -290,10 +434,12 @@ export function buildLoadCapabilityTool(
290
434
  loadedPlugins: newToLoad,
291
435
  };
292
436
  if (ctx.toolCallId) {
437
+ update.capabilityUsage = newToLoad;
293
438
  update.messages = [
294
439
  new ToolMessage({
295
440
  content: JSON.stringify(results),
296
441
  tool_call_id: ctx.toolCallId,
442
+ additional_kwargs: usageMark(newToLoad),
297
443
  }),
298
444
  ];
299
445
  }
@@ -305,7 +451,7 @@ export function buildLoadCapabilityTool(
305
451
  {
306
452
  name: LOAD_CAPABILITY_TOOL_NAME,
307
453
  description:
308
- "Load one or more capabilities for the rest of this conversation. Pass all capabilities you need in a single call — batching is preferred over multiple calls. The response is an array of plugin manifests plus tool lists; after this call, the new capabilities' tools are usable on the next model step.",
454
+ 'Load capabilities that are not in your tool list yet. Pass all you need in one call. Returns how to use each and its tool names; the tools are usable on your next step. A capability left unused for a while is unloaded; load it again when you need it. Never needed for a tool already in your tool list.',
309
455
  schema: loadCapabilitySchema,
310
456
  },
311
457
  );
@@ -323,6 +469,8 @@ export interface BuildMetaToolsOptions {
323
469
  toolRegistry: ToolSummarySource;
324
470
  /** The turn's tool-plane result (`resolveTurnToolAccess`). */
325
471
  toolAccess: MetaToolAccess;
472
+ /** The turn's loader, when the capability gate shares it. */
473
+ loader?: CapabilityLoader;
326
474
  }
327
475
 
328
476
  /**
@@ -330,13 +478,13 @@ export interface BuildMetaToolsOptions {
330
478
  * plugins are loaded:
331
479
  *
332
480
  * - `load_capability` — mark a plugin as loaded for this thread; returns
333
- * the plugin's full manifest + tool list.
481
+ * how to use it + its tool names.
334
482
  * - `list_capabilities` — list every visible plugin with status flags.
335
483
  */
336
484
  export function buildMetaTools(opts: BuildMetaToolsOptions): PluginTool[] {
337
- const { manifestRegistry, toolRegistry, toolAccess } = opts;
485
+ const { manifestRegistry, toolRegistry, toolAccess, loader } = opts;
338
486
  return [
339
- buildLoadCapabilityTool(manifestRegistry, toolRegistry, toolAccess),
340
- buildListCapabilitiesTool(manifestRegistry, toolAccess),
487
+ buildLoadCapabilityTool(manifestRegistry, toolRegistry, toolAccess, loader),
488
+ buildListCapabilitiesTool(manifestRegistry, toolRegistry, toolAccess),
341
489
  ];
342
490
  }
@@ -1,8 +1,10 @@
1
1
  import { ToolMessage } from '@langchain/core/messages';
2
+ import { Command, ReducedValue, StateSchema } from '@langchain/langgraph';
2
3
  import { type AgentMiddleware, createMiddleware } from 'langchain';
3
4
  import { z } from 'zod';
4
5
  import type { Logger, PluginManifest } from '../../plugin-api/types';
5
6
  import { describeRequirements, type CapabilityRequirement } from '../manifest';
7
+ import type { GateLoadResult } from '../meta-tools';
6
8
  import { NOOP_LOGGER } from '../utils';
7
9
 
8
10
  type Visibility = NonNullable<PluginManifest['visibility']>;
@@ -10,6 +12,43 @@ type Visibility = NonNullable<PluginManifest['visibility']>;
10
12
  /** `response_metadata` key the gate sets on its refusals (never sent to a provider). */
11
13
  const REFUSED = 'capabilityGateRefused';
12
14
 
15
+ const pluginSetUpdate = z
16
+ .union([z.array(z.string()), z.object({ replace: z.array(z.string()) })])
17
+ .optional();
18
+
19
+ /**
20
+ * What a write to a channel of plugin names does to it: a list adds to the set
21
+ * (`load_capability`, the editor context; two loads in one model step both
22
+ * land), `{ replace }` sets it (a turn that unloads idle plugins, a summary).
23
+ */
24
+ function nextPluginSet(
25
+ current: string[] | undefined,
26
+ update: z.infer<typeof pluginSetUpdate>,
27
+ ): string[] | undefined {
28
+ if (update === undefined) return current;
29
+ if (!Array.isArray(update)) return update.replace;
30
+ return Array.from(new Set([...(current ?? []), ...update]));
31
+ }
32
+
33
+ /**
34
+ * The graph channels of the loaded plugins, of the plugins whose usage the
35
+ * thread's messages carry, and of the plugins whose operating guides the
36
+ * system prompt carries (`operating-guides.ts`; last value wins). `createAgent`
37
+ * builds its channels from the middlewares' state schemas: a reducer declared
38
+ * anywhere else never runs.
39
+ */
40
+ const capabilityGateState = new StateSchema({
41
+ loadedPlugins: new ReducedValue(z.array(z.string()).optional(), {
42
+ inputSchema: pluginSetUpdate,
43
+ reducer: nextPluginSet,
44
+ }),
45
+ capabilityUsage: new ReducedValue(z.array(z.string()).optional(), {
46
+ inputSchema: pluginSetUpdate,
47
+ reducer: nextPluginSet,
48
+ }),
49
+ promptGuides: z.array(z.string()).optional(),
50
+ });
51
+
13
52
  /**
14
53
  * A ToolMessage the gate produced instead of running the tool. The tool never
15
54
  * ran, so it is not a failure: the repetition guard must let the same call
@@ -32,15 +71,10 @@ export interface CapabilityGateMiddlewareOptions {
32
71
  * middleware does not need access to the registries at runtime.
33
72
  */
34
73
  visibilityByToolName: Map<string, Visibility>;
35
- /**
36
- * Plugins the capability router preloaded for this turn. Their on-demand
37
- * tools pass the gate as if loaded, without touching `state.loadedPlugins`.
38
- */
39
- preloadedPlugins?: ReadonlySet<string>;
40
74
  /**
41
75
  * Plugin name → the `manifest.requires` capabilities the user's delegation
42
76
  * does not grant. Such a plugin's tools are hidden and refused whatever
43
- * their visibility, loaded or preloaded.
77
+ * their visibility, loaded or not.
44
78
  */
45
79
  unmetRequirements?: ReadonlyMap<string, readonly CapabilityRequirement[]>;
46
80
  /**
@@ -50,6 +84,12 @@ export interface CapabilityGateMiddlewareOptions {
50
84
  * cannot make it available.
51
85
  */
52
86
  withheldToolNames?: ReadonlySet<string>;
87
+ /**
88
+ * What the model should read about `plugin` once a hidden tool call loaded
89
+ * it (`CapabilityLoader.describeForGate`): usage, tool names, its operating
90
+ * guide. Called once per such call.
91
+ */
92
+ describeLoad: (plugin: string) => GateLoadResult;
53
93
  /** Optional logger; defaults to a no-op. */
54
94
  logger?: Logger;
55
95
  }
@@ -74,13 +114,18 @@ export interface CapabilityGateMiddlewareOptions {
74
114
  *
75
115
  * This is capability discovery, not authorization: any non-silent plugin can
76
116
  * be loaded with `load_capability`. What the gate guarantees is that an
77
- * on-demand tool runs only after that load (or the turn's preload) is
78
- * visible in the thread.
117
+ * on-demand tool runs only after that load (or the router's, in the turn's
118
+ * graph input) is visible in the thread.
119
+ *
120
+ * A call to a hidden tool still never runs, whatever its effect: the model
121
+ * has not seen the tool's schema, so its arguments are a guess. Instead the
122
+ * gate loads the capability on that call, so the refusal costs one step, the
123
+ * call again with the schema now in view, not a `load_capability` step too.
79
124
  */
80
125
  export const createCapabilityGateMiddleware = (
81
126
  options: CapabilityGateMiddlewareOptions,
82
127
  ): AgentMiddleware => {
83
- const { pluginByToolName, visibilityByToolName, preloadedPlugins } = options;
128
+ const { pluginByToolName, visibilityByToolName } = options;
84
129
  const logger = options.logger ?? NOOP_LOGGER;
85
130
 
86
131
  /** What the user's authorization lacks for `toolName`'s plugin, or null. */
@@ -102,16 +147,12 @@ export const createCapabilityGateMiddleware = (
102
147
  if (!plugin) return null;
103
148
  const viz = visibilityByToolName.get(toolName) ?? 'on-demand';
104
149
  if (viz === 'always' || viz === 'silent') return null;
105
- if (loaded.has(plugin) || preloadedPlugins?.has(plugin) === true)
106
- return null;
107
- return plugin;
150
+ return loaded.has(plugin) ? null : plugin;
108
151
  };
109
152
 
110
153
  return createMiddleware({
111
154
  name: 'CapabilityGateMiddleware',
112
- stateSchema: z.object({
113
- loadedPlugins: z.array(z.string()).optional(),
114
- }),
155
+ stateSchema: capabilityGateState,
115
156
  // No `contextSchema`: the gate reads only `state.loadedPlugins`, and
116
157
  // declaring one would make LangChain reject every invocation that omits
117
158
  // `context` — the tool wrapper already carries the request context.
@@ -132,10 +173,7 @@ export const createCapabilityGateMiddleware = (
132
173
 
133
174
  if (filtered.length !== request.tools.length) {
134
175
  logger.log(
135
- `[CapabilityGateMiddleware] exposed ${filtered.length}/${request.tools.length} tools; loadedPlugins=${Array.from(loaded).join(',') || '∅'}` +
136
- (preloadedPlugins?.size
137
- ? ` preloadedPlugins=${Array.from(preloadedPlugins).join(',')}`
138
- : ''),
176
+ `[CapabilityGateMiddleware] exposed ${filtered.length}/${request.tools.length} tools; loadedPlugins=${Array.from(loaded).join(',') || '∅'}`,
139
177
  );
140
178
  }
141
179
 
@@ -178,16 +216,27 @@ export const createCapabilityGateMiddleware = (
178
216
  );
179
217
  if (plugin === null) return handler(request);
180
218
  logger.warn(
181
- `[CapabilityGateMiddleware] refused a call to ${name}: capability '${plugin}' is not loaded in this thread`,
219
+ `[CapabilityGateMiddleware] refused a call to ${name} and loaded capability '${plugin}'`,
182
220
  );
183
- return new ToolMessage({
184
- content:
185
- `Tool "${name}" is not available: it belongs to the "${plugin}" capability, which is not loaded in this conversation, so the call was not run. ` +
186
- `If the user's request needs it, call load_capability with names ["${plugin}"] first, then call the tool again.`,
187
- tool_call_id: request.toolCall.id ?? '',
188
- name,
189
- status: 'error',
190
- response_metadata: { [REFUSED]: true },
221
+ const load = options.describeLoad(plugin);
222
+ return new Command({
223
+ update: {
224
+ loadedPlugins: [plugin],
225
+ capabilityUsage: [plugin],
226
+ messages: [
227
+ new ToolMessage({
228
+ content:
229
+ `Tool "${name}" was not run: it belongs to the "${plugin}" capability, which was not loaded. ` +
230
+ `It is loaded now, so the tool is in your tool list from your next step. If the user's request needs it, check the arguments against its schema and call it again.\n\n` +
231
+ load.text,
232
+ tool_call_id: request.toolCall.id ?? '',
233
+ name,
234
+ status: 'error',
235
+ additional_kwargs: load.additional_kwargs,
236
+ response_metadata: { [REFUSED]: true },
237
+ }),
238
+ ],
239
+ },
191
240
  });
192
241
  },
193
242
  });