@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
@@ -0,0 +1,273 @@
1
+ import {
2
+ AmbiguousDecisionProviderError,
3
+ CAPABILITY_ROUTER_MODES,
4
+ CAPABILITY_ROUTE_DECISION_NAME,
5
+ DecisionProviderUnavailableError,
6
+ capabilityRouteDecision,
7
+ decideCapabilityRoute,
8
+ toRoutableCapabilities,
9
+ type CapabilityRouterMode,
10
+ type DecisionEvaluator,
11
+ type DecisionTraceOptions,
12
+ type RoutableCapability,
13
+ } from '@ixo/common/ai/decisions';
14
+ import type { Logger } from '../plugin-api/types';
15
+ import { unmetRequirements } from './manifest';
16
+ import type { RegisteredManifest } from './registries';
17
+
18
+ /**
19
+ * Predicts, before the first model call of a turn, which on-demand plugin the
20
+ * user's message needs and preloads that plugin's tools for the turn, so the
21
+ * model does not spend a round trip on `load_capability`.
22
+ *
23
+ * The preload is one-turn state only: it is admitted by the capability gate
24
+ * and visible as loaded to tool handlers, but never written to the graph's
25
+ * `loadedPlugins` channel. The router fails open — any evaluation failure
26
+ * preloads nothing and the turn proceeds exactly as without a router.
27
+ */
28
+ export interface CapabilityRouterOptions {
29
+ evaluator: DecisionEvaluator;
30
+ logger: Logger;
31
+ /**
32
+ * Keeps a shadow evaluation alive after the turn has moved on
33
+ * (`ctx.waitUntil` on Workers). Without it the promise is merely detached.
34
+ */
35
+ background?: (work: Promise<unknown>) => void;
36
+ now?: () => number;
37
+ }
38
+
39
+ export interface CapabilityRouteTurn {
40
+ mode: CapabilityRouterMode;
41
+ /** Every registered manifest; the router picks the routable candidates. */
42
+ manifests: readonly RegisteredManifest[];
43
+ /** Plugins the thread has already loaded (the checkpointed channel). */
44
+ loaded: ReadonlySet<string>;
45
+ /**
46
+ * Plugins the user's delegation leaves with nothing to use (all of their
47
+ * tools admin-plane and not granted): never offered to the router. A
48
+ * function is called only when the router actually evaluates (not when
49
+ * it is off, or the message is empty), so the host does not compute the
50
+ * set for nothing.
51
+ */
52
+ hidden?: ReadonlySet<string> | (() => Promise<ReadonlySet<string>>);
53
+ /**
54
+ * The user's delegation, as `ctx.ucan.hasCapability`: a plugin whose
55
+ * `manifest.requires` it does not grant is never offered (the gate would
56
+ * refuse every one of its tools). Omitted, `requires` is not checked.
57
+ */
58
+ hasCapability?: (resource: string, action: string) => boolean;
59
+ /** The user's message text for this turn. */
60
+ text: string;
61
+ requestId: string;
62
+ /** The turn's abort signal; only an awaited (`on`) evaluation is bound to it. */
63
+ signal?: AbortSignal;
64
+ /**
65
+ * The turn's tracer and trace metadata. The router runs before the graph,
66
+ * outside any LangChain run, so without these its Decision is not traced.
67
+ */
68
+ trace?: DecisionTraceOptions;
69
+ /**
70
+ * Shadow mode only: receives what the router would have preloaded once its
71
+ * evaluation completes, so the host can compare it with what the turn
72
+ * actually loaded.
73
+ */
74
+ onShadowVerdict?: (wouldPreload: readonly string[]) => void;
75
+ }
76
+
77
+ export type CapabilityRouter = (
78
+ turn: CapabilityRouteTurn,
79
+ ) => Promise<ReadonlySet<string>>;
80
+
81
+ const NOTHING: ReadonlySet<string> = new Set<string>();
82
+
83
+ /** Narrows an env value to a router mode; anything else is `off`. */
84
+ export function capabilityRouterMode(value: unknown): CapabilityRouterMode {
85
+ return CAPABILITY_ROUTER_MODES.find((mode) => mode === value) ?? 'off';
86
+ }
87
+
88
+ /**
89
+ * The manifests the router may choose from: effectively on-demand plugins the
90
+ * thread has not loaded yet. `always` plugins are already bound and `silent`
91
+ * ones are never surfaced, so predicting them is pointless; a `hidden` one
92
+ * (no tool the user's delegation reaches), or one whose `requires` the
93
+ * delegation (`hasCapability`) does not grant, is not even named to the
94
+ * router.
95
+ */
96
+ export function routableCandidates(
97
+ manifests: readonly RegisteredManifest[],
98
+ loaded: ReadonlySet<string>,
99
+ hidden: ReadonlySet<string> = NOTHING,
100
+ hasCapability?: (resource: string, action: string) => boolean,
101
+ ): RoutableCapability[] {
102
+ return toRoutableCapabilities(
103
+ manifests
104
+ .filter(
105
+ ({ pluginName, manifest }) =>
106
+ (manifest.visibility ?? 'on-demand') === 'on-demand' &&
107
+ !loaded.has(pluginName) &&
108
+ !hidden.has(pluginName) &&
109
+ (hasCapability === undefined ||
110
+ unmetRequirements(manifest, hasCapability).length === 0),
111
+ )
112
+ .map(({ pluginName, manifest }) => ({
113
+ name: pluginName,
114
+ title: manifest.title,
115
+ summary: manifest.summary,
116
+ })),
117
+ );
118
+ }
119
+
120
+ /**
121
+ * After-turn comparison for shadow mode: which plugins the turn loaded on its
122
+ * own, and whether that matches what the router would have preloaded.
123
+ */
124
+ export function shadowAgreement(args: {
125
+ priorLoaded: ReadonlySet<string>;
126
+ loadedAfter: ReadonlySet<string>;
127
+ wouldPreload: readonly string[];
128
+ }): { loadedDuringTurn: string[]; agree: boolean } {
129
+ const loadedDuringTurn = Array.from(args.loadedAfter).filter(
130
+ (name) => !args.priorLoaded.has(name),
131
+ );
132
+ const predicted = new Set(args.wouldPreload);
133
+ const agree =
134
+ predicted.size === loadedDuringTurn.length &&
135
+ loadedDuringTurn.every((name) => predicted.has(name));
136
+ return { loadedDuringTurn, agree };
137
+ }
138
+
139
+ function errorName(error: unknown): string {
140
+ return error instanceof Error ? error.name : typeof error;
141
+ }
142
+
143
+ function list(names: readonly string[]): string {
144
+ return `[${names.join(', ')}]`;
145
+ }
146
+
147
+ export function createCapabilityRouter(
148
+ options: CapabilityRouterOptions,
149
+ ): CapabilityRouter {
150
+ const { evaluator, logger } = options;
151
+ const now = options.now ?? (() => Date.now());
152
+ const background =
153
+ options.background ??
154
+ ((work: Promise<unknown>) => {
155
+ void work;
156
+ });
157
+ // A missing Decision provider is a deployment fact, not a per-turn event:
158
+ // said once per object, then the router stays quiet.
159
+ let unavailableWarned = false;
160
+
161
+ // An ambiguous multi-provider configuration is a subclass: equally a
162
+ // deployment fact, but fixed by routing rather than by adding a provider.
163
+ const unavailable = (
164
+ error: unknown,
165
+ ): error is DecisionProviderUnavailableError =>
166
+ error instanceof DecisionProviderUnavailableError;
167
+
168
+ const warnUnavailable = (
169
+ requestId: string,
170
+ mode: CapabilityRouterMode,
171
+ error: DecisionProviderUnavailableError,
172
+ ) => {
173
+ if (unavailableWarned) return;
174
+ unavailableWarned = true;
175
+ const hint =
176
+ error instanceof AmbiguousDecisionProviderError
177
+ ? `route ${CAPABILITY_ROUTE_DECISION_NAME} or set decisionProviderPolicy.defaultProviderId`
178
+ : 'set DECISION_PROVIDER or CAPABILITY_ROUTER=off';
179
+ logger.warn(
180
+ `[capability-router] request=${requestId} mode=${mode} status=fallback reason=${error.name} (${hint})`,
181
+ );
182
+ };
183
+
184
+ return async (turn) => {
185
+ if (turn.mode === 'off') return NOTHING;
186
+ const text = turn.text.trim();
187
+ if (!text) return NOTHING;
188
+ let hidden: ReadonlySet<string> | undefined;
189
+ try {
190
+ hidden =
191
+ typeof turn.hidden === 'function' ? await turn.hidden() : turn.hidden;
192
+ } catch (error) {
193
+ // Fail open like any other router failure: no preload this turn.
194
+ logger.warn(
195
+ `[capability-router] request=${turn.requestId} mode=${turn.mode} status=fallback reason=${errorName(error)} (hidden plugins unavailable)`,
196
+ );
197
+ return NOTHING;
198
+ }
199
+ const capabilities = routableCandidates(
200
+ turn.manifests,
201
+ turn.loaded,
202
+ hidden,
203
+ turn.hasCapability,
204
+ );
205
+ if (capabilities.length === 0) return NOTHING;
206
+
207
+ // The turn is prepared from a checkpoint read without its messages, so
208
+ // the router sees the current message only; `recentTurns` stays empty.
209
+ const input = { text, recentTurns: [], capabilities };
210
+ const { requestId } = turn;
211
+
212
+ if (turn.mode === 'shadow') {
213
+ const started = now();
214
+ background(
215
+ evaluator
216
+ .evaluate(capabilityRouteDecision, input, turn.trace)
217
+ .then((evaluation) => {
218
+ const verdict = decideCapabilityRoute(evaluation, capabilities);
219
+ logger.log(
220
+ `[capability-router-shadow] request=${requestId} status=ok ` +
221
+ `needsCapability=${verdict.needsCapability.toFixed(2)} ` +
222
+ `capability=${verdict.capability ?? '-'} ` +
223
+ `capabilityConfidence=${verdict.capabilityConfidence?.toFixed(2) ?? '-'} ` +
224
+ `wouldPreload=${list(verdict.preload)} reason=${verdict.reason} ` +
225
+ `latencyMs=${now() - started} provider=${evaluation.provider} model=${evaluation.model}`,
226
+ );
227
+ turn.onShadowVerdict?.(verdict.preload);
228
+ })
229
+ .catch((error: unknown) => {
230
+ if (unavailable(error)) {
231
+ warnUnavailable(requestId, 'shadow', error);
232
+ return;
233
+ }
234
+ logger.log(
235
+ `[capability-router-shadow] request=${requestId} status=failed errorType=${errorName(error)} latencyMs=${now() - started}`,
236
+ );
237
+ }),
238
+ );
239
+ return NOTHING;
240
+ }
241
+
242
+ try {
243
+ const evaluation = await evaluator.evaluate(
244
+ capabilityRouteDecision,
245
+ input,
246
+ {
247
+ ...turn.trace,
248
+ ...(turn.signal && { signal: turn.signal }),
249
+ },
250
+ );
251
+ const verdict = decideCapabilityRoute(evaluation, capabilities);
252
+ if (verdict.preload.length === 0) {
253
+ logger.debug?.(
254
+ `[capability-router] request=${requestId} mode=on status=none reason=${verdict.reason} candidates=${capabilities.length}`,
255
+ );
256
+ return NOTHING;
257
+ }
258
+ logger.log(
259
+ `[capability-router] request=${requestId} preloaded=${list(verdict.preload)} candidates=${capabilities.length}`,
260
+ );
261
+ return new Set(verdict.preload);
262
+ } catch (error) {
263
+ if (unavailable(error)) {
264
+ warnUnavailable(requestId, 'on', error);
265
+ } else {
266
+ logger.warn(
267
+ `[capability-router] request=${requestId} mode=on status=fallback reason=${errorName(error)}`,
268
+ );
269
+ }
270
+ return NOTHING;
271
+ }
272
+ };
273
+ }
@@ -3,7 +3,8 @@
3
3
  * (`context-window.ts`) so that they scale with the model:
4
4
  *
5
5
  * - `summarizeAtTokens` — the history is condensed once it is this large
6
- * (default half the window; Hermes fires at 50%);
6
+ * (default half the window; Hermes fires at 50%),
7
+ * never above what the summarizer may read;
7
8
  * - `pruneAtTokens` — above this, old tool results are demoted to
8
9
  * one-liners in the request (no model call, no
9
10
  * state change) before the model sees them;
@@ -64,8 +65,14 @@ export interface ContextBudget {
64
65
  resultCapChars: number;
65
66
  requestCapTokens: number;
66
67
  outputReserveTokens: number;
67
- /** The summarizer's own input limit (what it may read to write the summary). */
68
+ /**
69
+ * The summarizer's own input limit (what it may read to write the summary):
70
+ * the smaller of what the main model's request cap and the summarizing
71
+ * model's own request cap leave, less a margin for the summary prompt.
72
+ */
68
73
  summaryInputTokens: number;
74
+ /** The summarizing model's window, when the host resolved it. */
75
+ summaryWindowTokens?: number;
69
76
  summarizeTriggerMessages?: number;
70
77
  keepMessages: number;
71
78
  }
@@ -142,12 +149,14 @@ export function contextKnobs(
142
149
  };
143
150
  }
144
151
 
145
- /** The budget for a turn on `resolution.model`. */
146
- export function contextBudgetFor(
147
- resolution: ContextWindowResolution,
148
- knobs: ContextKnobs = DEFAULT_CONTEXT_KNOBS,
149
- ): ContextBudget {
150
- const window = resolution.tokens;
152
+ /** Tokens the summary prompt and its framing need on top of the history it reads. */
153
+ const SUMMARY_PROMPT_MARGIN_TOKENS = 4_000;
154
+
155
+ /** What a request to a model with `window` may hold, the reply reserve left out. */
156
+ function requestCapFor(
157
+ window: number,
158
+ knobs: ContextKnobs,
159
+ ): { requestCapTokens: number; outputReserveTokens: number } {
151
160
  // The reply reserve can never eat a small window: cap it at a quarter.
152
161
  const outputReserveTokens = Math.min(
153
162
  knobs.outputReserveTokens,
@@ -157,9 +166,42 @@ export function contextBudgetFor(
157
166
  1_000,
158
167
  Math.floor(window * knobs.requestFraction) - outputReserveTokens,
159
168
  );
169
+ return { requestCapTokens, outputReserveTokens };
170
+ }
171
+
172
+ /**
173
+ * The budget for a turn on `resolution.model`. `summarizer` is the window of
174
+ * the model that writes the summary (the `routing` role), resolved the same
175
+ * way: the history it is handed is bounded by its own window too, not only
176
+ * by the main model's. Omitted, the main model's window alone bounds it.
177
+ */
178
+ export function contextBudgetFor(
179
+ resolution: ContextWindowResolution,
180
+ knobs: ContextKnobs = DEFAULT_CONTEXT_KNOBS,
181
+ summarizer?: ContextWindowResolution,
182
+ ): ContextBudget {
183
+ const window = resolution.tokens;
184
+ const { requestCapTokens, outputReserveTokens } = requestCapFor(
185
+ window,
186
+ knobs,
187
+ );
188
+ const summaryRequestCap = summarizer
189
+ ? Math.min(
190
+ requestCapTokens,
191
+ requestCapFor(summarizer.tokens, knobs).requestCapTokens,
192
+ )
193
+ : requestCapTokens;
194
+ const summaryInputTokens = Math.max(
195
+ 1_000,
196
+ summaryRequestCap - SUMMARY_PROMPT_MARGIN_TOKENS,
197
+ );
198
+ // The summarizer keeps only the newest `summaryInputTokens` of what it is
199
+ // asked to condense and drops the rest unread — the earlier summary first.
200
+ // Summarizing starts no later than that, so nothing is lost unsummarized.
160
201
  const summarizeAtTokens = Math.min(
161
202
  Math.floor(window * knobs.summarizeFraction),
162
203
  requestCapTokens,
204
+ summaryInputTokens,
163
205
  );
164
206
  const pruneAtTokens = Math.min(
165
207
  Math.floor(window * knobs.pruneFraction),
@@ -177,7 +219,8 @@ export function contextBudgetFor(
177
219
  ),
178
220
  requestCapTokens,
179
221
  outputReserveTokens,
180
- summaryInputTokens: Math.max(1_000, requestCapTokens - 4_000),
222
+ summaryInputTokens,
223
+ ...(summarizer ? { summaryWindowTokens: summarizer.tokens } : {}),
181
224
  ...(knobs.summarizeTriggerMessages !== undefined
182
225
  ? { summarizeTriggerMessages: knobs.summarizeTriggerMessages }
183
226
  : {}),
@@ -196,6 +239,50 @@ export function estimateContentTokens(content: unknown): number {
196
239
  }
197
240
  }
198
241
 
242
+ /**
243
+ * Per-message memo of the chars/4 estimates: every model step of a turn
244
+ * re-estimates the whole history (the context guard, the turn budget, the
245
+ * summarizer's trigger), and the history is the same message objects from
246
+ * step to step. An entry is reused only while the message still holds the
247
+ * same `content` / `tool_calls` values it was computed from.
248
+ */
249
+ interface MessageEstimate {
250
+ content: unknown;
251
+ contentTokens: number;
252
+ toolCalls?: { value: unknown; tokens: number };
253
+ }
254
+
255
+ const messageEstimates = new WeakMap<object, MessageEstimate>();
256
+
257
+ function estimateOf(message: { content: unknown }): MessageEstimate {
258
+ const cached = messageEstimates.get(message);
259
+ if (cached && cached.content === message.content) return cached;
260
+ const fresh: MessageEstimate = {
261
+ content: message.content,
262
+ contentTokens: estimateContentTokens(message.content),
263
+ };
264
+ messageEstimates.set(message, fresh);
265
+ return fresh;
266
+ }
267
+
268
+ /** `estimateContentTokens(message.content)`, memoised per message. */
269
+ export function messageContentTokens(message: { content: unknown }): number {
270
+ return estimateOf(message).contentTokens;
271
+ }
272
+
273
+ /** `estimateContentTokens(toolCalls)` for `message`'s tool calls, memoised per message. */
274
+ export function messageToolCallTokens(
275
+ message: { content: unknown },
276
+ toolCalls: unknown,
277
+ ): number {
278
+ const entry = estimateOf(message);
279
+ if (entry.toolCalls && entry.toolCalls.value === toolCalls)
280
+ return entry.toolCalls.tokens;
281
+ const tokens = estimateContentTokens(toolCalls);
282
+ entry.toolCalls = { value: toolCalls, tokens };
283
+ return tokens;
284
+ }
285
+
199
286
  export function describeBudget(b: ContextBudget): string {
200
- return `model=${b.model} window=${b.windowTokens} (${b.origin}) summarizeAt=${b.summarizeAtTokens} pruneAt=${b.pruneAtTokens} resultCap=${b.resultCapChars}c requestCap=${b.requestCapTokens} reserve=${b.outputReserveTokens}`;
287
+ return `model=${b.model} window=${b.windowTokens} (${b.origin}) summarizeAt=${b.summarizeAtTokens} pruneAt=${b.pruneAtTokens} resultCap=${b.resultCapChars}c requestCap=${b.requestCapTokens} reserve=${b.outputReserveTokens} summaryInput=${b.summaryInputTokens}${b.summaryWindowTokens !== undefined ? ` (summarizer window ${b.summaryWindowTokens})` : ''}`;
201
288
  }
package/src/core/env.ts CHANGED
@@ -1,7 +1,17 @@
1
+ import {
2
+ capabilityRouterEnvShape,
3
+ decisionProviderEnvShape,
4
+ } from '@ixo/common/ai/decisions';
1
5
  import { z } from 'zod';
2
6
  import type { OraclePlugin } from '../plugin-api/oracle-plugin';
3
7
  import type { Logger } from '../plugin-api/types';
4
8
  import { NOOP_LOGGER } from './utils';
9
+ import { FEEDBACK_ENV_PREFIX } from '../feedback/contract';
10
+ import {
11
+ isSecureUrl,
12
+ MAX_ARTIFACT_SWEEP_INTERVAL_HOURS,
13
+ MAX_ARTIFACT_TTL_DAYS,
14
+ } from '../artifacts/config';
5
15
 
6
16
  /**
7
17
  * Tier-0 (core) environment for the Workers runtime. Mirrors
@@ -29,6 +39,11 @@ import { NOOP_LOGGER } from './utils';
29
39
  */
30
40
  export const TURN_RECURSION_LIMIT_DEFAULT = 600;
31
41
 
42
+ /** An https URL, or http on localhost / 127.0.0.1 (local harness). */
43
+ const secureUrlSchema = z.string().refine((raw) => isSecureUrl(raw), {
44
+ message: 'must be an https URL (http only on localhost)',
45
+ });
46
+
32
47
  export const baseEnvSchema = z.object({
33
48
  // --- identity -----------------------------------------------------------
34
49
  ORACLE_NAME: z.string().min(1),
@@ -50,6 +65,13 @@ export const baseEnvSchema = z.object({
50
65
  * declares. Default 15 minutes.
51
66
  */
52
67
  UCAN_AUTH_MAX_TTL_SECONDS: z.coerce.number().int().positive().default(900),
68
+ /**
69
+ * `true` lets a bare `x-ucan-delegation` (no invocation) authenticate a
70
+ * request — the legacy fallback, for clients that do not send a UCAN
71
+ * invocation yet. Off by default: a delegation travels on as proof in
72
+ * downstream invocations, so it must not also be a login credential.
73
+ */
74
+ UCAN_ALLOW_BARE_DELEGATION_AUTH: z.enum(['true', 'false']).optional(),
53
75
 
54
76
  // --- matrix -------------------------------------------------------------
55
77
  MATRIX_BASE_URL: z.string().min(1),
@@ -114,6 +136,45 @@ export const baseEnvSchema = z.object({
114
136
  .int()
115
137
  .min(1)
116
138
  .default(TURN_RECURSION_LIMIT_DEFAULT),
139
+ /**
140
+ * The turn budget (docs/plans/workers-harness-hardening.md): cumulative
141
+ * model tokens across the main agent, sub-agents and helper models; tool
142
+ * attempts; and the wall-clock deadline. Exhaustion ends the turn and keeps
143
+ * the work done so far. Parsed by `turnLimitsFromEnv`.
144
+ */
145
+ TURN_MAX_TOKENS: z.coerce.number().int().positive().default(500_000),
146
+ TURN_MAX_TOOL_CALLS: z.coerce.number().int().positive().default(120),
147
+ TURN_TIMEOUT_MS: z.coerce.number().int().positive().default(600_000),
148
+ /**
149
+ * The repetition guard's per-turn caps on identical successful calls
150
+ * (same tool, same arguments): a write, and a read or `repeatable` tool.
151
+ * Parsed by `repetitionCapsFromEnv`.
152
+ */
153
+ TURN_MAX_IDENTICAL_WRITES: z.coerce.number().int().positive().default(1),
154
+ TURN_MAX_IDENTICAL_READS: z.coerce.number().int().positive().default(5),
155
+ /**
156
+ * Time limit per plugin `getRequestAdmission` handler (ms). A handler that
157
+ * has not answered by then counts as `pass` and the turn goes on to the
158
+ * next handler or the agent. See docs/architecture/request-admission.md.
159
+ */
160
+ REQUEST_ADMISSION_TIMEOUT_MS: z.coerce
161
+ .number()
162
+ .int()
163
+ .positive()
164
+ .default(2_000),
165
+
166
+ // --- decisions ----------------------------------------------------------
167
+ // Bounded semantic Decision provider (`DECISION_PROVIDER`, `DECISION_MODEL`,
168
+ // Cloudflare credentials), shared with the Node runtime and optional so
169
+ // oracles without Decisions keep their behaviour. `openrouter-jev` reuses
170
+ // `OPEN_ROUTER_API_KEY` (required above). On Workers `cloudflare-jev`
171
+ // prefers the `AI` binding over the account credentials, which are only
172
+ // needed when the binding is not declared.
173
+ ...decisionProviderEnvShape,
174
+ // `CAPABILITY_ROUTER` (`off` | `shadow` | `on`, default `off`): the first
175
+ // runtime consumer of Decisions. It predicts the on-demand plugin a message
176
+ // needs and preloads it for the turn; it needs a `DECISION_PROVIDER`.
177
+ ...capabilityRouterEnvShape,
117
178
 
118
179
  // --- durable runs (docs/plans/durable-runs.md) ---------------------------
119
180
  // Parsed by `runDurabilityConfig` (src/do/run-store.ts) with defaults:
@@ -144,9 +205,49 @@ export const baseEnvSchema = z.object({
144
205
  TOOL_RESULT_TTL_HOURS: z.string().optional(),
145
206
  TOOL_RESULT_R2_MIN_BYTES: z.string().optional(),
146
207
 
208
+ // --- chat delivery (docs/chat-delivery.md) -------------------------------
209
+ /**
210
+ * `true` gives Matrix room turns the chat style (several short messages,
211
+ * long content as artefacts); unset keeps `OracleConfig.delivery.matrixChat`,
212
+ * which defaults to one message per reply.
213
+ */
214
+ MATRIX_CHAT_DELIVERY: z.enum(['true', 'false']).optional(),
215
+ /**
216
+ * Artefact links (the bucket is a binding, `ARTIFACT_BUCKET`): the oracle's
217
+ * public origin, an optional shared viewer page, and the link lifetime in
218
+ * days. Links carry the decryption key, so both URLs must be https (http
219
+ * only on localhost).
220
+ */
221
+ ORACLE_PUBLIC_URL: secureUrlSchema.optional(),
222
+ ARTIFACT_VIEWER_URL: secureUrlSchema.optional(),
223
+ ARTIFACT_LINK_TTL_DAYS: z.coerce
224
+ .number()
225
+ .int()
226
+ .min(1)
227
+ .max(MAX_ARTIFACT_TTL_DAYS)
228
+ .optional(),
229
+ /**
230
+ * Hours between the cron's sweeps of expired share copies in the bucket
231
+ * (unset = 24). Only runs where `ARTIFACT_BUCKET` is bound and a cron
232
+ * trigger is declared.
233
+ */
234
+ ARTIFACT_SWEEP_INTERVAL_HOURS: z.coerce
235
+ .number()
236
+ .int()
237
+ .min(1)
238
+ .max(MAX_ARTIFACT_SWEEP_INTERVAL_HOURS)
239
+ .optional(),
240
+
147
241
  // --- misc ---------------------------------------------------------------
148
242
  LOG_LEVEL: z.string().default('info'),
149
243
  CORS_ORIGIN: z.string().default('*'),
244
+ /**
245
+ * Comma-separated DIDs allowed onto the gateway-wide operator routes
246
+ * (`/debug/matrix/*`). Unset: any authenticated caller (with
247
+ * `ORACLE_DEBUG_ROUTES=true`). A list with an entry that is not a DID
248
+ * admits nobody.
249
+ */
250
+ ORACLE_OPERATOR_DIDS: z.string().optional(),
150
251
  });
151
252
 
152
253
  export type BaseEnv = z.infer<typeof baseEnvSchema>;
@@ -207,8 +308,18 @@ export function composeEnvSchema(
207
308
  if (!plugin.configSchema) continue;
208
309
  const shape = plugin.configSchema.shape as Record<string, unknown>;
209
310
  for (const key of Object.keys(shape)) {
311
+ // Runtime-only secrets (the feedback sink's Linear key and pseudonym
312
+ // secret) must never reach a plugin's `ctx.config`.
313
+ if (key.startsWith(FEEDBACK_ENV_PREFIX))
314
+ throw new Error(
315
+ `Plugin '${plugin.name}' declares env key '${key}': the '${FEEDBACK_ENV_PREFIX}' prefix is reserved for the runtime`,
316
+ );
210
317
  const previous = pluginOwnership.get(key);
211
318
  if (previous !== undefined && previous !== plugin.name) {
319
+ // Plugins that declare the very same schema object share the key on
320
+ // purpose (the wallet-signing chain ids): nothing is replaced, and
321
+ // the first declarer stays its owner.
322
+ if (merged.shape[key] === shape[key]) continue;
212
323
  logger.warn(
213
324
  `[boot] env key '${key}' is defined by both '${previous}' and '${plugin.name}'; '${plugin.name}' wins.`,
214
325
  );
@@ -0,0 +1,17 @@
1
+ export const TASK_EXECUTION_PROFILES = ['supplied-context-markdown'] as const;
2
+
3
+ export type TaskExecutionProfile = (typeof TASK_EXECUTION_PROFILES)[number];
4
+
5
+ export function isTaskExecutionProfile(
6
+ value: unknown,
7
+ ): value is TaskExecutionProfile {
8
+ return (TASK_EXECUTION_PROFILES as readonly unknown[]).includes(value);
9
+ }
10
+
11
+ export function taskExecutionProfile(
12
+ value: unknown,
13
+ ): TaskExecutionProfile | undefined {
14
+ if (value === undefined || value === null) return undefined;
15
+ if (isTaskExecutionProfile(value)) return value;
16
+ throw new Error('Unsupported task execution profile');
17
+ }