@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
@@ -20,7 +20,11 @@ import type {
20
20
  PluginTool,
21
21
  RuntimeContext,
22
22
  } from '../../plugin-api/types';
23
- import { logActionToMatrix } from '../portal/action-log';
23
+ import { logFrontendAction } from '../portal/action-log';
24
+ import {
25
+ describeDropped,
26
+ sanitizeDeclaredTools,
27
+ } from '../portal/declared-tools';
24
28
  import { createAguiSubAgent } from './agui-agent';
25
29
 
26
30
  const manifest: PluginManifest = {
@@ -60,12 +64,16 @@ const AG_ACTION_SHAPE = z.object({
60
64
 
61
65
  const ARGS_RECORD_SHAPE = z.record(z.string(), z.unknown());
62
66
 
67
+ /** The client's AG-UI actions; a name declared twice keeps its first descriptor. */
63
68
  export function parseAgActions(value: unknown): AgAction[] {
64
69
  if (!Array.isArray(value)) return [];
65
70
  const out: AgAction[] = [];
71
+ const names = new Set<string>();
66
72
  for (const entry of value) {
67
73
  const parsed = AG_ACTION_SHAPE.safeParse(entry);
68
- if (parsed.success) out.push(parsed.data);
74
+ if (!parsed.success || names.has(parsed.data.name)) continue;
75
+ names.add(parsed.data.name);
76
+ out.push(parsed.data);
69
77
  }
70
78
  return out;
71
79
  }
@@ -95,36 +103,43 @@ export function buildActionTool(action: AgAction): PluginTool | null {
95
103
  `No browser is connected to session ${sessionId}, so AG-UI action ${action.name} cannot run — the client must open the realtime (socket.io) channel for this session first.`,
96
104
  );
97
105
  }
98
- const args = parseArgs(input);
99
- const requestId = ctx.session.requestId;
100
- const toolCallId = `ag_${requestId || 'noreq'}_${crypto
101
- .randomUUID()
102
- .slice(0, 8)}`;
106
+ // The bridge makes every invocation unique (`ag_<requestId>:<uuid>`).
107
+ let invocationId: string | undefined;
103
108
  const result = await frontend.callAgAction({
104
109
  sessionId,
105
- toolCallId,
110
+ toolCallId: `ag_${ctx.session.requestId || 'noreq'}`,
106
111
  toolName: action.name,
107
- args,
112
+ args: parseArgs(input),
108
113
  timeoutMs: AG_ACTION_TIMEOUT_MS,
114
+ signal: ctx.abortSignal,
115
+ onInvocation: (id) => {
116
+ invocationId = id;
117
+ },
109
118
  });
110
- logActionToMatrix(ctx, {
111
- name: action.name,
112
- args,
113
- result,
114
- success: true,
115
- });
119
+ logFrontendAction(ctx, { name: action.name, invocationId, result });
116
120
  return JSON.stringify(result);
117
121
  },
118
122
  {
119
123
  name: action.name,
120
124
  description: action.description,
121
125
  schema,
126
+ // A UI action on the user's screen: running it again is a new action.
127
+ repeatable: true,
122
128
  },
123
129
  );
124
130
  }
125
131
 
132
+ /**
133
+ * The AG-UI actions declared on this request that may become tools: invalid,
134
+ * duplicate and oversized declarations are dropped (`declared-tools.ts`).
135
+ */
126
136
  function readAgActions(rtCtx: RuntimeContext): AgAction[] {
127
- return parseAgActions(rtCtx.history.state.agActions);
137
+ const { kept, dropped } = sanitizeDeclaredTools(
138
+ parseAgActions(rtCtx.history.state.agActions),
139
+ );
140
+ const summary = describeDropped('AG-UI actions', dropped);
141
+ if (summary) rtCtx.logger.warn(summary);
142
+ return kept;
128
143
  }
129
144
 
130
145
  export class AGUIPlugin extends OraclePlugin {
@@ -2,6 +2,7 @@ import { Composio } from '@composio/core';
2
2
  import { LangchainProvider } from '@composio/langchain';
3
3
  import { z } from 'zod';
4
4
  import type { PluginTool, RuntimeContext } from '../../plugin-api/types';
5
+ import { withCallTimeout } from '../mcp-call-timeout';
5
6
 
6
7
  /**
7
8
  * Minimal shape of a LangChain `DynamicStructuredTool` the composio session
@@ -110,9 +111,10 @@ const refreshInFlight = new Set<string>();
110
111
 
111
112
  /**
112
113
  * Drop long-expired entries (and, if still over the cap, the
113
- * soonest-to-expire ones). Runs on every cache write, so in a long-running
114
- * multi-tenant isolate one-off users' schemas are reclaimed instead of
115
- * accumulating for the isolate lifetime. Entries get one extra TTL of grace
114
+ * soonest-to-expire ones). Runs on every cache lookup and write, so in a
115
+ * long-running multi-tenant isolate one-off users' schemas are reclaimed
116
+ * instead of accumulating for the isolate lifetime, even when the replacement
117
+ * session open fails and no write follows. Entries get one extra TTL of grace
116
118
  * past expiry — a recently-expired entry is still served stale while its
117
119
  * background refresh runs, so reclaiming it immediately would put the
118
120
  * session-open round-trip back on that user's next turn.
@@ -209,10 +211,47 @@ function normalizeArgs(toolName: string, args: unknown): unknown {
209
211
  };
210
212
  }
211
213
 
214
+ /**
215
+ * Upper bound on one Composio tool call. The Composio SDK gives each HTTP
216
+ * attempt 60 s and retries a timed-out or failed attempt twice with a short
217
+ * back-off (about 182 s in the worst case), so this sits just past that and
218
+ * the SDK's own answer arrives first. What it catches is what the SDK leaves
219
+ * open: its timer stops once the response headers arrive, so a body that
220
+ * stalls after them would otherwise be waited on forever.
221
+ */
222
+ export const COMPOSIO_TOOL_TIMEOUT_MS = 190_000;
223
+
224
+ /**
225
+ * A promise that rejects when `signal` aborts (at once if it already has),
226
+ * plus the call that stops listening. The SDK's LangChain tools take no
227
+ * signal, so the turn's cancellation is applied by racing against this.
228
+ */
229
+ function rejectOnAbort(signal: AbortSignal): {
230
+ promise: Promise<never>;
231
+ dispose: () => void;
232
+ } {
233
+ let onAbort = (): void => undefined;
234
+ const promise = new Promise<never>((_, reject) => {
235
+ onAbort = () => {
236
+ const reason: unknown = signal.reason;
237
+ reject(reason instanceof Error ? reason : new Error(String(reason)));
238
+ };
239
+ });
240
+ if (signal.aborted) onAbort();
241
+ else signal.addEventListener('abort', onAbort, { once: true });
242
+ return {
243
+ promise,
244
+ dispose: () => signal.removeEventListener('abort', onAbort),
245
+ };
246
+ }
247
+
212
248
  /**
213
249
  * Wrap a single composio session tool as a {@link PluginTool}. The handler
214
- * forwards the args through the underlying `invoke` and propagates errors so
215
- * the agent surfaces a clean failure rather than a silent empty response.
250
+ * forwards the args through the underlying `invoke`, bounded by
251
+ * {@link COMPOSIO_TOOL_TIMEOUT_MS} and the turn's abort signal (the request
252
+ * itself is abandoned, not cancelled: the SDK offers no way to stop it), and
253
+ * propagates errors so the agent surfaces a clean failure rather than a
254
+ * silent empty response.
216
255
  */
217
256
  function wrapAsPluginTool(sessionTool: ComposioSessionTool): PluginTool {
218
257
  return {
@@ -220,8 +259,17 @@ function wrapAsPluginTool(sessionTool: ComposioSessionTool): PluginTool {
220
259
  description: sessionTool.description,
221
260
  schema: toolSchema(sessionTool.schema),
222
261
  handler: async (args, ctx: RuntimeContext) => {
262
+ const cancelled = rejectOnAbort(ctx.abortSignal);
223
263
  try {
224
- return await sessionTool.invoke(normalizeArgs(sessionTool.name, args));
264
+ return await withCallTimeout(
265
+ () =>
266
+ Promise.race([
267
+ sessionTool.invoke(normalizeArgs(sessionTool.name, args)),
268
+ cancelled.promise,
269
+ ]),
270
+ COMPOSIO_TOOL_TIMEOUT_MS,
271
+ `composio tool ${sessionTool.name}`,
272
+ );
225
273
  } catch (error) {
226
274
  const detail =
227
275
  error instanceof Error
@@ -231,6 +279,8 @@ function wrapAsPluginTool(sessionTool: ComposioSessionTool): PluginTool {
231
279
  `[composio] tool "${sessionTool.name}" failed: ${detail}`,
232
280
  );
233
281
  throw error;
282
+ } finally {
283
+ cancelled.dispose();
234
284
  }
235
285
  },
236
286
  };
@@ -307,6 +357,7 @@ export async function createComposioTools(
307
357
  };
308
358
 
309
359
  const cacheKey = defsCacheKey(opts.baseUrl, opts.userId);
360
+ if (opts.defsCache) pruneDefsCache(opts.defsCache, Date.now());
310
361
  const cached = opts.defsCache?.get(cacheKey);
311
362
  if (cached) {
312
363
  // An expired entry is served as-is (the session meta-toolset only shifts
@@ -1,4 +1,5 @@
1
1
  import type { RuntimeContext } from '../../plugin-api/types';
2
+ import { SANDBOX_CAPABILITY } from '../delegated-capabilities';
2
3
  import { mintInvocationSafely, resolveServiceDidSafely } from '../ucan-failure';
3
4
 
4
5
  /**
@@ -33,11 +34,11 @@ export async function mintComposioInvocation(
33
34
  // `401 REPLAY: Invocation has already been used`. Mint fresh every call.
34
35
  return mintInvocationSafely(
35
36
  runCtx,
36
- { did: composioDid, capability: 'ixo:sandbox' },
37
+ { did: composioDid, capability: SANDBOX_CAPABILITY.resource },
37
38
  'composio',
38
39
  // can: composio routes through the sandbox capability, and the user's
39
40
  // delegation grants `sandbox/*`. A `'*'` claim is satisfiable only by a
40
41
  // `'*'` grant — `'*'.startsWith('sandbox/')` is false.
41
- { skipCache: true, can: 'sandbox/*' },
42
+ { skipCache: true, can: SANDBOX_CAPABILITY.action },
42
43
  );
43
44
  }
@@ -11,6 +11,7 @@ import {
11
11
  type ComposioSessionFactory,
12
12
  } from './composio-tools';
13
13
  import { mintComposioInvocation } from './composio-ucan';
14
+ import { SANDBOX_CAPABILITY } from '../delegated-capabilities';
14
15
 
15
16
  const configSchema = z.object({
16
17
  COMPOSIO_API_KEY: z.string().min(1, 'COMPOSIO_API_KEY must not be empty.'),
@@ -100,6 +101,7 @@ const manifest: PluginManifest = {
100
101
  category: 'integration',
101
102
  visibility: 'on-demand',
102
103
  stability: 'stable',
104
+ requires: [SANDBOX_CAPABILITY],
103
105
  };
104
106
 
105
107
  export interface ComposioPluginOptions {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Capabilities bundled plugins prove through the user's delegation to this
3
+ * oracle. Each is both the plugin's `manifest.requires` and the capability
4
+ * its invocations claim, so what the gate checks and what the downstream
5
+ * service is shown cannot drift apart. A plugin that authorizes some other
6
+ * way (VFS fetches its own `ixo:filesystem` delegation from the UCAN store)
7
+ * or degrades without a grant (skills fall back to public capsules) declares
8
+ * nothing.
9
+ */
10
+ export interface DelegatedCapability {
11
+ readonly resource: string;
12
+ readonly action: string;
13
+ }
14
+
15
+ /** Memory Engine MCP: the memory plugin's tools. */
16
+ export const MEMORY_CAPABILITY: DelegatedCapability = {
17
+ resource: 'ixo:memory',
18
+ action: 'memory/*',
19
+ };
20
+
21
+ /** Sandbox MCP, and Composio, which routes through the sandbox. */
22
+ export const SANDBOX_CAPABILITY: DelegatedCapability = {
23
+ resource: 'ixo:sandbox',
24
+ action: 'sandbox/*',
25
+ };
@@ -5,17 +5,19 @@
5
5
  * document. It enforces, in this order:
6
6
  *
7
7
  * 1. a live flow is read-only → `read_only_flow`
8
- * 2. the provider already knows it cannot write → `needs_access`
8
+ * 2. an earlier write in this session failed → the same answer as step 6
9
9
  * 3. the oracle's power level is below the room's write threshold → `needs_access`
10
10
  * 4. the prop allowlist (the caller's `plan` step) → `prop_not_editable` etc.
11
11
  * 5. one `doc.transact()` for the whole batch
12
- * 6. `await provider.waitForFlush()` **before** success is reported
13
- * 7. a post-flush write check → `needs_access`
12
+ * 6. the provider manager's bounded `flush()` **before** success is reported:
13
+ * a forbidden write → `needs_access`, a definite refusal (413, 401,
14
+ * 400) → `error`, an unacknowledged one → `write_not_saved`
14
15
  *
15
- * Steps 6 and 7 exist because matrix-crdt writes are fire-and-forget: a Y.Doc
16
- * mutation always "succeeds" locally, and a homeserver rejection surfaces only
17
- * once the batched update is actually sent. Reporting success before the flush
18
- * is how tools ended up claiming edits that never landed.
16
+ * Step 6 exists because matrix-crdt writes are fire-and-forget: a Y.Doc
17
+ * mutation always "succeeds" locally, and a homeserver rejection or a failed
18
+ * delivery surfaces only once the batched update is actually sent. Reporting
19
+ * success before the flush is how tools ended up claiming edits that never
20
+ * landed.
19
21
  */
20
22
 
21
23
  import type { MatrixClient } from 'matrix-js-sdk';
@@ -29,36 +31,40 @@ import {
29
31
  } from './document-model';
30
32
  import {
31
33
  editorError,
32
- flushTimeout,
33
34
  isEditorFailure,
34
35
  needsAccess,
35
36
  readOnlyFlow,
37
+ writeNotSaved,
38
+ writeRefused,
36
39
  type EditorFailure,
37
40
  } from './failures';
38
41
  import {
42
+ DOC_UPDATE_EVENT_TYPE,
39
43
  MatrixProviderManager,
40
44
  RoomNotAccessibleError,
41
45
  type AppConfig,
46
+ type DocWriteFailure,
42
47
  } from './provider';
43
48
 
44
49
  const logger = createConsoleLogger({ module: 'editor-content-session' });
45
50
 
46
- /** The Matrix event type matrix-crdt writes Y.Doc updates as. */
47
- const CRDT_UPDATE_EVENT_TYPE = 'matrix-crdt.doc_update';
48
-
49
51
  /** Rooms whose canonical alias starts with this hold live flows. */
50
52
  const FLOW_ALIAS_PREFIX = '#flow-';
51
53
 
52
- /** How long to wait for a write to reach the homeserver before giving up. */
53
- const FLUSH_TIMEOUT_MS = 20_000;
54
-
55
54
  /**
56
- * What the write path needs from the CRDT provider. `MatrixProvider` satisfies
57
- * it; narrowing keeps the guards testable without a Matrix connection.
55
+ * What the write path needs from the document's provider manager.
56
+ * `MatrixProviderManager` satisfies it; narrowing keeps the guards testable
57
+ * without a Matrix connection.
58
58
  */
59
59
  export interface DocumentWriter {
60
- readonly canWrite: boolean;
61
- waitForFlush(): Promise<void>;
60
+ /** Why an earlier write did not reach the room, if one did not. */
61
+ readonly writeFailure: DocWriteFailure | undefined;
62
+ /**
63
+ * Wait (bounded by the provider's own deadline and write-retry budget)
64
+ * until every change made so far has been sent; resolves to the failure
65
+ * when one did not land.
66
+ */
67
+ flush(): Promise<DocWriteFailure | undefined>;
62
68
  }
63
69
 
64
70
  /** What the write path needs from the Matrix client. */
@@ -73,7 +79,7 @@ export interface RoomStateReader {
73
79
 
74
80
  export interface DocumentSession {
75
81
  doc: Y.Doc;
76
- provider: DocumentWriter;
82
+ writer: DocumentWriter;
77
83
  matrixClient: RoomStateReader;
78
84
  roomId: string;
79
85
  /** Canonical alias, when the homeserver exposes one. */
@@ -136,18 +142,41 @@ async function resolveAlias(
136
142
  }
137
143
  }
138
144
 
145
+ /** Where a document lives and the client that reaches it. */
146
+ export interface DocumentParams {
147
+ matrixClient: MatrixClient;
148
+ appConfig: AppConfig;
149
+ }
150
+
151
+ /** An open document plus the call that releases it. */
152
+ export interface OpenedDocument {
153
+ session: DocumentSession;
154
+ dispose: () => Promise<void>;
155
+ }
156
+
157
+ /** Opens a document; a failure to reach it comes back typed, never thrown. */
158
+ export type DocumentOpener = (
159
+ params: DocumentParams,
160
+ ) => Promise<OpenedDocument | EditorFailure>;
161
+
162
+ /** A thrown open/work error, as the typed failure a tool returns. */
163
+ function documentFailure(error: unknown, roomId: string): EditorFailure {
164
+ if (error instanceof RoomNotAccessibleError) {
165
+ return needsAccess(roomId, 'this assistant cannot open the document');
166
+ }
167
+ const detail = error instanceof Error ? error.message : String(error);
168
+ logger.warn(`Failed to open document ${roomId}: ${detail}`);
169
+ return editorError(`Could not open the document: ${detail}`, roomId);
170
+ }
171
+
139
172
  /**
140
- * Open the room's Y.Doc, run `work`, and always dispose the provider.
141
- *
142
- * Access problems come back as typed failures rather than exceptions, so every
143
- * tool can return them straight to the agent.
173
+ * Open the room's Y.Doc through matrix-crdt. On failure the provider is
174
+ * already disposed; on success the caller owns `dispose`.
144
175
  */
145
- export async function withDocument<T>(
146
- params: { matrixClient: MatrixClient; appConfig: AppConfig },
147
- work: (session: DocumentSession) => Promise<T>,
148
- ): Promise<T | EditorFailure> {
149
- const { matrixClient, appConfig } = params;
150
-
176
+ export const openDocumentSession: DocumentOpener = async ({
177
+ matrixClient,
178
+ appConfig,
179
+ }) => {
151
180
  let roomId: string;
152
181
  try {
153
182
  roomId = await resolveRoomId(matrixClient, appConfig);
@@ -158,26 +187,97 @@ export async function withDocument<T>(
158
187
 
159
188
  const manager = new MatrixProviderManager(matrixClient, appConfig);
160
189
  try {
161
- const { doc, provider } = await manager.init();
190
+ const { doc } = await manager.init();
162
191
  const alias = await resolveAlias(matrixClient, appConfig, roomId);
163
- return await work({
164
- doc,
165
- provider,
166
- matrixClient,
167
- roomId,
168
- alias,
169
- isFlow: isFlowAlias(alias),
170
- });
192
+ return {
193
+ session: {
194
+ doc,
195
+ writer: manager,
196
+ matrixClient,
197
+ roomId,
198
+ alias,
199
+ isFlow: isFlowAlias(alias),
200
+ },
201
+ dispose: () => manager.dispose(),
202
+ };
171
203
  } catch (error) {
172
- if (error instanceof RoomNotAccessibleError) {
173
- return needsAccess(roomId, 'this assistant cannot open the document');
174
- }
175
- const detail = error instanceof Error ? error.message : String(error);
176
- logger.warn(`Failed to open document ${roomId}: ${detail}`);
177
- return editorError(`Could not open the document: ${detail}`, roomId);
178
- } finally {
179
204
  await manager.dispose();
205
+ return documentFailure(error, roomId);
180
206
  }
207
+ };
208
+
209
+ /**
210
+ * How the content tools reach their document. `use` runs `work` against an
211
+ * open session; access problems and thrown errors come back as typed
212
+ * failures so every tool can return them straight to the agent.
213
+ */
214
+ export interface DocumentSource {
215
+ use<T>(
216
+ work: (session: DocumentSession) => Promise<T>,
217
+ ): Promise<T | EditorFailure>;
218
+ }
219
+
220
+ /**
221
+ * One document session shared by every tool call until `close()`: opened on
222
+ * the first `use` (so a run that never touches the document never opens it),
223
+ * then reused, so a multi-step task pays the join, history walk and settle
224
+ * delay once. Reads see the live doc; writes still go through
225
+ * `applyDocumentEdit`. A failed open is not remembered — the next `use`
226
+ * tries again. After `close()` every `use` is refused; `close()` waits for
227
+ * calls still running (a write awaiting its flush) before disposing, so a
228
+ * cancelled run never destroys the doc under an in-flight write.
229
+ */
230
+ export function sharedDocumentSource(
231
+ params: DocumentParams,
232
+ open: DocumentOpener = openDocumentSession,
233
+ ): DocumentSource & { close(): Promise<void> } {
234
+ let opening: Promise<OpenedDocument | EditorFailure> | null = null;
235
+ let closed = false;
236
+ const inFlight = new Set<Promise<unknown>>();
237
+
238
+ const acquire = (): Promise<OpenedDocument | EditorFailure> => {
239
+ if (opening) return opening;
240
+ const attempt = open(params);
241
+ opening = attempt;
242
+ void attempt.then((result) => {
243
+ if (isEditorFailure(result) && opening === attempt) opening = null;
244
+ });
245
+ return attempt;
246
+ };
247
+
248
+ const run = async <T>(
249
+ work: (session: DocumentSession) => Promise<T>,
250
+ ): Promise<T | EditorFailure> => {
251
+ const opened = await acquire();
252
+ if (isEditorFailure(opened)) return opened;
253
+ try {
254
+ return await work(opened.session);
255
+ } catch (error) {
256
+ return documentFailure(error, opened.session.roomId);
257
+ }
258
+ };
259
+
260
+ return {
261
+ async use(work) {
262
+ if (closed) return editorError('The document session has ended.');
263
+ const call = run(work);
264
+ inFlight.add(call);
265
+ try {
266
+ return await call;
267
+ } finally {
268
+ inFlight.delete(call);
269
+ }
270
+ },
271
+ async close() {
272
+ closed = true;
273
+ await Promise.allSettled([...inFlight]);
274
+ const pending = opening;
275
+ opening = null;
276
+ if (!pending) return;
277
+ const opened = await pending;
278
+ if (!isEditorFailure(opened)) await opened.dispose();
279
+ },
280
+ };
181
281
  }
182
282
 
183
283
  /** A live flow's room alias is `#flow-…`; a page's is `#page-…`. */
@@ -223,7 +323,7 @@ export async function canOracleWrite(
223
323
  const eventsDefault = numberAt(content, 'events_default', 0);
224
324
  const required = numberAt(
225
325
  content.events,
226
- CRDT_UPDATE_EVENT_TYPE,
326
+ DOC_UPDATE_EVENT_TYPE,
227
327
  eventsDefault,
228
328
  );
229
329
 
@@ -231,34 +331,20 @@ export async function canOracleWrite(
231
331
  }
232
332
 
233
333
  /**
234
- * `canWrite` is a live getter that flips when the homeserver rejects a write,
235
- * so it is read through a call rather than inline: a direct property read would
236
- * let the compiler narrow it to its earlier value.
334
+ * The tool result for a write that did not land: the homeserver forbidding it
335
+ * means the assistant lacks access; a definite refusal means nothing was
336
+ * stored; otherwise delivery is uncertain.
237
337
  */
238
- function currentlyWritable(writer: DocumentWriter): boolean {
239
- return writer.canWrite;
240
- }
241
-
242
- async function waitForFlushBounded(
243
- provider: DocumentWriter,
244
- ): Promise<'flushed' | 'timeout' | 'error'> {
245
- let timer: ReturnType<typeof setTimeout> | undefined;
246
- const timeout = new Promise<'timeout'>((resolve) => {
247
- timer = setTimeout(() => resolve('timeout'), FLUSH_TIMEOUT_MS);
248
- });
249
- try {
250
- return await Promise.race([
251
- provider.waitForFlush().then((): 'flushed' => 'flushed'),
252
- timeout,
253
- ]);
254
- } catch (error) {
255
- logger.warn(
256
- `waitForFlush rejected: ${error instanceof Error ? error.message : String(error)}`,
257
- );
258
- return 'error';
259
- } finally {
260
- if (timer) clearTimeout(timer);
261
- }
338
+ function writeFailureResult(
339
+ failure: DocWriteFailure,
340
+ roomId: string,
341
+ forbiddenDetail: string,
342
+ ): EditorFailure {
343
+ if (failure.kind === 'forbidden') return needsAccess(roomId, forbiddenDetail);
344
+ logger.warn(`Document write to ${roomId} not delivered: ${failure.detail}`);
345
+ return failure.mayHaveLanded
346
+ ? writeNotSaved(roomId, failure.detail)
347
+ : writeRefused(roomId, failure.detail);
262
348
  }
263
349
 
264
350
  /**
@@ -283,8 +369,12 @@ export async function applyDocumentEdit<TPlan, TResult>(
283
369
  return readOnlyFlow(session.roomId, session.alias);
284
370
  }
285
371
 
286
- if (!currentlyWritable(session.provider)) {
287
- return needsAccess(
372
+ // Once a write in this session has failed, the provider refuses every
373
+ // further one, so report that failure instead of attempting another.
374
+ const earlier = session.writer.writeFailure;
375
+ if (earlier) {
376
+ return writeFailureResult(
377
+ earlier,
288
378
  session.roomId,
289
379
  'a previous write to this document was rejected',
290
380
  );
@@ -313,15 +403,15 @@ export async function applyDocumentEdit<TPlan, TResult>(
313
403
  if (titleSeed) seedDocumentTitle(session.doc, titleSeed);
314
404
  }, EDIT_ORIGIN);
315
405
 
316
- const flush = await waitForFlushBounded(session.provider);
317
- if (flush !== 'flushed') {
318
- return flushTimeout(session.roomId);
319
- }
320
-
321
- // The homeserver's verdict only arrives with the flush; a rejection here
406
+ // The homeserver's verdict only arrives with the flush; a failure here
322
407
  // means the mutation exists locally but never reached the room.
323
- if (!currentlyWritable(session.provider)) {
324
- return needsAccess(session.roomId, 'the homeserver rejected the write');
408
+ const failure = await session.writer.flush();
409
+ if (failure) {
410
+ return writeFailureResult(
411
+ failure,
412
+ session.roomId,
413
+ 'the homeserver rejected the write',
414
+ );
325
415
  }
326
416
 
327
417
  if (result === undefined) {