@ixo/oracle-runtime-workers 0.13.0 → 0.16.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 +310 -0
  18. package/src/channels/contract.ts +246 -0
  19. package/src/channels/turns.ts +280 -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 +2003 -274
  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 +562 -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
@@ -3,6 +3,7 @@ import { tool } from '../../plugin-api/tool-helper';
3
3
  import type { PluginTool, RuntimeContext } from '../../plugin-api/types';
4
4
  import { vfsBearer } from './vfs-auth';
5
5
  import {
6
+ hasGlobWildcard,
6
7
  VfsClient,
7
8
  type VfsBatchItemResult,
8
9
  type VfsSearchHit,
@@ -191,6 +192,31 @@ async function withConflictRetry<T>(op: () => Promise<T>): Promise<T> {
191
192
  }
192
193
  }
193
194
 
195
+ /** Appended wherever a path resolved to no file — folders never resolve. */
196
+ const FILES_ONLY_NOTE =
197
+ 'Paths must name files exactly — folders and wildcard patterns are not accepted (find files with `vfs_glob`).';
198
+
199
+ /** The answer for a move/delete source that is not a file. */
200
+ function notAFileMessage(path: string, verb: string): string {
201
+ return `No file at \`${path}\` to ${verb}. ${FILES_ONLY_NOTE}`;
202
+ }
203
+
204
+ /**
205
+ * Map `items` through `fn` with at most `width` calls in flight, preserving
206
+ * order. Each batch settles before the next starts.
207
+ */
208
+ async function mapInBatches<T, R>(
209
+ items: readonly T[],
210
+ width: number,
211
+ fn: (item: T) => Promise<R>,
212
+ ): Promise<R[]> {
213
+ const out: R[] = [];
214
+ for (let i = 0; i < items.length; i += width) {
215
+ out.push(...(await Promise.all(items.slice(i, i + width).map(fn))));
216
+ }
217
+ return out;
218
+ }
219
+
194
220
  export function invalidArgs(err: z.ZodError): string {
195
221
  return `Invalid arguments: ${err.issues
196
222
  .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
@@ -291,16 +317,32 @@ const editSchema = z.object({
291
317
  });
292
318
 
293
319
  const moveSchema = z.object({
294
- from: z.string().describe('Absolute source path.'),
320
+ from: z.string().describe('Absolute path of the file to move.'),
295
321
  to: z.string().describe('Absolute destination path.'),
296
322
  });
297
323
 
324
+ /**
325
+ * Most paths one `vfs_delete` call accepts. Each path costs one `/glob`
326
+ * lookup (plus, on a cold delegation cache, a UCAN-store fetch), so the cap
327
+ * keeps a call well inside the per-invocation subrequest budget.
328
+ */
329
+ export const VFS_DELETE_MAX_PATHS = 50;
330
+
331
+ /**
332
+ * Path lookups `vfs_delete` keeps in flight at once — the Workers limit on
333
+ * simultaneous open connections per invocation. Requests beyond it would
334
+ * queue inside `fetch` while their timeout is already running.
335
+ */
336
+ export const VFS_PATH_LOOKUP_CONCURRENCY = 6;
337
+
298
338
  const deleteSchema = z.object({
299
339
  paths: z
300
340
  .array(z.string())
301
341
  .min(1)
302
- .max(1000)
303
- .describe('Absolute paths to move to trash.'),
342
+ .max(VFS_DELETE_MAX_PATHS)
343
+ .describe(
344
+ `Absolute paths of the files to move to trash (at most ${VFS_DELETE_MAX_PATHS} per call).`,
345
+ ),
304
346
  });
305
347
 
306
348
  const shareSchema = z.object({
@@ -529,13 +571,7 @@ export function createVfsTools(deps: CreateVfsToolsDeps): PluginTool[] {
529
571
  withConflictRetry(async () => {
530
572
  // The worker addresses moves by file id, not source path — resolve it.
531
573
  const stat = await c.statByPath(from);
532
- if (!stat) {
533
- throw new VfsHttpError({
534
- status: 404,
535
- message: `No such file at ${from}`,
536
- raw: '',
537
- });
538
- }
574
+ if (!stat) return notAFileMessage(from, 'move');
539
575
  const results = await c.move([{ id: stat.id, destinationPath: to }]);
540
576
  const failed = results.find((r) => !r.ok);
541
577
  if (failed) {
@@ -548,7 +584,7 @@ export function createVfsTools(deps: CreateVfsToolsDeps): PluginTool[] {
548
584
  {
549
585
  name: 'vfs_move',
550
586
  description:
551
- 'Move or rename a file or folder. Confirm destructive actions with the user first.',
587
+ 'Move or rename one file (`from` must be the exact path of a file; wildcards and folders are not accepted). To move a folder, find its files with `vfs_glob` and move each. Confirm destructive actions with the user first.',
552
588
  schema: moveSchema,
553
589
  },
554
590
  );
@@ -561,33 +597,32 @@ export function createVfsTools(deps: CreateVfsToolsDeps): PluginTool[] {
561
597
  const err = validatePath(p);
562
598
  if (err) return `Invalid path "${p}": ${err}`;
563
599
  }
600
+ const paths = [...new Set(parsed.data.paths)];
564
601
  const c = client(ctx);
565
602
  return guard(ctx, {}, async () => {
566
- const resolved = await Promise.all(
567
- parsed.data.paths.map(async (p) => ({
568
- path: p,
569
- stat: await c.statByPath(p),
570
- })),
603
+ const resolved = await mapInBatches(
604
+ paths,
605
+ VFS_PATH_LOOKUP_CONCURRENCY,
606
+ async (p) => ({ path: p, stat: await c.statByPath(p) }),
571
607
  );
572
608
  const missing = resolved
573
609
  .filter((x) => x.stat === null)
574
610
  .map((x) => x.path);
575
611
  const ids = resolved.flatMap((x) => (x.stat ? [x.stat.id] : []));
576
612
  if (ids.length === 0) {
577
- return `No such file(s): ${parsed.data.paths.join(', ')}.`;
613
+ return `No such file(s): ${paths.join(', ')}. ${FILES_ONLY_NOTE}`;
578
614
  }
579
615
  const results = await c.trash(ids);
580
616
  let summary = summarizeBatch(results, 'Moved to trash', ids.length);
581
617
  if (missing.length > 0) {
582
- summary += `\nNot found (skipped): ${missing.join(', ')}.`;
618
+ summary += `\nNot found (skipped): ${missing.join(', ')}. ${FILES_ONLY_NOTE}`;
583
619
  }
584
620
  return summary;
585
621
  });
586
622
  },
587
623
  {
588
624
  name: 'vfs_delete',
589
- description:
590
- 'Move a file or folder to trash (recoverable). Confirm destructive actions with the user first. Deletes go to trash, not permanent.',
625
+ description: `Move files to trash (recoverable), up to ${VFS_DELETE_MAX_PATHS} per call. Each path must be the exact path of a file; wildcards and folders are not accepted — find a folder's files with \`vfs_glob\` and pass them. Confirm destructive actions with the user first. Deletes go to trash, not permanent.`,
591
626
  schema: deleteSchema,
592
627
  },
593
628
  );
@@ -603,6 +638,11 @@ export function createVfsTools(deps: CreateVfsToolsDeps): PluginTool[] {
603
638
  const c = client(ctx);
604
639
  return guard(ctx, { path }, async () => {
605
640
  const stat = await c.statByPath(path);
641
+ // A path that is not a file is published as a folder; a wildcard
642
+ // pattern is neither, so it is refused rather than sent on.
643
+ if (!stat && hasGlobWildcard(path)) {
644
+ return `No file at \`${path}\`. \`*\` and \`?\` are not wildcards here — give the exact path of one file or folder (find it with \`vfs_glob\`).`;
645
+ }
606
646
  const result = stat
607
647
  ? await c.setFilePublic(stat.id, pub)
608
648
  : await c.setFolderPublic(path, pub);
@@ -1,111 +1,191 @@
1
1
  /**
2
- * Pending frontend (browser-tool / AG-UI action) calls awaiting a result
3
- * from the user's browser — the Workers counterpart of the Node runtime's
4
- * `callFrontendTool`, which parks a promise on `rootEventEmitter` until the
5
- * socket delivers `browser_tool_result` / `action_call_result` for the same
6
- * `toolCallId`.
2
+ * Frontend (browser-tool / AG-UI action) invocations of one user object:
3
+ * the calls waiting for an answer from the user's browser, and a bounded
4
+ * record of the ones already finished.
7
5
  *
8
- * Settlement rules are Node's, verbatim:
9
- * - `error` set → reject(Error(error))
10
- * - AG-UI and `result.success === false` → reject(Error(result.error ?? 'Action failed'))
11
- * - otherwise → resolve(result)
12
- * - no result within `timeoutMs` → reject("<kind> timeout after <ms>ms: <tool>")
6
+ * The contract (see docs/frontend-bridge.md):
7
+ * - every invocation has its own id (`frontendInvocationId`), and an id is
8
+ * issued once — never reused while it is pending or remembered;
9
+ * - an invocation is sent to ONE socket (`dispatched`), and only a result
10
+ * arriving on that socket — same engine.io sid, same session, same
11
+ * authenticated DID — settles it. Another tab of the same session, a
12
+ * reconnected tab (a new sid) or another session cannot;
13
+ * - a settled invocation is remembered (bounded by count and age), so a
14
+ * replayed or late result is recognised and rejected as such;
15
+ * - pending invocations are never evicted to make room; a full table
16
+ * refuses new calls instead;
17
+ * - an invocation whose answer cannot arrive any more — its deadline
18
+ * passed, or its socket went — resolves with `FRONTEND_OUTCOME_UNKNOWN`:
19
+ * the browser may have performed the write, so it is neither a success
20
+ * nor a failure, and it is never sent again.
13
21
  *
14
- * Purely in-memory: a pending call does not survive an object restart, and
15
- * neither does the turn that made it.
22
+ * Settlement rules for a matching result:
23
+ * - `error` set → reject(Error(error))
24
+ * - AG-UI, `result.success === false`, and not an
25
+ * unknown outcome the client reported → reject(Error(result.error ?? 'Action failed'))
26
+ * - otherwise → resolve(result)
27
+ *
28
+ * Purely in-memory, which is exact on Workers: a pending call holds a timer,
29
+ * so its object cannot hibernate before the call ends, and an object restart
30
+ * ends the turn that made it (a resumed durable run does not re-run a write
31
+ * that had started — see tool-marks). Finished-call records are lost when
32
+ * the object hibernates; a result for a forgotten id is then rejected as
33
+ * never issued, which is the same outcome.
16
34
  */
35
+ import {
36
+ frontendOutcomeUnknown,
37
+ reportsUnknownOutcome,
38
+ } from '@ixo/common/ai/frontend-bridge';
17
39
 
18
40
  export type FrontendCallKind = 'browser' | 'agui';
19
41
 
42
+ /** The socket an invocation was sent to, as its attachment identifies it. */
43
+ export interface FrontendExecutor {
44
+ /** engine.io sid of the socket (stable across hibernation, new on reconnect). */
45
+ sid: string;
46
+ sessionId: string;
47
+ /** The DID the socket's CONNECT authenticated. */
48
+ userDid: string;
49
+ }
50
+
20
51
  export interface FrontendCallResult {
52
+ /** The invocation id the client echoes back. */
21
53
  toolCallId: string;
54
+ /** The socket the result arrived on (never the client's claims). */
55
+ from: FrontendExecutor;
22
56
  result?: unknown;
23
57
  error?: string;
24
58
  }
25
59
 
60
+ /** Why a result settled nothing (the call, if any, keeps waiting). */
61
+ export type FrontendResultRejection =
62
+ /** No such invocation was issued (or its record has expired). */
63
+ | 'not-issued'
64
+ /** The invocation already ended: a duplicate, replayed or late result. */
65
+ | 'already-settled'
66
+ /** The result came from a socket other than the one the call was sent to. */
67
+ | 'wrong-socket'
68
+ /** A browser-tool result for an AG-UI invocation, or the reverse. */
69
+ | 'wrong-kind';
70
+
71
+ export type SettleOutcome =
72
+ | { settled: true }
73
+ | { settled: false; reason: FrontendResultRejection };
74
+
26
75
  export interface PendingFrontendCall {
27
76
  kind: FrontendCallKind;
77
+ /** The invocation id (what the browser received as `toolCallId`). */
28
78
  toolCallId: string;
29
79
  toolName: string;
30
80
  sessionId: string;
31
81
  startedAt: number;
82
+ /** sid of the socket executing it (unset only before it is sent). */
83
+ executorSid?: string;
32
84
  }
33
85
 
34
- interface Pending extends PendingFrontendCall {
86
+ interface Pending extends Omit<PendingFrontendCall, 'executorSid'> {
87
+ executor?: FrontendExecutor;
35
88
  resolve: (value: unknown) => void;
36
89
  reject: (error: Error) => void;
37
90
  timer: ReturnType<typeof setTimeout>;
38
91
  }
39
92
 
40
- const KIND_LABEL: Record<FrontendCallKind, string> = {
93
+ /** Finished invocations kept to recognise replays: at most this many… */
94
+ export const MAX_COMPLETED_FRONTEND_CALLS = 1024;
95
+ /** …for at most this long. */
96
+ export const COMPLETED_FRONTEND_CALL_TTL_MS = 30 * 60_000;
97
+ /** Calls in flight at once for one user; beyond it new calls are refused. */
98
+ export const MAX_PENDING_FRONTEND_CALLS = 256;
99
+
100
+ export const FRONTEND_CALL_LABEL: Record<FrontendCallKind, string> = {
41
101
  browser: 'Browser tool',
42
102
  agui: 'AG-UI action',
43
103
  };
44
104
 
105
+ export interface FrontendCallRegistryOptions {
106
+ now?: () => number;
107
+ maxPending?: number;
108
+ maxCompleted?: number;
109
+ completedTtlMs?: number;
110
+ }
111
+
45
112
  function isRecord(value: unknown): value is Record<string, unknown> {
46
113
  return typeof value === 'object' && value !== null;
47
114
  }
48
115
 
49
- function readSuccessFlag(result: unknown): {
50
- success?: boolean;
51
- error?: string;
52
- } {
53
- if (!isRecord(result)) return {};
54
- const record = result;
55
- return {
56
- ...(typeof record.success === 'boolean' ? { success: record.success } : {}),
57
- ...(typeof record.error === 'string' ? { error: record.error } : {}),
58
- };
116
+ function sameExecutor(a: FrontendExecutor, b: FrontendExecutor): boolean {
117
+ return (
118
+ a.sid === b.sid && a.sessionId === b.sessionId && a.userDid === b.userDid
119
+ );
59
120
  }
60
121
 
61
122
  export class FrontendCallRegistry {
62
123
  private readonly pending = new Map<string, Pending>();
63
124
 
64
- private key(kind: FrontendCallKind, toolCallId: string): string {
65
- return `${kind}:${toolCallId}`;
125
+ /** Invocation id → when it finished; insertion order is completion order. */
126
+ private readonly completed = new Map<string, number>();
127
+
128
+ private readonly now: () => number;
129
+
130
+ private readonly maxPending: number;
131
+
132
+ private readonly maxCompleted: number;
133
+
134
+ private readonly completedTtlMs: number;
135
+
136
+ constructor(options: FrontendCallRegistryOptions = {}) {
137
+ this.now = options.now ?? (() => Date.now());
138
+ this.maxPending = options.maxPending ?? MAX_PENDING_FRONTEND_CALLS;
139
+ this.maxCompleted = options.maxCompleted ?? MAX_COMPLETED_FRONTEND_CALLS;
140
+ this.completedTtlMs =
141
+ options.completedTtlMs ?? COMPLETED_FRONTEND_CALL_TTL_MS;
66
142
  }
67
143
 
68
144
  /**
69
- * Park a call until `settle` delivers its result or `timeoutMs` elapses.
70
- * `signal` (the turn's abort signal) rejects early so an aborted turn does
71
- * not keep a browser waiting.
145
+ * Register an invocation BEFORE it is sent, so an immediate answer cannot
146
+ * be missed, and return the promise its result settles. Throws when the id
147
+ * was already issued or the table is full — the call is then not sent.
148
+ * `signal` (the turn's abort signal) ends it early: rejected as not sent
149
+ * before `dispatched`, an unknown outcome after.
72
150
  */
73
- wait(
74
- call: Omit<PendingFrontendCall, 'startedAt'>,
151
+ open(
152
+ call: Omit<PendingFrontendCall, 'startedAt' | 'executorSid'>,
75
153
  opts: { timeoutMs: number; signal?: AbortSignal },
76
154
  ): Promise<unknown> {
77
- const key = this.key(call.kind, call.toolCallId);
78
- const existing = this.pending.get(key);
79
- if (existing) {
80
- this.finish(key);
81
- existing.reject(
82
- new Error(`${KIND_LABEL[call.kind]} ${call.toolCallId} was superseded`),
155
+ const id = call.toolCallId;
156
+ const label = FRONTEND_CALL_LABEL[call.kind];
157
+ this.prune();
158
+ if (this.pending.has(id) || this.completed.has(id))
159
+ throw new Error(`${label} invocation ${id} was already issued`);
160
+ if (this.pending.size >= this.maxPending)
161
+ throw new Error(
162
+ `${label} ${call.toolName} was not sent: ${this.maxPending} frontend calls are already in flight`,
83
163
  );
84
- }
85
164
  return new Promise<unknown>((resolve, reject) => {
86
165
  const timer = setTimeout(() => {
87
- this.finish(key);
88
- reject(
89
- new Error(
90
- `${KIND_LABEL[call.kind]} timeout after ${opts.timeoutMs}ms: ${call.toolName}`,
91
- ),
92
- );
166
+ if (this.complete(id)) resolve(frontendOutcomeUnknown(id));
93
167
  }, opts.timeoutMs);
94
168
  const entry: Pending = {
95
169
  ...call,
96
- startedAt: Date.now(),
170
+ startedAt: this.now(),
97
171
  resolve,
98
172
  reject,
99
173
  timer,
100
174
  };
101
- this.pending.set(key, entry);
175
+ this.pending.set(id, entry);
102
176
  if (opts.signal) {
177
+ // The turn ended (Stop, deadline). Before the call was sent it is a
178
+ // definite failure; once sent, the browser may already have run it.
103
179
  const onAbort = () => {
104
- if (this.pending.get(key) !== entry) return;
105
- this.finish(key);
106
- reject(
107
- new Error(`${KIND_LABEL[call.kind]} ${call.toolName} aborted`),
108
- );
180
+ if (this.pending.get(id) !== entry) return;
181
+ this.complete(id);
182
+ if (entry.executor) resolve(frontendOutcomeUnknown(id));
183
+ else
184
+ reject(
185
+ new Error(
186
+ `${label} ${call.toolName} was not sent: the turn was aborted`,
187
+ ),
188
+ );
109
189
  };
110
190
  if (opts.signal.aborted) onAbort();
111
191
  else opts.signal.addEventListener('abort', onAbort, { once: true });
@@ -113,40 +193,88 @@ export class FrontendCallRegistry {
113
193
  });
114
194
  }
115
195
 
196
+ /** Whether `toolCallId` is still waiting (an already-aborted call is not). */
197
+ isPending(toolCallId: string): boolean {
198
+ return this.pending.has(toolCallId);
199
+ }
200
+
201
+ /** Bind a pending invocation to the one socket it was sent to. */
202
+ dispatched(toolCallId: string, executor: FrontendExecutor): void {
203
+ const entry = this.pending.get(toolCallId);
204
+ if (entry) entry.executor = executor;
205
+ }
206
+
207
+ /** End an invocation that never reached a browser: a definite failure. */
208
+ fail(toolCallId: string, error: Error): void {
209
+ const entry = this.pending.get(toolCallId);
210
+ if (!entry || !this.complete(toolCallId)) return;
211
+ entry.reject(error);
212
+ }
213
+
214
+ /** Deliver a result that arrived on a socket. */
215
+ settle(kind: FrontendCallKind, data: FrontendCallResult): SettleOutcome {
216
+ const id = data.toolCallId;
217
+ const entry = this.pending.get(id);
218
+ if (!entry) {
219
+ this.prune();
220
+ return {
221
+ settled: false,
222
+ reason: this.completed.has(id) ? 'already-settled' : 'not-issued',
223
+ };
224
+ }
225
+ if (entry.kind !== kind) return { settled: false, reason: 'wrong-kind' };
226
+ if (!entry.executor || !sameExecutor(entry.executor, data.from))
227
+ return { settled: false, reason: 'wrong-socket' };
228
+ this.complete(id);
229
+ const result = data.result;
230
+ if (data.error) entry.reject(new Error(data.error));
231
+ else if (
232
+ kind === 'agui' &&
233
+ isRecord(result) &&
234
+ result.success === false &&
235
+ !reportsUnknownOutcome(result)
236
+ )
237
+ entry.reject(
238
+ new Error(
239
+ typeof result.error === 'string' && result.error
240
+ ? result.error
241
+ : 'Action failed',
242
+ ),
243
+ );
244
+ else entry.resolve(result);
245
+ return { settled: true };
246
+ }
247
+
116
248
  /**
117
- * Deliver a result from the socket. Returns false when nothing was waiting
118
- * for that id (late, duplicate or unknown result — logged by the caller).
249
+ * The socket `sid` is gone (closed, errored, dropped by the heartbeat).
250
+ * Its pending invocations can never be answered — a reconnect is a new
251
+ * socket — and are never re-sent elsewhere: they resolve with an unknown
252
+ * outcome now instead of at their deadline. Returns how many there were.
119
253
  */
120
- settle(kind: FrontendCallKind, data: FrontendCallResult): boolean {
121
- const key = this.key(kind, data.toolCallId);
122
- const entry = this.pending.get(key);
123
- if (!entry) return false;
124
- this.finish(key);
125
- if (data.error) {
126
- entry.reject(new Error(data.error));
127
- return true;
254
+ executorGone(sid: string): number {
255
+ let ended = 0;
256
+ for (const [id, entry] of [...this.pending]) {
257
+ if (entry.executor?.sid !== sid) continue;
258
+ this.complete(id);
259
+ entry.resolve(frontendOutcomeUnknown(id));
260
+ ended += 1;
128
261
  }
129
- if (kind === 'agui') {
130
- const flags = readSuccessFlag(data.result);
131
- if (flags.success === false) {
132
- entry.reject(new Error(flags.error || 'Action failed'));
133
- return true;
134
- }
135
- }
136
- entry.resolve(data.result);
137
- return true;
262
+ return ended;
138
263
  }
139
264
 
140
265
  /** Pending calls, oldest first (diagnostics / status). */
141
266
  list(): PendingFrontendCall[] {
142
267
  return [...this.pending.values()]
143
- .map(({ kind, toolCallId, toolName, sessionId, startedAt }) => ({
144
- kind,
145
- toolCallId,
146
- toolName,
147
- sessionId,
148
- startedAt,
149
- }))
268
+ .map(
269
+ ({ kind, toolCallId, toolName, sessionId, startedAt, executor }) => ({
270
+ kind,
271
+ toolCallId,
272
+ toolName,
273
+ sessionId,
274
+ startedAt,
275
+ ...(executor ? { executorSid: executor.sid } : {}),
276
+ }),
277
+ )
150
278
  .sort((a, b) => a.startedAt - b.startedAt);
151
279
  }
152
280
 
@@ -154,10 +282,29 @@ export class FrontendCallRegistry {
154
282
  return this.pending.size;
155
283
  }
156
284
 
157
- private finish(key: string): void {
158
- const entry = this.pending.get(key);
159
- if (!entry) return;
285
+ /** Finished invocations currently remembered. */
286
+ get completedCount(): number {
287
+ this.prune();
288
+ return this.completed.size;
289
+ }
290
+
291
+ /** Move a pending invocation to the finished records; false if it was not pending. */
292
+ private complete(id: string): boolean {
293
+ const entry = this.pending.get(id);
294
+ if (!entry) return false;
160
295
  clearTimeout(entry.timer);
161
- this.pending.delete(key);
296
+ this.pending.delete(id);
297
+ this.completed.set(id, this.now());
298
+ this.prune();
299
+ return true;
300
+ }
301
+
302
+ /** Drop finished records past their age, then the oldest beyond the bound. */
303
+ private prune(): void {
304
+ const cutoff = this.now() - this.completedTtlMs;
305
+ for (const [id, at] of this.completed) {
306
+ if (at > cutoff && this.completed.size <= this.maxCompleted) break;
307
+ this.completed.delete(id);
308
+ }
162
309
  }
163
310
  }