@ixo/oracle-runtime-workers 0.13.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 +13 -8
  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 +170 -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 +504 -108
  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
@@ -87,7 +87,13 @@ export interface ComposePromptInput {
87
87
  userPreferencesContext: string;
88
88
  /** Memory-engine context blocks. */
89
89
  userContext: UserContextData | undefined;
90
- /** Time context (timezone + current time). */
90
+ /**
91
+ * Date context (`formatDateContext`): the day and the user's timezone. Only
92
+ * values stable for a whole day belong here — the system prompt opens
93
+ * every request, so anything that changes per turn would stop the
94
+ * provider's prompt cache at this point on every turn. The exact time
95
+ * rides on the turn's own message (`renderTurnTimeNote`).
96
+ */
91
97
  timeContext: string;
92
98
  /** Currently-viewed entity DID, if any. */
93
99
  currentEntityDid: string;
@@ -95,8 +101,22 @@ export interface ComposePromptInput {
95
101
  oracleNameOverride?: string;
96
102
  /** Degraded-services notice appended after the main prompt body. */
97
103
  degradedServicesBlock?: string;
104
+ /**
105
+ * How to write for the surface the reply lands on (chat delivery). Empty on
106
+ * Portal turns, which render a streamed document.
107
+ */
108
+ surfaceBlock?: string;
109
+ /**
110
+ * Operating guides of the plugins the turn starts with in use, already in
111
+ * their final order (`OraclePlugin.operatingGuide`). Rendered after every
112
+ * other section, under one heading; empty renders nothing.
113
+ */
114
+ operatingGuides?: readonly string[];
98
115
  }
99
116
 
117
+ /** Heading of the section that carries the in-use plugins' operating guides. */
118
+ export const OPERATING_GUIDES_HEADING = '## Capabilities in use';
119
+
100
120
  /** Headers used for each populated memory-context sub-section. */
101
121
  const CONTEXT_SECTION_LABELS: Record<ContextSlot, string> = {
102
122
  identity: 'Personal identity',
@@ -403,7 +423,7 @@ const TEMPLATE = `{{{ORACLE_SECTION}}}
403
423
  {{/DISCOVERY_PRINCIPLE}}
404
424
  - Being proactive does **not** mean charging ahead blind. Pause and ask the user a short clarifying question when: (a) the request has multiple plausible interpretations and picking wrong would waste their time, (b) a capability scan surfaces several equally-good fits and you can't tell which they want, (c) you're about to take an irreversible or costly action (deleting data, sending a message, publishing, spending tokens on a long job), or (d) a required input is missing and you'd have to guess. One clarifying question beats five minutes of wrong work — but don't ask for things you can reasonably infer from context.
405
425
  - When a tool or sub-agent succeeds, report the result. Never refuse after a successful tool call — including for credentials, tokens, identity data, or block/document operations.
406
- - When a tool or sub-agent fails, surface the failure to the user and ask how to proceed. Don't silently retry.
426
+ - The runtime retries a transient failure of a read-only tool once by itself. When a tool or sub-agent still fails, surface the failure to the user and ask how to proceed. Never repeat a write whose outcome is unknown without first verifying what happened, and never claim an authorization you were not given.
407
427
  - When delegating to a sub-agent, give it scope, intent, and the context it needs — sub-agents have no access to your conversation history.
408
428
  - Match the user's communication style. Be concise. Use Unicode emoji directly (\`🔥\`), never text shortcodes (\`:fire:\`).
409
429
  - When a capability turns out to be a strong fit for the user's recurring work, surface it explicitly and — if memory tools are available — offer to save the pattern for next time. Don't save silently; ask first.
@@ -412,6 +432,10 @@ const TEMPLATE = `{{{ORACLE_SECTION}}}
412
432
 
413
433
  {{{COMMUNICATION_STYLE}}}
414
434
  {{/COMMUNICATION_STYLE}}
435
+ {{#SURFACE_BLOCK}}
436
+
437
+ {{{SURFACE_BLOCK}}}
438
+ {{/SURFACE_BLOCK}}
415
439
  {{#CUSTOM_INSTRUCTIONS}}
416
440
 
417
441
  ## Custom Instructions
@@ -425,7 +449,7 @@ const TEMPLATE = `{{{ORACLE_SECTION}}}
425
449
  {{{CONTEXT_BLOCK}}}
426
450
  {{/CONTEXT_BLOCK}}
427
451
 
428
- **Current time:** {{TIME_CONTEXT}}
452
+ **Current date:** {{TIME_CONTEXT}}
429
453
  {{#CURRENT_ENTITY_DID}}
430
454
 
431
455
  **Current entity:** {{CURRENT_ENTITY_DID}}
@@ -479,6 +503,7 @@ export async function composePrompt(
479
503
  input.capabilityDiscovery === false ? '' : DISCOVERY_PRINCIPLE,
480
504
  CUSTOM_INSTRUCTIONS: input.customInstructions,
481
505
  COMMUNICATION_STYLE: communicationStyle,
506
+ SURFACE_BLOCK: input.surfaceBlock ?? '',
482
507
  CONTEXT_BLOCK: buildContextBlock(input.userContext),
483
508
  TIME_CONTEXT: input.timeContext,
484
509
  CURRENT_ENTITY_DID: input.currentEntityDid,
@@ -486,15 +511,95 @@ export async function composePrompt(
486
511
  USER_PREFERENCES_CONTEXT: input.userPreferencesContext,
487
512
  });
488
513
 
514
+ // After the stable sections: the set changes only when a plugin is loaded,
515
+ // so the prefix before it stays cacheable across that change too.
516
+ const guides = (input.operatingGuides ?? []).filter((g) => g.trim() !== '');
517
+ const withGuides =
518
+ guides.length > 0
519
+ ? `${rendered}\n${OPERATING_GUIDES_HEADING}\n\n${guides.join('\n\n')}\n`
520
+ : rendered;
521
+
489
522
  if (input.degradedServicesBlock && input.degradedServicesBlock.length > 0) {
490
- return `${rendered}\n\n---\n\n## Degraded services\n\n${input.degradedServicesBlock}\n`;
523
+ return `${withGuides}\n\n---\n\n## Degraded services\n\n${input.degradedServicesBlock}\n`;
524
+ }
525
+ return withGuides;
526
+ }
527
+
528
+ interface ZonedParts {
529
+ weekday: string;
530
+ date: string;
531
+ time: string;
532
+ /** The IANA zone the parts are in: the one asked for, or `UTC` when it is not a zone. */
533
+ zone: string;
534
+ }
535
+
536
+ /** `instant` as calendar parts in `timeZone`, or in UTC when that is not a usable zone. */
537
+ function zonedParts(instant: Date, timeZone: string | undefined): ZonedParts {
538
+ const zone = timeZone?.trim() || 'UTC';
539
+ let parts: Intl.DateTimeFormatPart[];
540
+ try {
541
+ parts = new Intl.DateTimeFormat('en-US', {
542
+ timeZone: zone,
543
+ weekday: 'long',
544
+ year: 'numeric',
545
+ month: '2-digit',
546
+ day: '2-digit',
547
+ hour: '2-digit',
548
+ minute: '2-digit',
549
+ hourCycle: 'h23',
550
+ }).formatToParts(instant);
551
+ } catch {
552
+ if (zone === 'UTC') throw new RangeError('Invalid date');
553
+ return zonedParts(instant, 'UTC');
491
554
  }
492
- return rendered;
555
+ const part = (type: Intl.DateTimeFormatPartTypes): string =>
556
+ parts.find((p) => p.type === type)?.value ?? '';
557
+ return {
558
+ weekday: part('weekday'),
559
+ date: `${part('year')}-${part('month')}-${part('day')}`,
560
+ time: `${part('hour')}:${part('minute')}`,
561
+ zone,
562
+ };
563
+ }
564
+
565
+ function parseInstant(value: string | undefined): Date | undefined {
566
+ if (!value) return undefined;
567
+ const instant = new Date(value);
568
+ return Number.isNaN(instant.getTime()) ? undefined : instant;
569
+ }
570
+
571
+ /**
572
+ * The system prompt's date line: the day in the user's timezone and the
573
+ * zone (`Saturday, 2026-10-03 (Europe/Berlin)`). Nothing finer than the day,
574
+ * so the prompt stays byte-identical across the turns of a day. A timezone
575
+ * that is not an IANA zone is not rendered; the day is then UTC's.
576
+ */
577
+ export function formatDateContext(
578
+ timezone: string | undefined,
579
+ currentTime: string | undefined,
580
+ ): string {
581
+ const instant = parseInstant(currentTime);
582
+ if (!instant) return 'Not available.';
583
+ const { weekday, date, zone } = zonedParts(instant, timezone);
584
+ return `${weekday}, ${date} (${zone})`;
585
+ }
586
+
587
+ /**
588
+ * The exact time for the turn's own message (`Current time: Saturday,
589
+ * 2026-10-03 14:05 (Europe/Berlin)`): it belongs on the message the user
590
+ * sent this turn, where it is part of the history from then on, not in the
591
+ * system prompt that opens every request. Pure: same inputs, same text.
592
+ */
593
+ export function renderTurnTimeNote(now: Date, timezone?: string): string {
594
+ if (Number.isNaN(now.getTime())) return 'Current time: not available.';
595
+ const { weekday, date, time, zone } = zonedParts(now, timezone);
596
+ return `Current time: ${weekday}, ${date} ${time} (${zone})`;
493
597
  }
494
598
 
495
599
  /**
496
- * Format a (timezone, currentTime) pair into a stable prompt block. Public so
497
- * forks driving their own `composePrompt` calls can re-use it.
600
+ * Format a (timezone, currentTime) pair into a prompt block, time included.
601
+ * Kept for forks driving their own `composePrompt` calls; the runtime's own
602
+ * prompt uses `formatDateContext`, which does not change within a day.
498
603
  */
499
604
  export function formatTimeContext(
500
605
  timezone: string | undefined,
@@ -1,3 +1,4 @@
1
+ import type { DecisionRegistration } from '@ixo/common/ai/decisions';
1
2
  import type { z } from 'zod';
2
3
  import type { OraclePlugin } from '../plugin-api/oracle-plugin';
3
4
  import type {
@@ -17,7 +18,7 @@ import {
17
18
  import { computeSubAgentToolName } from './subagent-as-tool';
18
19
 
19
20
  /**
20
- * The six registries the runtime composes. Instances are created per
21
+ * The seven registries the runtime composes. Instances are created per
21
22
  * `createRuntimeCore()` call — never at module scope — so an isolate that
22
23
  * boots two oracles (tests, or a multi-tenant shell) never shares caches.
23
24
  */
@@ -28,6 +29,7 @@ export interface Registries {
28
29
  manifests: ManifestRegistry;
29
30
  configSchema: ConfigSchemaRegistry;
30
31
  sharedState: SharedStateRegistry;
32
+ decisions: DecisionRegistry;
31
33
  }
32
34
 
33
35
  /** Build a fresh, empty set of registries. */
@@ -39,6 +41,7 @@ export function createRegistries(): Registries {
39
41
  manifests: new ManifestRegistry(),
40
42
  configSchema: new ConfigSchemaRegistry(),
41
43
  sharedState: new SharedStateRegistry(),
44
+ decisions: new DecisionRegistry(),
42
45
  };
43
46
  }
44
47
 
@@ -83,6 +86,31 @@ export interface ToolSummary {
83
86
  origin: 'boot' | 'request';
84
87
  }
85
88
 
89
+ /** Where `load_capability` reads the tools a plugin contributes. */
90
+ export interface ToolSummarySource {
91
+ toolSummariesForPlugin(
92
+ pluginName: string,
93
+ ): Array<{ name: string; description: string }>;
94
+ }
95
+
96
+ /**
97
+ * One turn's view of its own collected tools (boot and request-time), in
98
+ * collection order — never another turn's.
99
+ */
100
+ export function turnToolSummaries(
101
+ tools: ReadonlyArray<{ pluginName: string; tool: PluginTool }>,
102
+ ): ToolSummarySource {
103
+ return {
104
+ toolSummariesForPlugin: (pluginName) =>
105
+ tools
106
+ .filter((entry) => entry.pluginName === pluginName)
107
+ .map(({ tool }) => ({
108
+ name: tool.name,
109
+ description: tool.description,
110
+ })),
111
+ };
112
+ }
113
+
86
114
  /**
87
115
  * Stores plugins that contribute tools and resolves them lazily by invoking
88
116
  * each plugin's `getTools(buildCtx)` once at collection time.
@@ -105,19 +133,11 @@ export interface ToolSummary {
105
133
  export class ToolRegistry {
106
134
  private readonly plugins: OraclePlugin[] = [];
107
135
  private bootCache: RegisteredTool[] | null = null;
108
- /**
109
- * Metadata snapshot of the most recent `collect()`. Deliberately NOT the
110
- * tool objects: request-time tools close over that request's
111
- * `RuntimeContext`, and retaining them here would pin a full request
112
- * graph in memory between turns.
113
- */
114
- private collectedMeta: ToolSummary[] | null = null;
115
136
 
116
137
  /** Add a plugin whose `getTools` will be called at `collect()` time. */
117
138
  register(plugin: OraclePlugin): void {
118
139
  this.plugins.push(plugin);
119
140
  this.bootCache = null;
120
- this.collectedMeta = null;
121
141
  }
122
142
 
123
143
  /**
@@ -184,6 +204,11 @@ export class ToolRegistry {
184
204
  * Combined boot + request collection used by the main agent build. Uses
185
205
  * the cached boot output when present so per-request rebuilds skip the
186
206
  * `getTools` invocations that don't depend on runtime state.
207
+ *
208
+ * The request-time part is returned to the caller only, never kept: the
209
+ * registry is shared by every turn of the isolate, so nothing one user's
210
+ * turn collected may be read back by another (`turnToolSummaries` gives a
211
+ * turn its own view).
187
212
  */
188
213
  async collect(
189
214
  buildCtx: PluginContext,
@@ -191,19 +216,13 @@ export class ToolRegistry {
191
216
  ): Promise<RegisteredTool[]> {
192
217
  const boot = await this.collectBoot(buildCtx);
193
218
  const request = rtCtx ? await this.collectRequest(rtCtx) : [];
194
- const out = [...boot, ...request];
195
- this.collectedMeta = out.map(({ pluginName, tool, origin }) => ({
196
- pluginName,
197
- name: tool.name,
198
- description: tool.description,
199
- origin,
200
- }));
201
- return out;
219
+ return rtCtx
220
+ ? withoutShadowingRequestTools([...boot, ...request], rtCtx.logger)
221
+ : boot;
202
222
  }
203
223
 
204
- /** Summaries source: the last full collection, else the boot cache. */
224
+ /** Summaries of the boot-time tools (the boot checks read these). */
205
225
  private summaries(): ToolSummary[] {
206
- if (this.collectedMeta !== null) return this.collectedMeta;
207
226
  return (this.bootCache ?? []).map(({ pluginName, tool, origin }) => ({
208
227
  pluginName,
209
228
  name: tool.name,
@@ -212,15 +231,12 @@ export class ToolRegistry {
212
231
  }));
213
232
  }
214
233
 
215
- /** The flat list of tool names produced by the most recent `collect()`. */
234
+ /** The flat list of boot-time tool names. */
216
235
  toolNames(): string[] {
217
236
  return this.summaries().map((entry) => entry.name);
218
237
  }
219
238
 
220
- /**
221
- * Tool names contributed by a given plugin in the most recent `collect()`
222
- * (or `collectBoot()` if no full collection has happened yet).
223
- */
239
+ /** Boot-time tool names contributed by a given plugin. */
224
240
  toolNamesForPlugin(pluginName: string): string[] {
225
241
  return this.summaries()
226
242
  .filter((entry) => entry.pluginName === pluginName)
@@ -228,8 +244,9 @@ export class ToolRegistry {
228
244
  }
229
245
 
230
246
  /**
231
- * Name/description of the tools a given plugin contributed in the most
232
- * recent collection. Summaries, not the tool objects — see `collectedMeta`.
247
+ * Name/description of the boot-time tools a given plugin contributed.
248
+ * Summaries, not the tool objects. A turn that also has request-time tools
249
+ * reads its own collection instead (`turnToolSummaries`).
233
250
  */
234
251
  toolSummariesForPlugin(
235
252
  pluginName: string,
@@ -245,7 +262,7 @@ export class ToolRegistry {
245
262
  * actual conflict.
246
263
  */
247
264
  assertNoCollisions(): void {
248
- if (this.collectedMeta === null && this.bootCache === null) {
265
+ if (this.bootCache === null) {
249
266
  throw new Error('ToolRegistry.assertNoCollisions called before collect');
250
267
  }
251
268
  const seen = new Map<string, string>();
@@ -268,6 +285,86 @@ export class ToolRegistry {
268
285
  }
269
286
  }
270
287
 
288
+ /**
289
+ * A request-time tool (most of them declared by the client in the turn body,
290
+ * such as the Portal's browser tools) may not take a name another tool of the
291
+ * turn already has: the model would call one name while the runtime ran
292
+ * either tool. The colliding request tool is dropped for this turn with one
293
+ * warning — the server tool (or the first request tool of that name) stays,
294
+ * and the turn runs. Failing the turn instead would break every turn of a
295
+ * client release that happens to declare a clashing name. Boot-time
296
+ * collisions are `assertNoCollisions`' job; the names the registry cannot
297
+ * see (the meta-tools, the runtime's per-turn tools, sub-agent tool names)
298
+ * are reserved by `dropShadowingRequestEntries` when the agent is built.
299
+ */
300
+ function withoutShadowingRequestTools(
301
+ tools: readonly RegisteredTool[],
302
+ logger: { warn(message: string): void },
303
+ ): RegisteredTool[] {
304
+ const owner = new Map<string, string>();
305
+ const out: RegisteredTool[] = [];
306
+ for (const entry of tools) {
307
+ const { pluginName, tool, origin } = entry;
308
+ const previous = owner.get(tool.name);
309
+ if (previous !== undefined && origin === 'request') {
310
+ logger.warn(
311
+ `${LOG_PREFIX} request tool "${tool.name}" of plugin "${pluginName}" dropped for this turn: the name is already taken by plugin "${previous}"`,
312
+ );
313
+ continue;
314
+ }
315
+ if (previous === undefined) owner.set(tool.name, pluginName);
316
+ out.push(entry);
317
+ }
318
+ return out;
319
+ }
320
+
321
+ /**
322
+ * Drop the request-time tools and sub-agents whose name is already taken.
323
+ * Request-time contributions are recomputed every turn and some carry names
324
+ * a client declared (browser tools, AG-UI actions), so none of them may stand
325
+ * in for a tool the server owns: a name in `reservedNames` (the turn's
326
+ * boot-time tools and sub-agents, the meta-tools and the runtime's per-turn
327
+ * tools), or one an earlier request-time entry of the same turn already
328
+ * claimed, is dropped with one warning naming the plugin. Sub-agents are
329
+ * compared by the tool name they are bound under. Boot-time entries pass
330
+ * through untouched; their collisions are a boot error
331
+ * (`assertNoCollisions`).
332
+ */
333
+ export function dropShadowingRequestEntries(opts: {
334
+ tools: readonly RegisteredTool[];
335
+ requestSubAgents: readonly RegisteredSubAgent[];
336
+ reservedNames: Iterable<string>;
337
+ logger: { warn(message: string): void };
338
+ }): { tools: RegisteredTool[]; requestSubAgents: RegisteredSubAgent[] } {
339
+ const taken = new Set(opts.reservedNames);
340
+ const drop = (pluginName: string, name: string, kind: string): void =>
341
+ opts.logger.warn(
342
+ `${LOG_PREFIX} plugin "${pluginName}" request-time ${kind} "${name}" dropped: the name is already taken this turn`,
343
+ );
344
+ const tools: RegisteredTool[] = [];
345
+ for (const entry of opts.tools) {
346
+ if (entry.origin === 'request') {
347
+ if (taken.has(entry.tool.name)) {
348
+ drop(entry.pluginName, entry.tool.name, 'tool');
349
+ continue;
350
+ }
351
+ taken.add(entry.tool.name);
352
+ }
353
+ tools.push(entry);
354
+ }
355
+ const requestSubAgents: RegisteredSubAgent[] = [];
356
+ for (const entry of opts.requestSubAgents) {
357
+ const toolName = computeSubAgentToolName(entry.subAgent.name);
358
+ if (taken.has(toolName)) {
359
+ drop(entry.pluginName, toolName, 'sub-agent');
360
+ continue;
361
+ }
362
+ taken.add(toolName);
363
+ requestSubAgents.push(entry);
364
+ }
365
+ return { tools, requestSubAgents };
366
+ }
367
+
271
368
  // ── Sub-agent registry ──────────────────────────────────────────────────────
272
369
 
273
370
  /** A collected sub-agent tagged with the plugin that contributed it. */
@@ -286,17 +383,11 @@ export interface RegisteredSubAgent {
286
383
  export class SubAgentRegistry {
287
384
  private readonly plugins: OraclePlugin[] = [];
288
385
  private bootCache: RegisteredSubAgent[] | null = null;
289
- /** Names-only snapshot of the most recent `collect()` (see ToolRegistry). */
290
- private collectedNames: Array<{
291
- pluginName: string;
292
- subAgentName: string;
293
- }> | null = null;
294
386
 
295
387
  /** Add a plugin whose `getSubAgents` will be called at `collect()` time. */
296
388
  register(plugin: OraclePlugin): void {
297
389
  this.plugins.push(plugin);
298
390
  this.bootCache = null;
299
- this.collectedNames = null;
300
391
  }
301
392
 
302
393
  /** Run every plugin's `getSubAgents(buildCtx)` once and cache the result. */
@@ -343,24 +434,21 @@ export class SubAgentRegistry {
343
434
  return perPlugin.flat();
344
435
  }
345
436
 
346
- /** Combined boot + request collection used by the main agent build. */
437
+ /**
438
+ * Combined boot + request collection used by the main agent build. As
439
+ * with `ToolRegistry.collect`, the request-time part is not kept.
440
+ */
347
441
  async collect(
348
442
  buildCtx: PluginContext,
349
443
  rtCtx?: RuntimeContext,
350
444
  ): Promise<RegisteredSubAgent[]> {
351
445
  const boot = this.collectBoot(buildCtx);
352
446
  const request = rtCtx ? await this.collectRequest(rtCtx) : [];
353
- const out = [...boot, ...request];
354
- this.collectedNames = out.map(({ pluginName, subAgent }) => ({
355
- pluginName,
356
- subAgentName: subAgent.name,
357
- }));
358
- return out;
447
+ return [...boot, ...request];
359
448
  }
360
449
 
361
- /** Names source: the last full collection, else the boot cache. */
450
+ /** Names of the boot-time sub-agents (the boot checks read these). */
362
451
  private names(): Array<{ pluginName: string; subAgentName: string }> {
363
- if (this.collectedNames !== null) return this.collectedNames;
364
452
  return (this.bootCache ?? []).map(({ pluginName, subAgent }) => ({
365
453
  pluginName,
366
454
  subAgentName: subAgent.name,
@@ -368,8 +456,8 @@ export class SubAgentRegistry {
368
456
  }
369
457
 
370
458
  /**
371
- * The *wrapped* tool names contributed by a given plugin in the most recent
372
- * collection. Each entry passes through `computeSubAgentToolName` — the
459
+ * The *wrapped* tool names of the boot-time sub-agents a given plugin
460
+ * contributes. Each entry passes through `computeSubAgentToolName` — the
373
461
  * same transform `createSubagentAsTool` applies — so the names returned
374
462
  * here match what the agent will actually see.
375
463
  */
@@ -384,7 +472,7 @@ export class SubAgentRegistry {
384
472
  * message names both plugin names so the boot log points at the conflict.
385
473
  */
386
474
  assertNoCollisions(): void {
387
- if (this.collectedNames === null && this.bootCache === null) {
475
+ if (this.bootCache === null) {
388
476
  throw new Error(
389
477
  'SubAgentRegistry.assertNoCollisions called before collect',
390
478
  );
@@ -424,8 +512,6 @@ export interface RegisteredMiddleware {
424
512
  * Order is preserved as registration order — the loader applies any
425
513
  * dependency-driven topological reordering before registering plugins here.
426
514
  *
427
- * Middlewares are boot-time-only — there's no per-request hook. The
428
- * registry caches the first `collect(buildCtx)` result.
429
515
  */
430
516
  export class MiddlewareRegistry {
431
517
  private readonly plugins: OraclePlugin[] = [];
@@ -450,6 +536,17 @@ export class MiddlewareRegistry {
450
536
  return out;
451
537
  }
452
538
 
539
+ async collectRequest(ctx: RuntimeContext): Promise<RegisteredMiddleware[]> {
540
+ const entries = await Promise.all(
541
+ this.plugins.map(async (plugin) =>
542
+ ((await plugin.getRequestMiddlewares?.(ctx)) ?? []).map(
543
+ (middleware) => ({ pluginName: plugin.name, middleware }),
544
+ ),
545
+ ),
546
+ );
547
+ return entries.flat();
548
+ }
549
+
453
550
  /** Middlewares have no names, so no collision is possible. */
454
551
  assertNoCollisions(): void {
455
552
  // Intentional no-op — middleware order is established by the loader.
@@ -475,17 +572,35 @@ export interface ManifestCrossCheckResult {
475
572
  */
476
573
  export class ManifestRegistry {
477
574
  private readonly entries: RegisteredManifest[] = [];
575
+ /** Plugin name → `operatingGuide`, kept apart from the manifests (never rendered with them). */
576
+ private readonly guides = new Map<string, string>();
478
577
 
479
578
  /**
480
579
  * Record a plugin's manifest. A fork-supplied `override` is merged shallowly
481
580
  * over the plugin's own manifest, so every downstream reader sees the
482
- * effective manifest.
581
+ * effective manifest. A non-blank `operatingGuide` is recorded beside it.
483
582
  */
484
583
  register(plugin: OraclePlugin, override?: PluginManifestOverride): void {
485
584
  this.entries.push({
486
585
  pluginName: plugin.name,
487
586
  manifest: mergeManifestOverride(plugin.manifest, override),
488
587
  });
588
+ const guide = plugin.operatingGuide?.trim();
589
+ if (guide) this.guides.set(plugin.name, guide);
590
+ }
591
+
592
+ /** The plugin's operating guide, trimmed; `undefined` when it has none. */
593
+ operatingGuide(pluginName: string): string | undefined {
594
+ return this.guides.get(pluginName);
595
+ }
596
+
597
+ /** Every registered operating guide, by plugin name in code-point order. */
598
+ operatingGuides(): Array<{ pluginName: string; guide: string }> {
599
+ return [...this.guides]
600
+ .map(([pluginName, guide]) => ({ pluginName, guide }))
601
+ .sort((a, b) =>
602
+ a.pluginName < b.pluginName ? -1 : a.pluginName > b.pluginName ? 1 : 0,
603
+ );
489
604
  }
490
605
 
491
606
  /** Registered manifests in registration order. */
@@ -640,3 +755,93 @@ export class SharedStateRegistry {
640
755
  }
641
756
  }
642
757
  }
758
+
759
+ // ── Decision registry ───────────────────────────────────────────────────────
760
+
761
+ /** A collected decision tagged with the plugin that contributed it. */
762
+ export interface RegisteredDecision {
763
+ pluginName: string;
764
+ decision: DecisionRegistration;
765
+ }
766
+
767
+ interface DecisionBootCache {
768
+ entries: RegisteredDecision[];
769
+ /** First registration per name; duplicates surface in `assertNoCollisions`. */
770
+ byName: Map<string, RegisteredDecision>;
771
+ }
772
+
773
+ /**
774
+ * Stores the bounded semantic decisions plugins contribute through
775
+ * `getDecisions(ctx)`. Collected once per isolate (in `warm()`); the
776
+ * `DecisionRuntime` resolves `evaluateByName` through `get`. Name collisions
777
+ * across plugins are a boot error.
778
+ */
779
+ export class DecisionRegistry {
780
+ private readonly plugins: OraclePlugin[] = [];
781
+ private bootCache: DecisionBootCache | null = null;
782
+
783
+ register(plugin: OraclePlugin): void {
784
+ this.plugins.push(plugin);
785
+ this.bootCache = null;
786
+ }
787
+
788
+ /** Collect every plugin's decisions once; later calls return the cache. */
789
+ collect(buildCtx: PluginContext): RegisteredDecision[] {
790
+ if (this.bootCache !== null) return this.bootCache.entries;
791
+
792
+ const entries: RegisteredDecision[] = [];
793
+ const byName = new Map<string, RegisteredDecision>();
794
+ for (const plugin of this.plugins) {
795
+ if (!plugin.getDecisions) continue;
796
+ for (const decision of plugin.getDecisions(buildCtx)) {
797
+ const entry: RegisteredDecision = { pluginName: plugin.name, decision };
798
+ entries.push(entry);
799
+ if (!byName.has(decision.name)) byName.set(decision.name, entry);
800
+ }
801
+ }
802
+ this.bootCache = { entries, byName };
803
+ return entries;
804
+ }
805
+
806
+ get(name: string): RegisteredDecision | undefined {
807
+ if (this.bootCache === null) {
808
+ throw new Error('DecisionRegistry.get called before collect');
809
+ }
810
+ return this.bootCache.byName.get(name);
811
+ }
812
+
813
+ namesForPlugin(pluginName: string): string[] {
814
+ if (this.bootCache === null) return [];
815
+ return this.bootCache.entries
816
+ .filter((entry) => entry.pluginName === pluginName)
817
+ .map((entry) => entry.decision.name);
818
+ }
819
+
820
+ /** Throw if two plugins contribute decisions under the same name. */
821
+ assertNoCollisions(): void {
822
+ if (this.bootCache === null) {
823
+ throw new Error(
824
+ 'DecisionRegistry.assertNoCollisions called before collect',
825
+ );
826
+ }
827
+
828
+ const seen = new Map<string, string>();
829
+ const collisions: string[] = [];
830
+ for (const { pluginName, decision } of this.bootCache.entries) {
831
+ const previous = seen.get(decision.name);
832
+ if (previous && previous !== pluginName) {
833
+ collisions.push(
834
+ `Decision "${decision.name}" registered by both "${previous}" and "${pluginName}"`,
835
+ );
836
+ } else if (!previous) {
837
+ seen.set(decision.name, pluginName);
838
+ }
839
+ }
840
+
841
+ if (collisions.length > 0) {
842
+ throw new Error(
843
+ `DecisionRegistry: decision name collisions detected:\n - ${collisions.join('\n - ')}`,
844
+ );
845
+ }
846
+ }
847
+ }