@ixo/oracle-runtime-workers 0.12.0 → 0.15.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 (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +14 -9
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +267 -0
  18. package/src/channels/contract.ts +137 -0
  19. package/src/channels/turns.ts +270 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +198 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +1883 -264
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +523 -111
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +529 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. package/src/tasks/topic-deliverables.ts +70 -0
@@ -49,8 +49,35 @@ export const pluginManifestSchema: z.ZodType<PluginManifest> = z.object({
49
49
  category: manifestCategorySchema.optional(),
50
50
  visibility: manifestVisibilitySchema.optional(),
51
51
  stability: manifestStabilitySchema.optional(),
52
+ requires: z
53
+ .array(z.object({ resource: z.string().min(1), action: z.string().min(1) }))
54
+ .optional(),
52
55
  });
53
56
 
57
+ /** A UCAN capability a plugin's manifest requires of the user's delegation. */
58
+ export type CapabilityRequirement = NonNullable<
59
+ PluginManifest['requires']
60
+ >[number];
61
+
62
+ /** The manifest's requirements the user's delegation does not meet (empty when usable). */
63
+ export function unmetRequirements(
64
+ manifest: Pick<PluginManifest, 'requires'>,
65
+ hasCapability: (resource: string, action: string) => boolean,
66
+ ): CapabilityRequirement[] {
67
+ return (manifest.requires ?? []).filter(
68
+ (r) => !hasCapability(r.resource, r.action),
69
+ );
70
+ }
71
+
72
+ /** `can` on `with` pairs for a message: "`*` on `ixo:filesystem`, …". */
73
+ export function describeRequirements(
74
+ requirements: readonly CapabilityRequirement[],
75
+ ): string {
76
+ return requirements
77
+ .map((r) => `\`${r.action}\` on \`${r.resource}\``)
78
+ .join(', ');
79
+ }
80
+
54
81
  // ── Validator ───────────────────────────────────────────────────────────────
55
82
 
56
83
  /** Result of validating a single plugin manifest. */
@@ -289,6 +316,13 @@ export function estimateTokensApprox(text: string): number {
289
316
  return Math.ceil(text.length / 4);
290
317
  }
291
318
 
319
+ /**
320
+ * Size above which a plugin's `operatingGuide` is reported at boot (chars/4
321
+ * tokens). The guide rides in the system prompt of every turn that has the
322
+ * plugin loaded; the bundled flows guide is about 2,000.
323
+ */
324
+ export const OPERATING_GUIDE_WARN_TOKENS = 3_000;
325
+
292
326
  function formatExample(ex: ManifestExample): string {
293
327
  const argSummary =
294
328
  ex.args && Object.keys(ex.args).length > 0
@@ -3,9 +3,39 @@ import { Command } from '@langchain/langgraph';
3
3
  import { ToolMessage } from '@langchain/core/messages';
4
4
  import { tool } from '../plugin-api/tool-helper';
5
5
  import type { PluginManifest, PluginTool } from '../plugin-api/types';
6
- import type { ManifestRegistry, ToolRegistry } from './registries';
6
+ import {
7
+ describeRequirements,
8
+ unmetRequirements,
9
+ type CapabilityRequirement,
10
+ } from './manifest';
11
+ import type { ManifestRegistry, ToolSummarySource } from './registries';
12
+ import { withoutWithheldExamples, type TurnToolAccess } from './tool-access';
7
13
  import { acquireToolLock } from './utils';
8
14
 
15
+ export const LOAD_CAPABILITY_TOOL_NAME = 'load_capability';
16
+ export const LIST_CAPABILITIES_TOOL_NAME = 'list_capabilities';
17
+ /** Names of the tools `buildMetaTools` returns. */
18
+ export const META_TOOL_NAMES: readonly string[] = [
19
+ LOAD_CAPABILITY_TOOL_NAME,
20
+ LIST_CAPABILITIES_TOOL_NAME,
21
+ ];
22
+
23
+ /**
24
+ * The part of the turn's `resolveTurnToolAccess` result the meta-tools read:
25
+ * computed once per build from the same delegation their `ctx` carries.
26
+ */
27
+ export type MetaToolAccess = Pick<
28
+ TurnToolAccess<never>,
29
+ '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
+ };
38
+
9
39
  // ── list_capabilities ───────────────────────────────────────────────────────
10
40
 
11
41
  /**
@@ -27,6 +57,11 @@ interface CapabilityListing {
27
57
  loaded: boolean;
28
58
  category?: PluginManifest['category'];
29
59
  tags: string[];
60
+ /**
61
+ * Present only when the user's authorization does not grant what the
62
+ * plugin requires: it cannot be loaded or used until they re-authorize.
63
+ */
64
+ unavailable?: { missing: CapabilityRequirement[] };
30
65
  }
31
66
 
32
67
  /**
@@ -34,10 +69,13 @@ interface CapabilityListing {
34
69
  *
35
70
  * Returns one entry per loaded plugin, with a `loaded` flag that combines
36
71
  * the boot-time always-on plugins (`visibility === 'always'`) with the set
37
- * of plugins the agent has explicitly loaded for this thread.
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`.
38
75
  */
39
76
  export function buildListCapabilitiesTool(
40
77
  manifestRegistry: ManifestRegistry,
78
+ access: MetaToolAccess,
41
79
  ): PluginTool {
42
80
  return tool(
43
81
  async (args, ctx) => {
@@ -54,13 +92,19 @@ export function buildListCapabilitiesTool(
54
92
  if (visibility === 'silent' && !includeSilent) continue;
55
93
  if (visibility === 'on-demand' && !includeOnDemand) continue;
56
94
 
95
+ if (access.hiddenPlugins.has(pluginName)) continue;
96
+
97
+ const missing = unmetRequirements(manifest, ctx.ucan.hasCapability);
57
98
  out.push({
58
99
  name: pluginName,
59
100
  summary: manifest.summary,
60
101
  visibility,
61
- loaded: visibility === 'always' || loadedSet.has(pluginName),
102
+ loaded:
103
+ missing.length === 0 &&
104
+ (visibility === 'always' || loadedSet.has(pluginName)),
62
105
  category: manifest.category,
63
106
  tags: manifest.tags ?? [],
107
+ ...(missing.length > 0 ? { unavailable: { missing } } : {}),
64
108
  });
65
109
  }
66
110
  ctx.logger.debug?.(
@@ -72,7 +116,7 @@ export function buildListCapabilitiesTool(
72
116
  return JSON.stringify(out);
73
117
  },
74
118
  {
75
- name: 'list_capabilities',
119
+ name: LIST_CAPABILITIES_TOOL_NAME,
76
120
  description: 'List all available capabilities and their summaries.',
77
121
  schema: listCapabilitiesSchema,
78
122
  },
@@ -103,6 +147,18 @@ interface LoadCapabilityResult extends PluginManifest {
103
147
  alreadyAvailable: boolean;
104
148
  /** One entry per tool the plugin contributes. */
105
149
  tools: ToolDetail[];
150
+ /**
151
+ * 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.
155
+ */
156
+ operatingGuide?: string;
157
+ /**
158
+ * Present when the plugin was NOT loaded because the user's authorization
159
+ * does not grant what it requires (`manifest.requires`).
160
+ */
161
+ refused?: { missing: CapabilityRequirement[]; reason: string };
106
162
  }
107
163
 
108
164
  /**
@@ -113,13 +169,22 @@ interface LoadCapabilityResult extends PluginManifest {
113
169
  * cannot be invoked in parallel — concurrent calls throw immediately.
114
170
  *
115
171
  * Behavior per name:
116
- * - Unknown plugin → throws, instructing the agent to call
117
- * `list_capabilities` first.
172
+ * - Unknown plugin, or one the turn's tool planes left with nothing
173
+ * (`access.hiddenPlugins`, answered exactly like an unknown one) →
174
+ * throws, instructing the agent to call `list_capabilities` first.
118
175
  * - `silent` plugin → throws (silent plugins are not agent-loadable).
176
+ * - 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.
119
179
  * - Plugin already loaded, or visibility is `always` → included in result
120
180
  * with `alreadyAvailable: true` (no state change for that plugin).
121
181
  * - Otherwise → added to the `loadedPlugins` state update.
122
182
  *
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.
187
+ *
123
188
  * Return value:
124
189
  * - If all requested plugins were already available: returns the result
125
190
  * array directly (no state change).
@@ -129,8 +194,12 @@ interface LoadCapabilityResult extends PluginManifest {
129
194
  */
130
195
  export function buildLoadCapabilityTool(
131
196
  manifestRegistry: ManifestRegistry,
132
- toolRegistry: ToolRegistry,
197
+ toolSource: ToolSummarySource,
198
+ access: MetaToolAccess,
133
199
  ): 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>();
134
203
  return tool(
135
204
  async (args, ctx) => {
136
205
  const { names } = loadCapabilitySchema.parse(args);
@@ -145,7 +214,7 @@ export function buildLoadCapabilityTool(
145
214
  .collect()
146
215
  .find((m) => m.pluginName === name);
147
216
 
148
- if (!entry) {
217
+ if (!entry || access.hiddenPlugins.has(name)) {
149
218
  throw new Error(
150
219
  `Capability "${name}" does not exist. Call list_capabilities first to discover available plugins.`,
151
220
  );
@@ -157,8 +226,29 @@ export function buildLoadCapabilityTool(
157
226
  );
158
227
  }
159
228
 
160
- const tools: ToolDetail[] = toolRegistry
229
+ const missing = unmetRequirements(
230
+ entry.manifest,
231
+ ctx.ucan.hasCapability,
232
+ );
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
161
250
  .toolSummariesForPlugin(name)
251
+ .filter((t) => !access.withheldToolNames.has(t.name))
162
252
  .map((t) => ({
163
253
  name: t.name,
164
254
  description: t.description,
@@ -168,15 +258,32 @@ export function buildLoadCapabilityTool(
168
258
  const alwaysVisible = entry.manifest.visibility === 'always';
169
259
  const alreadyAvailable = alreadyLoaded || alwaysVisible;
170
260
 
171
- results.push({ ...entry.manifest, alreadyAvailable, tools });
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
+ });
172
277
 
173
278
  if (!alreadyAvailable) {
174
279
  newToLoad.push(name);
175
280
  }
176
281
  }
177
282
 
283
+ // Text, never the bare array: LangChain would pass an array of
284
+ // objects through as the message's content blocks.
178
285
  if (newToLoad.length === 0) {
179
- return results;
286
+ return JSON.stringify(results);
180
287
  }
181
288
 
182
289
  const update: Record<string, unknown> = {
@@ -196,7 +303,7 @@ export function buildLoadCapabilityTool(
196
303
  }
197
304
  },
198
305
  {
199
- name: 'load_capability',
306
+ name: LOAD_CAPABILITY_TOOL_NAME,
200
307
  description:
201
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.",
202
309
  schema: loadCapabilitySchema,
@@ -207,12 +314,15 @@ export function buildLoadCapabilityTool(
207
314
  // ── Bundle ──────────────────────────────────────────────────────────────────
208
315
 
209
316
  /**
210
- * Inputs for `buildMetaTools`. The runtime passes its already-collected
211
- * registries; the meta-tools read manifests and tool descriptors from them.
317
+ * Inputs for `buildMetaTools`. The runtime passes the manifest registry and
318
+ * the turn's own collected tools (`turnToolSummaries`); a `ToolRegistry`
319
+ * also satisfies `toolRegistry`, but lists boot-time tools only.
212
320
  */
213
321
  export interface BuildMetaToolsOptions {
214
322
  manifestRegistry: ManifestRegistry;
215
- toolRegistry: ToolRegistry;
323
+ toolRegistry: ToolSummarySource;
324
+ /** The turn's tool-plane result (`resolveTurnToolAccess`). */
325
+ toolAccess: MetaToolAccess;
216
326
  }
217
327
 
218
328
  /**
@@ -224,9 +334,9 @@ export interface BuildMetaToolsOptions {
224
334
  * - `list_capabilities` — list every visible plugin with status flags.
225
335
  */
226
336
  export function buildMetaTools(opts: BuildMetaToolsOptions): PluginTool[] {
227
- const { manifestRegistry, toolRegistry } = opts;
337
+ const { manifestRegistry, toolRegistry, toolAccess } = opts;
228
338
  return [
229
- buildLoadCapabilityTool(manifestRegistry, toolRegistry),
230
- buildListCapabilitiesTool(manifestRegistry),
339
+ buildLoadCapabilityTool(manifestRegistry, toolRegistry, toolAccess),
340
+ buildListCapabilitiesTool(manifestRegistry, toolAccess),
231
341
  ];
232
342
  }
@@ -1,10 +1,24 @@
1
+ import { ToolMessage } from '@langchain/core/messages';
1
2
  import { type AgentMiddleware, createMiddleware } from 'langchain';
2
3
  import { z } from 'zod';
3
4
  import type { Logger, PluginManifest } from '../../plugin-api/types';
5
+ import { describeRequirements, type CapabilityRequirement } from '../manifest';
4
6
  import { NOOP_LOGGER } from '../utils';
5
7
 
6
8
  type Visibility = NonNullable<PluginManifest['visibility']>;
7
9
 
10
+ /** `response_metadata` key the gate sets on its refusals (never sent to a provider). */
11
+ const REFUSED = 'capabilityGateRefused';
12
+
13
+ /**
14
+ * A ToolMessage the gate produced instead of running the tool. The tool never
15
+ * ran, so it is not a failure: the repetition guard must let the same call
16
+ * through once the capability is loaded.
17
+ */
18
+ export function isCapabilityGateRefusal(message: ToolMessage): boolean {
19
+ return message.response_metadata?.[REFUSED] === true;
20
+ }
21
+
8
22
  export interface CapabilityGateMiddlewareOptions {
9
23
  /**
10
24
  * Tool name → contributing plugin name. Tools absent from this map
@@ -18,29 +32,81 @@ export interface CapabilityGateMiddlewareOptions {
18
32
  * middleware does not need access to the registries at runtime.
19
33
  */
20
34
  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
+ /**
41
+ * Plugin name → the `manifest.requires` capabilities the user's delegation
42
+ * does not grant. Such a plugin's tools are hidden and refused whatever
43
+ * their visibility, loaded or preloaded.
44
+ */
45
+ unmetRequirements?: ReadonlyMap<string, readonly CapabilityRequirement[]>;
46
+ /**
47
+ * Tool names the turn withholds (`resolveTurnToolAccess`): admin tools the
48
+ * user's delegation does not grant, never bound. A call naming one is
49
+ * refused without saying what it is, and not as a load refusal: loading
50
+ * cannot make it available.
51
+ */
52
+ withheldToolNames?: ReadonlySet<string>;
21
53
  /** Optional logger; defaults to a no-op. */
22
54
  logger?: Logger;
23
55
  }
24
56
 
25
57
  /**
26
- * Gates on-demand plugin tools (and sub-agents-as-tools) per model call.
58
+ * Gates on-demand plugin tools (and sub-agents-as-tools) on both sides of
59
+ * the model.
27
60
  *
28
- * All plugin tools are bound to the agent at compile time. This middleware
29
- * runs on every model invocation: it reads `state.loadedPlugins` and trims
30
- * the request's `tools` array down to what the agent should actually see at
31
- * this point in the conversation.
61
+ * All plugin tools are bound to the agent at compile time. On every model
62
+ * invocation this middleware reads `state.loadedPlugins` and trims the
63
+ * request's `tools` array down to what the agent should see at this point
64
+ * in the conversation. Because they are all bound, the tool node would still
65
+ * run a call to a hidden tool — one named from an earlier thread, guessed,
66
+ * or planted by injected text — so every tool call is checked against the
67
+ * same rule before it runs, and a hidden one is answered with an error
68
+ * instead of executing.
32
69
  *
33
70
  * Why a middleware: `createAgent({ tools })` freezes the bound list, so
34
71
  * `load_capability` updating state mid-run would otherwise have no effect
35
72
  * until the next request rebuilt the agent. Filtering inside `wrapModelCall`
36
73
  * lets a load decision take effect on the very next LLM call.
74
+ *
75
+ * This is capability discovery, not authorization: any non-silent plugin can
76
+ * 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.
37
79
  */
38
80
  export const createCapabilityGateMiddleware = (
39
81
  options: CapabilityGateMiddlewareOptions,
40
82
  ): AgentMiddleware => {
41
- const { pluginByToolName, visibilityByToolName } = options;
83
+ const { pluginByToolName, visibilityByToolName, preloadedPlugins } = options;
42
84
  const logger = options.logger ?? NOOP_LOGGER;
43
85
 
86
+ /** What the user's authorization lacks for `toolName`'s plugin, or null. */
87
+ const unmetFor = (
88
+ toolName: string,
89
+ ): { plugin: string; missing: readonly CapabilityRequirement[] } | null => {
90
+ const plugin = pluginByToolName.get(toolName);
91
+ if (!plugin) return null;
92
+ const missing = options.unmetRequirements?.get(plugin);
93
+ return missing && missing.length > 0 ? { plugin, missing } : null;
94
+ };
95
+
96
+ /** The plugin that has to be loaded before `toolName` may be seen or run; null when it is open. */
97
+ const gatingPlugin = (
98
+ toolName: string,
99
+ loaded: ReadonlySet<string>,
100
+ ): string | null => {
101
+ const plugin = pluginByToolName.get(toolName);
102
+ if (!plugin) return null;
103
+ const viz = visibilityByToolName.get(toolName) ?? 'on-demand';
104
+ if (viz === 'always' || viz === 'silent') return null;
105
+ if (loaded.has(plugin) || preloadedPlugins?.has(plugin) === true)
106
+ return null;
107
+ return plugin;
108
+ };
109
+
44
110
  return createMiddleware({
45
111
  name: 'CapabilityGateMiddleware',
46
112
  stateSchema: z.object({
@@ -57,20 +123,72 @@ export const createCapabilityGateMiddleware = (
57
123
  // `unknown`. Narrow at runtime; unknown-named tools pass through.
58
124
  const name = typeof t.name === 'string' ? t.name : undefined;
59
125
  if (!name) return true;
60
- const plugin = pluginByToolName.get(name);
61
- if (!plugin) return true;
62
- const viz = visibilityByToolName.get(name) ?? 'on-demand';
63
- if (viz === 'always' || viz === 'silent') return true;
64
- return loaded.has(plugin);
126
+ return (
127
+ options.withheldToolNames?.has(name) !== true &&
128
+ unmetFor(name) === null &&
129
+ gatingPlugin(name, loaded) === null
130
+ );
65
131
  });
66
132
 
67
133
  if (filtered.length !== request.tools.length) {
68
134
  logger.log(
69
- `[CapabilityGateMiddleware] exposed ${filtered.length}/${request.tools.length} tools; loadedPlugins=${Array.from(loaded).join(',') || '∅'}`,
135
+ `[CapabilityGateMiddleware] exposed ${filtered.length}/${request.tools.length} tools; loadedPlugins=${Array.from(loaded).join(',') || '∅'}` +
136
+ (preloadedPlugins?.size
137
+ ? ` preloadedPlugins=${Array.from(preloadedPlugins).join(',')}`
138
+ : ''),
70
139
  );
71
140
  }
72
141
 
73
142
  return handler({ ...request, tools: filtered });
74
143
  },
144
+ // The state here is the one the tool node runs with, so a load made by
145
+ // an earlier step of this run counts; a `load_capability` issued in the
146
+ // same model response as the gated call does not (both run on the state
147
+ // from before either), and the model is told to call again.
148
+ wrapToolCall: (request, handler) => {
149
+ const name = request.toolCall.name;
150
+ if (options.withheldToolNames?.has(name)) {
151
+ logger.warn(
152
+ `[CapabilityGateMiddleware] refused a call to ${name}: an admin tool the user's delegation does not grant`,
153
+ );
154
+ return new ToolMessage({
155
+ content: `Tool "${name}" is not available in this conversation, so the call was not run.`,
156
+ tool_call_id: request.toolCall.id ?? '',
157
+ name,
158
+ status: 'error',
159
+ });
160
+ }
161
+ const unmet = unmetFor(name);
162
+ if (unmet) {
163
+ logger.warn(
164
+ `[CapabilityGateMiddleware] refused a call to ${name}: the user's authorization does not grant ${describeRequirements(unmet.missing)} ('${unmet.plugin}' requires it)`,
165
+ );
166
+ return new ToolMessage({
167
+ content:
168
+ `Tool "${name}" was not run: it belongs to the "${unmet.plugin}" capability, which requires ${describeRequirements(unmet.missing)}, and the user's authorization for this oracle does not grant it. ` +
169
+ 'Tell the user; they can re-authorize the oracle with that permission and ask again.',
170
+ tool_call_id: request.toolCall.id ?? '',
171
+ name,
172
+ status: 'error',
173
+ });
174
+ }
175
+ const plugin = gatingPlugin(
176
+ name,
177
+ new Set<string>(request.state.loadedPlugins ?? []),
178
+ );
179
+ if (plugin === null) return handler(request);
180
+ logger.warn(
181
+ `[CapabilityGateMiddleware] refused a call to ${name}: capability '${plugin}' is not loaded in this thread`,
182
+ );
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 },
191
+ });
192
+ },
75
193
  });
76
194
  };
@@ -14,7 +14,8 @@
14
14
  * fail at the provider.
15
15
  * 3. **Overflow recovery.** When the provider rejects the request anyway,
16
16
  * the limit it names is learned (the window is lowered for good), the
17
- * request is pruned hard, and it is retried once.
17
+ * request is pruned hard, and it is retried once — only when the prune
18
+ * made it strictly smaller; an unchanged request is never sent twice.
18
19
  *
19
20
  * Token counts are chars/4 estimates; the thresholds leave the slack for it.
20
21
  */
@@ -26,7 +27,12 @@ import {
26
27
  import { convertToOpenAITool } from '@langchain/core/utils/function_calling';
27
28
  import { type AgentMiddleware, createMiddleware } from 'langchain';
28
29
  import type { Logger } from '../../plugin-api/types';
29
- import { estimateContentTokens, type ContextBudget } from '../context-budget';
30
+ import {
31
+ estimateContentTokens,
32
+ messageContentTokens,
33
+ messageToolCallTokens,
34
+ type ContextBudget,
35
+ } from '../context-budget';
30
36
  import { isContextOverflowError } from '../context-window';
31
37
  import { NOOP_LOGGER } from '../utils';
32
38
  import { cappedMetaOf } from './result-cap';
@@ -156,13 +162,52 @@ export function estimateRequestTokens(input: {
156
162
  }): number {
157
163
  let tokens = input.systemTokens + input.schemaTokens;
158
164
  for (const m of input.messages) {
159
- tokens += estimateContentTokens(m.content) + 4;
165
+ tokens += messageContentTokens(m) + 4;
160
166
  if (AIMessage.isInstance(m) && m.tool_calls?.length)
161
- tokens += estimateContentTokens(m.tool_calls);
167
+ tokens += messageToolCallTokens(m, m.tool_calls);
162
168
  }
163
169
  return tokens;
164
170
  }
165
171
 
172
+ /**
173
+ * Tool-schema sizes, kept across turns. Every turn binds fresh tool objects,
174
+ * but a plugin's tool keeps its schema object for the life of the isolate,
175
+ * and what a tool sends is fixed by that schema, its name and its
176
+ * description: the size is kept per schema object, then per name and
177
+ * description. A tool without a schema object is sized per tool object.
178
+ */
179
+ const schemaSizes = new WeakMap<object, Map<string, number>>();
180
+ const entrySizes = new WeakMap<object, number>();
181
+
182
+ function schemaTokensOf(tools: readonly unknown[]): number {
183
+ let total = 0;
184
+ for (const entry of tools) {
185
+ if (!entry || typeof entry !== 'object') continue;
186
+ const schema: unknown =
187
+ 'schema' in entry ? Reflect.get(entry, 'schema') : undefined;
188
+ const sizes =
189
+ schema && typeof schema === 'object'
190
+ ? (schemaSizes.get(schema) ??
191
+ schemaSizes.set(schema, new Map()).get(schema))
192
+ : undefined;
193
+ const key = `${String(Reflect.get(entry, 'name'))}\n${String(Reflect.get(entry, 'description'))}`;
194
+ let n = sizes ? sizes.get(key) : entrySizes.get(entry);
195
+ if (n === undefined) {
196
+ try {
197
+ n = estimateContentTokens(
198
+ 'schema' in entry ? convertToOpenAITool(entry as never) : entry,
199
+ );
200
+ } catch {
201
+ n = 0;
202
+ }
203
+ if (sizes) sizes.set(key, n);
204
+ else entrySizes.set(entry, n);
205
+ }
206
+ total += n;
207
+ }
208
+ return total;
209
+ }
210
+
166
211
  export function createContextGuardMiddleware(
167
212
  options: ContextGuardOptions,
168
213
  ): AgentMiddleware {
@@ -177,26 +222,6 @@ export function createContextGuardMiddleware(
177
222
  );
178
223
  }
179
224
  };
180
- const schemaTokensCache = new WeakMap<object, number>();
181
- const schemaTokensOf = (tools: unknown[]): number => {
182
- let total = 0;
183
- for (const entry of tools) {
184
- if (!entry || typeof entry !== 'object') continue;
185
- let n = schemaTokensCache.get(entry);
186
- if (n === undefined) {
187
- try {
188
- n = estimateContentTokens(
189
- 'schema' in entry ? convertToOpenAITool(entry as never) : entry,
190
- );
191
- } catch {
192
- n = 0;
193
- }
194
- schemaTokensCache.set(entry, n);
195
- }
196
- total += n;
197
- }
198
- return total;
199
- };
200
225
  return createMiddleware({
201
226
  name: 'ContextGuardMiddleware',
202
227
  wrapModelCall: async (request, handler) => {
@@ -254,7 +279,13 @@ export function createContextGuardMiddleware(
254
279
  kind: 'overflow-retry',
255
280
  ...(learned !== undefined ? { learnedWindow: learned } : {}),
256
281
  });
282
+ const before = tokens;
257
283
  shrink(PRUNE_HARD, 'overflow', 'after the provider overflow');
284
+ if (tokens >= before)
285
+ throw new ContextOverflowError(
286
+ "The conversation no longer fits the model's context window and nothing more could be trimmed. Start a new session.",
287
+ error,
288
+ );
258
289
  try {
259
290
  return await handler({ ...request, messages });
260
291
  } catch (retryError) {
@@ -2,8 +2,15 @@ export { createCapabilityGateMiddleware } from './capability-gate';
2
2
  export type { CapabilityGateMiddlewareOptions } from './capability-gate';
3
3
  export { createToolValidationMiddleware } from './tool-validation';
4
4
  export type { ToolValidationMiddlewareOptions } from './tool-validation';
5
- export { createToolRepetitionGuardMiddleware } from './tool-repetition-guard';
6
- export type { ToolRepetitionGuardMiddlewareOptions } from './tool-repetition-guard';
5
+ export {
6
+ createToolRepetitionGuardMiddleware,
7
+ DEFAULT_REPETITION_CAPS,
8
+ repetitionCapsFromEnv,
9
+ } from './tool-repetition-guard';
10
+ export type {
11
+ RepetitionCaps,
12
+ ToolRepetitionGuardMiddlewareOptions,
13
+ } from './tool-repetition-guard';
7
14
  export {
8
15
  createSummarizationMiddleware,
9
16
  isSummarizationMessage,
@@ -21,3 +28,14 @@ export { createPageContextMiddleware } from './page-context';
21
28
  export type { PageContextMiddlewareOptions } from './page-context';
22
29
  export { createSafetyGuardrailMiddleware } from './safety-guardrail';
23
30
  export type { SafetyGuardrailMiddlewareOptions } from './safety-guardrail';
31
+ export {
32
+ createToolExecutionMiddleware,
33
+ isUncertainOutcome,
34
+ operationKey,
35
+ uncertainWriteToolResult,
36
+ } from './tool-execution';
37
+ export type {
38
+ ToolExecutionOptions,
39
+ WriteClaim,
40
+ WriteClaimStore,
41
+ } from './tool-execution';