@ixo/oracle-runtime-workers 0.2.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 (215) hide show
  1. package/README.md +110 -0
  2. package/package.json +77 -0
  3. package/src/attachments/classify.ts +114 -0
  4. package/src/attachments/content-blocks.ts +51 -0
  5. package/src/attachments/download.ts +235 -0
  6. package/src/attachments/extract.ts +241 -0
  7. package/src/attachments/index.ts +84 -0
  8. package/src/attachments/magic.ts +133 -0
  9. package/src/attachments/pipeline.ts +409 -0
  10. package/src/attachments/retention.ts +205 -0
  11. package/src/attachments/route.ts +41 -0
  12. package/src/attachments/sandbox-archive.ts +90 -0
  13. package/src/attachments/sanitize.ts +35 -0
  14. package/src/attachments/types.ts +86 -0
  15. package/src/attachments/view.ts +107 -0
  16. package/src/core/env.ts +200 -0
  17. package/src/core/index.ts +513 -0
  18. package/src/core/llm.ts +842 -0
  19. package/src/core/main-agent-types.ts +123 -0
  20. package/src/core/main-agent.ts +399 -0
  21. package/src/core/manifest.ts +365 -0
  22. package/src/core/meta-tools.ts +232 -0
  23. package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
  24. package/src/core/middlewares/capability-gate.ts +76 -0
  25. package/src/core/middlewares/index.ts +18 -0
  26. package/src/core/middlewares/page-context.ts +98 -0
  27. package/src/core/middlewares/safety-guardrail.ts +117 -0
  28. package/src/core/middlewares/summarization.ts +99 -0
  29. package/src/core/middlewares/tool-repetition-guard.ts +125 -0
  30. package/src/core/middlewares/tool-validation.ts +83 -0
  31. package/src/core/middlewares/work-status.ts +57 -0
  32. package/src/core/openrouter-pricing.ts +124 -0
  33. package/src/core/plugin-loader.ts +192 -0
  34. package/src/core/plugins/index.ts +6 -0
  35. package/src/core/plugins/skills/index.ts +8 -0
  36. package/src/core/plugins/skills/skills-tools.ts +356 -0
  37. package/src/core/plugins/skills/skills.plugin.ts +113 -0
  38. package/src/core/plugins/weather/index.ts +8 -0
  39. package/src/core/plugins/weather/weather-client.ts +153 -0
  40. package/src/core/plugins/weather/weather-middleware.ts +27 -0
  41. package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
  42. package/src/core/plugins/weather/weather-tools.ts +103 -0
  43. package/src/core/plugins/weather/weather.plugin.ts +227 -0
  44. package/src/core/prompt-composer.ts +469 -0
  45. package/src/core/registries.ts +642 -0
  46. package/src/core/runtime-context.ts +619 -0
  47. package/src/core/state.ts +108 -0
  48. package/src/core/sub-agent-fallback.ts +187 -0
  49. package/src/core/subagent-as-tool.ts +322 -0
  50. package/src/core/template.ts +245 -0
  51. package/src/core/utils.ts +246 -0
  52. package/src/core/wrap-plugin-tool.ts +100 -0
  53. package/src/do/ambient.ts +266 -0
  54. package/src/do/contracts.ts +562 -0
  55. package/src/do/debug-timers.ts +120 -0
  56. package/src/do/fresh-stub.ts +32 -0
  57. package/src/do/gateway-retry.ts +73 -0
  58. package/src/do/secrets-adapter.ts +32 -0
  59. package/src/do/size-alerts.ts +76 -0
  60. package/src/do/sse-stream.ts +472 -0
  61. package/src/do/transcript.ts +192 -0
  62. package/src/do/turn-body.ts +84 -0
  63. package/src/do/turn-metadata.ts +146 -0
  64. package/src/do/ucan-service.ts +339 -0
  65. package/src/do/user-oracle-do.ts +3035 -0
  66. package/src/gateway-worker.ts +82 -0
  67. package/src/index.ts +237 -0
  68. package/src/llm/byo-adapter.ts +59 -0
  69. package/src/llm/byo-catalog.ts +520 -0
  70. package/src/llm/byo-client.ts +280 -0
  71. package/src/llm/byo-routes.ts +255 -0
  72. package/src/llm/byo-service.ts +836 -0
  73. package/src/llm/chatgpt-oauth.ts +430 -0
  74. package/src/llm/provider-error.ts +283 -0
  75. package/src/matrix/gateway-do.ts +918 -0
  76. package/src/matrix/ingest.ts +209 -0
  77. package/src/matrix/replay-format.ts +34 -0
  78. package/src/matrix/reply-chain.ts +109 -0
  79. package/src/matrix/room-state-codec.ts +115 -0
  80. package/src/matrix/user-homeserver.ts +106 -0
  81. package/src/matrix/work-status.ts +294 -0
  82. package/src/memory/session-history-indexer.ts +328 -0
  83. package/src/owner-store/ixo-vfs-store.ts +570 -0
  84. package/src/owner-store/matrix-media-store.ts +136 -0
  85. package/src/owner-store/migrating-store.ts +185 -0
  86. package/src/owner-store/owner-copy-errors.ts +175 -0
  87. package/src/owner-store/retry.ts +45 -0
  88. package/src/owner-store/tus-upload.ts +333 -0
  89. package/src/owner-store/types.ts +0 -0
  90. package/src/plugin-api/define-plugin.ts +45 -0
  91. package/src/plugin-api/index.ts +56 -0
  92. package/src/plugin-api/oracle-plugin.ts +145 -0
  93. package/src/plugin-api/tool-helper.ts +82 -0
  94. package/src/plugin-api/types.ts +816 -0
  95. package/src/plugin-api/ucan-errors.ts +16 -0
  96. package/src/plugins/agui/agui-agent.ts +115 -0
  97. package/src/plugins/agui/agui.plugin.ts +148 -0
  98. package/src/plugins/agui/index.ts +2 -0
  99. package/src/plugins/attachments/attachments.plugin.ts +54 -0
  100. package/src/plugins/attachments/index.ts +6 -0
  101. package/src/plugins/attachments/view-attachment-tool.ts +91 -0
  102. package/src/plugins/base64.ts +26 -0
  103. package/src/plugins/composio/composio-tools.ts +365 -0
  104. package/src/plugins/composio/composio-ucan.ts +43 -0
  105. package/src/plugins/composio/composio.plugin.ts +214 -0
  106. package/src/plugins/composio/index.ts +15 -0
  107. package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
  108. package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
  109. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
  110. package/src/plugins/domain-indexer/index.ts +3 -0
  111. package/src/plugins/editor/blocknote-bridge.ts +120 -0
  112. package/src/plugins/editor/content-session.ts +331 -0
  113. package/src/plugins/editor/content-tools.ts +686 -0
  114. package/src/plugins/editor/document-model.ts +0 -0
  115. package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
  116. package/src/plugins/editor/editor-agent.ts +34 -0
  117. package/src/plugins/editor/editor-config.ts +64 -0
  118. package/src/plugins/editor/editor-mx.ts +128 -0
  119. package/src/plugins/editor/editor.plugin.ts +143 -0
  120. package/src/plugins/editor/failures.ts +154 -0
  121. package/src/plugins/editor/index.ts +31 -0
  122. package/src/plugins/editor/jsdom-shim.ts +55 -0
  123. package/src/plugins/editor/prompts.ts +59 -0
  124. package/src/plugins/editor/prop-policy.ts +147 -0
  125. package/src/plugins/editor/provider.ts +386 -0
  126. package/src/plugins/editor/room-membership.ts +95 -0
  127. package/src/plugins/editor/standalone-editor-tool.ts +200 -0
  128. package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
  129. package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
  130. package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
  131. package/src/plugins/firecrawl/index.ts +15 -0
  132. package/src/plugins/flows/action-metadata.ts +3505 -0
  133. package/src/plugins/flows/actions.ts +190 -0
  134. package/src/plugins/flows/edit.ts +314 -0
  135. package/src/plugins/flows/errors.ts +38 -0
  136. package/src/plugins/flows/explain.ts +43 -0
  137. package/src/plugins/flows/flow-doc.ts +111 -0
  138. package/src/plugins/flows/flows.plugin.ts +129 -0
  139. package/src/plugins/flows/forms.ts +330 -0
  140. package/src/plugins/flows/index.ts +2 -0
  141. package/src/plugins/flows/linkage.ts +147 -0
  142. package/src/plugins/flows/port-types.ts +53 -0
  143. package/src/plugins/flows/prompts.ts +65 -0
  144. package/src/plugins/flows/read.ts +392 -0
  145. package/src/plugins/flows/references.ts +66 -0
  146. package/src/plugins/flows/test-support.ts +165 -0
  147. package/src/plugins/flows/tools/authoring.ts +516 -0
  148. package/src/plugins/flows/tools/discovery.ts +119 -0
  149. package/src/plugins/flows/tools/forms.ts +142 -0
  150. package/src/plugins/flows/tools/inspect.ts +165 -0
  151. package/src/plugins/flows/tools/linkage.ts +117 -0
  152. package/src/plugins/flows/tools/settings.ts +184 -0
  153. package/src/plugins/flows/translator.ts +328 -0
  154. package/src/plugins/flows/types.ts +210 -0
  155. package/src/plugins/flows/ydoc-helpers.ts +543 -0
  156. package/src/plugins/index.ts +70 -0
  157. package/src/plugins/mcp-call-timeout.ts +38 -0
  158. package/src/plugins/mcp-tool-adapter.ts +87 -0
  159. package/src/plugins/memory/index.ts +17 -0
  160. package/src/plugins/memory/memory-tools.ts +463 -0
  161. package/src/plugins/memory/memory-ucan.ts +51 -0
  162. package/src/plugins/memory/memory.plugin.ts +122 -0
  163. package/src/plugins/memory/types.ts +147 -0
  164. package/src/plugins/portal/action-log.ts +34 -0
  165. package/src/plugins/portal/index.ts +2 -0
  166. package/src/plugins/portal/portal.plugin.ts +203 -0
  167. package/src/plugins/sandbox/index.ts +34 -0
  168. package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
  169. package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
  170. package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
  171. package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
  172. package/src/plugins/tasks/index.ts +17 -0
  173. package/src/plugins/tasks/manifest.ts +81 -0
  174. package/src/plugins/tasks/middleware.ts +219 -0
  175. package/src/plugins/tasks/tasks-tools.ts +414 -0
  176. package/src/plugins/tasks/tasks.plugin.ts +74 -0
  177. package/src/plugins/ucan-failure.ts +133 -0
  178. package/src/plugins/user-preferences/index.ts +22 -0
  179. package/src/plugins/user-preferences/schema.ts +31 -0
  180. package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
  181. package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
  182. package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
  183. package/src/plugins/vfs/index.ts +40 -0
  184. package/src/plugins/vfs/vfs-auth.ts +56 -0
  185. package/src/plugins/vfs/vfs-client.ts +655 -0
  186. package/src/plugins/vfs/vfs-content.ts +181 -0
  187. package/src/plugins/vfs/vfs-errors.ts +163 -0
  188. package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
  189. package/src/plugins/vfs/vfs-tools.ts +635 -0
  190. package/src/plugins/vfs/vfs.plugin.ts +201 -0
  191. package/src/realtime/frontend-call-registry.ts +163 -0
  192. package/src/realtime/realtime-endpoint.ts +619 -0
  193. package/src/realtime/session-socket-hub.ts +249 -0
  194. package/src/realtime/socket-io-codec.ts +187 -0
  195. package/src/secrets/jwe.ts +74 -0
  196. package/src/secrets/pin-cipher.ts +43 -0
  197. package/src/secrets/secrets-service.ts +319 -0
  198. package/src/shell/app.ts +564 -0
  199. package/src/shell/auth.ts +290 -0
  200. package/src/sqlite/blob-codec.ts +100 -0
  201. package/src/sqlite/blob-compactor.ts +146 -0
  202. package/src/sqlite/cache-config.ts +45 -0
  203. package/src/sqlite/database.ts +643 -0
  204. package/src/sqlite/do-vfs.ts +1485 -0
  205. package/src/sqlite/index.ts +61 -0
  206. package/src/sqlite/serialization.ts +370 -0
  207. package/src/sqlite/sessions-store.ts +311 -0
  208. package/src/sqlite/sqlite-saver.ts +919 -0
  209. package/src/sqlite/vacuum-policy.ts +77 -0
  210. package/src/sqlite/wa-sqlite-loader.ts +123 -0
  211. package/src/sqlite/wasm.d.ts +10 -0
  212. package/src/tasks/schedule.ts +174 -0
  213. package/src/tasks/scheduler.ts +753 -0
  214. package/src/tasks/spec.ts +125 -0
  215. package/src/tasks/store.ts +351 -0
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A Durable Object stub that never goes stale.
3
+ *
4
+ * A stub is a connection to one incarnation of the target object: once that
5
+ * object restarts (deploy, eviction, memory reset, `restart()`), calls on the
6
+ * old stub fail with "Network connection lost" until the CALLER is itself
7
+ * recreated. The user object keeps one gateway handle for its whole life and
8
+ * hands it to long-lived helpers (owner store, preferences, status card,
9
+ * ambient matrix adapter), so a single gateway restart silently degraded
10
+ * every best-effort gateway call for hours.
11
+ *
12
+ * `freshStub` returns a proxy with the stub's interface whose every property
13
+ * access resolves against a NEW stub from `get()` — `namespace.get(id)` is a
14
+ * local, allocation-only operation, so this costs nothing per call and each
15
+ * RPC lands on whatever incarnation is current.
16
+ */
17
+ export function freshStub<T extends object>(get: () => T): T {
18
+ const handler: ProxyHandler<object> = {
19
+ get(_target, prop) {
20
+ // Hand the property back untouched: an RPC property carries its own
21
+ // target, and touching it in any other way (e.g. `.bind`) is itself
22
+ // treated as an RPC call by the runtime ("The RPC receiver does not
23
+ // implement the method \"bind\"").
24
+ const stub = get();
25
+ return Reflect.get(stub, prop, stub) as unknown;
26
+ },
27
+ has(_target, prop) {
28
+ return Reflect.has(get(), prop);
29
+ },
30
+ };
31
+ return new Proxy({}, handler) as T;
32
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Retry a gateway RPC across a gateway restart.
3
+ *
4
+ * The Matrix gateway object is replaced on every deploy and can be evicted;
5
+ * an RPC issued in that window fails with a transport error ("Network
6
+ * connection lost", "Durable Object reset", …) rather than a Matrix error.
7
+ * Waited sends that must not be replayed blindly (the session marker: its
8
+ * event id becomes the session id) instead retry the SAME transaction id a
9
+ * few times over ~10 s, so the homeserver deduplicates a send whose response
10
+ * was lost and a restart never produces a second marker.
11
+ */
12
+
13
+ export const GATEWAY_RETRY_DELAYS_MS: readonly number[] = [
14
+ // Must outlast a gateway restart or idle recycle: the SDK's stop, alarm
15
+ // re-arm and boot take a few seconds normally, longer on a cold start.
16
+ 500, 1000, 2000, 4000, 8000, 8000, 8000,
17
+ ];
18
+
19
+ const TRANSIENT_PATTERNS = [
20
+ /network connection lost/i,
21
+ /durable object (reset|storage operation exceeded|has been)/i,
22
+ /object (was|has been) (reset|evicted)/i,
23
+ /internal error in durable object/i,
24
+ /not (yet )?started/i,
25
+ /client is not running/i,
26
+ /matrix client.*(stopped|not ready|not started)/i,
27
+ /timed out waiting for the matrix client/i,
28
+ /failed to fetch/i,
29
+ /econnreset|econnrefused|socket hang up/i,
30
+ // `ctx.abort(reason)` surfaces the reason to in-flight RPC callers.
31
+ /gateway reset/i,
32
+ /planned recycle/i,
33
+ /\babort(ed)?\b/i,
34
+ // matrix-js-sdk's wording while the bot stops or recycles.
35
+ /shutting down/i,
36
+ /OutgoingRequestsManager was stopped/i,
37
+ /null pointer passed to rust/i,
38
+ // A retry that raced the stopping client's pending list (see startedClient).
39
+ /known txnId/i,
40
+ ];
41
+
42
+ export function isTransientGatewayError(err: unknown): boolean {
43
+ const message = err instanceof Error ? err.message : String(err);
44
+ return TRANSIENT_PATTERNS.some((p) => p.test(message));
45
+ }
46
+
47
+ export interface RetryGatewayOptions {
48
+ delaysMs?: readonly number[];
49
+ isTransient?: (err: unknown) => boolean;
50
+ onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
51
+ sleep?: (ms: number) => Promise<void>;
52
+ }
53
+
54
+ /** Run `fn`, retrying transient gateway failures with the given delays. */
55
+ export async function retryGateway<T>(
56
+ fn: () => Promise<T>,
57
+ opts: RetryGatewayOptions = {},
58
+ ): Promise<T> {
59
+ const delays = opts.delaysMs ?? GATEWAY_RETRY_DELAYS_MS;
60
+ const isTransient = opts.isTransient ?? isTransientGatewayError;
61
+ const sleep =
62
+ opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
63
+ for (let attempt = 0; ; attempt += 1) {
64
+ try {
65
+ return await fn();
66
+ } catch (err) {
67
+ const delay = delays[attempt];
68
+ if (delay === undefined || !isTransient(err)) throw err;
69
+ opts.onRetry?.(err, attempt + 1, delay);
70
+ await sleep(delay);
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Adapts `WorkersSecretsService` (per-room JWE secrets over the Matrix
3
+ * gateway) to the core's `SecretsAdapter` shape — the same mapping the Node
4
+ * runtime's `ambient-factory` performs over its `SecretsService`:
5
+ * `getIndex` lists names (plugins never see event ids), `getValues` decrypts
6
+ * only the requested names. Degrades exactly like Node when the oracle has no
7
+ * P-256 key seated: the index still lists, values come back empty.
8
+ */
9
+ import type { SecretsAdapter } from '../core/runtime-context';
10
+ import type { SecretIndex } from '../plugin-api/types';
11
+ import type { WorkersSecretsService } from '../secrets/secrets-service';
12
+
13
+ export function createSecretsAdapter(
14
+ service: WorkersSecretsService,
15
+ ): SecretsAdapter {
16
+ return {
17
+ async getIndex(roomId: string): Promise<SecretIndex> {
18
+ const entries = await service.getIndex(roomId);
19
+ const index: Record<string, { key: string }> = {};
20
+ for (const entry of entries) {
21
+ index[entry.name] = { key: entry.name };
22
+ }
23
+ return index satisfies SecretIndex;
24
+ },
25
+ async getValues(
26
+ roomId: string,
27
+ keys: string[],
28
+ ): Promise<Record<string, string>> {
29
+ return service.getValues(roomId, keys);
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Storage watermark alerts: a Slack webhook message the first time a user's
3
+ * working-copy SQLite file crosses each whole-GB threshold from
4
+ * `START_ALERT_GB` up (1 GB, then 2 GB, ... 9 GB is the last crossing below
5
+ * the cap). One alert per threshold — the caller persists the highest GB
6
+ * already alerted and passes it back in.
7
+ *
8
+ * Exists for two reasons: the DO storage cap is 10 GB per user object, and
9
+ * resident storage is the runtime's dominant cost ($0.20/GB-month) — see the
10
+ * cost-planning notes in the package README. Early crossings are the signal
11
+ * to schedule the R2 page-tier work with runway, not under fire.
12
+ */
13
+
14
+ export const START_ALERT_GB = 1;
15
+ const GB = 1024 * 1024 * 1024;
16
+
17
+ /**
18
+ * The threshold this file size newly crosses, or null when no alert is due.
19
+ * `lastAlertedGb` is the highest threshold already alerted (0 = none).
20
+ * A file that jumped several GB since the last check (e.g. a big legacy
21
+ * import) alerts once at its CURRENT floor, not once per skipped step.
22
+ */
23
+ export function crossedGbThreshold(
24
+ fileBytes: number,
25
+ lastAlertedGb: number,
26
+ startGb = START_ALERT_GB,
27
+ ): number | null {
28
+ const floorGb = Math.floor(fileBytes / GB);
29
+ if (floorGb < startGb || floorGb <= lastAlertedGb) return null;
30
+ return floorGb;
31
+ }
32
+
33
+ export function storageAlertText(args: {
34
+ oracleName: string;
35
+ oracleDid: string;
36
+ userDid: string;
37
+ fileBytes: number;
38
+ gb: number;
39
+ }): string {
40
+ const sizeGb = (args.fileBytes / GB).toFixed(2);
41
+ return (
42
+ `:warning: *Oracle user storage crossed ${args.gb} GB*\n` +
43
+ `• Oracle: ${args.oracleName} (\`${args.oracleDid}\`)\n` +
44
+ `• User: \`${args.userDid}\`\n` +
45
+ `• Working-copy SQLite file: ${sizeGb} GB of the 10 GB Durable Object cap\n` +
46
+ `At 10 GB this user's turns and exports start failing — ship the storage tier before then.`
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Fire the webhook. Failures are logged and swallowed — an alert must never
52
+ * take down the flush path it rides on.
53
+ */
54
+ export async function postSlackAlert(
55
+ webhookUrl: string,
56
+ text: string,
57
+ ): Promise<void> {
58
+ try {
59
+ const res = await fetch(webhookUrl, {
60
+ method: 'POST',
61
+ headers: { 'content-type': 'application/json' },
62
+ body: JSON.stringify({ text }),
63
+ });
64
+ if (!res.ok) {
65
+ console.warn(
66
+ `[size-alerts] Slack webhook responded ${res.status}: ${await res
67
+ .text()
68
+ .catch(() => '')}`,
69
+ );
70
+ }
71
+ } catch (err) {
72
+ console.warn(
73
+ `[size-alerts] Slack webhook failed: ${err instanceof Error ? err.message : String(err)}`,
74
+ );
75
+ }
76
+ }
@@ -0,0 +1,472 @@
1
+ /**
2
+ * LangGraph `streamEvents` → Server-Sent Events, as a Web `ReadableStream`.
3
+ *
4
+ * Wire-compatible port of the Node runtime's `SseStreamRunner`: the same
5
+ * event names and payload shapes the Portal / client SDK parse
6
+ * (`message`, `reasoning`, `tool_call`, `action_call`, `error`, `done`),
7
+ * the same "instant Thinking…" ack, the same 15 s heartbeat comment.
8
+ *
9
+ * On Workers the response body is a stream we write into; the client
10
+ * disconnecting cancels the stream, which aborts the run.
11
+ */
12
+ import {
13
+ classifyLlmError,
14
+ isOperatorFault,
15
+ redactOperatorFault,
16
+ } from '../llm/provider-error';
17
+ import type { AIMessageChunk, ToolMessage } from '@langchain/core/messages';
18
+
19
+ export interface SseTurnRunnerInput {
20
+ /** `agent.streamEvents(stateInput, config)` async iterable. */
21
+ events: AsyncIterable<unknown>;
22
+ sessionId: string;
23
+ requestId: string;
24
+ abortController: AbortController;
25
+ /** Names declared as client-side AG-UI actions (rendered as `action_call`). */
26
+ agActionNames?: ReadonlySet<string>;
27
+ onComplete?: (fullText: string) => void | Promise<void>;
28
+ onError?: (error: unknown) => void;
29
+ log?: (msg: string) => void;
30
+ /** BYO provider of this turn, when it ran on the user's own credential. */
31
+ byoProvider?: string;
32
+ /**
33
+ * Receives every `tool_call` / `action_call` / `router_update` frame the
34
+ * stream writes, for the session's realtime sockets (the SSE consumer
35
+ * already has them). Payloads carry `sessionId`.
36
+ */
37
+ mirror?: (eventName: string, payload: Record<string, unknown>) => void;
38
+ }
39
+
40
+ const THINKING_PHRASES = [
41
+ 'Thinking...',
42
+ 'Working...',
43
+ 'Analyzing...',
44
+ 'Processing...',
45
+ 'Reasoning...',
46
+ 'Reading...',
47
+ 'Synthesizing...',
48
+ 'Considering...',
49
+ 'Investigating...',
50
+ 'Solving...',
51
+ 'Reviewing...',
52
+ 'Reflecting...',
53
+ ];
54
+
55
+ export function pickThinkingPhrase(): string {
56
+ return (
57
+ THINKING_PHRASES[Math.floor(Math.random() * THINKING_PHRASES.length)] ??
58
+ 'Thinking...'
59
+ );
60
+ }
61
+
62
+ export function formatSSE(eventType: string, data: unknown): string {
63
+ return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
64
+ }
65
+
66
+ export const SSE_HEADERS: Record<string, string> = {
67
+ 'Content-Type': 'text/event-stream',
68
+ 'Cache-Control': 'no-cache, no-transform',
69
+ Connection: 'keep-alive',
70
+ 'X-Accel-Buffering': 'no',
71
+ };
72
+
73
+ interface ToolCallPayload {
74
+ sessionId: string;
75
+ requestId: string;
76
+ toolName: string;
77
+ args: Record<string, unknown>;
78
+ status: 'isRunning' | 'done';
79
+ output?: string;
80
+ eventId?: string;
81
+ }
82
+
83
+ interface ActionCallPayload {
84
+ sessionId: string;
85
+ requestId: string;
86
+ toolName: string;
87
+ toolCallId: string;
88
+ args?: Record<string, unknown>;
89
+ status: 'isRunning' | 'done' | 'error';
90
+ output?: string;
91
+ error?: string;
92
+ }
93
+
94
+ function extractToolArgs(input: unknown): Record<string, unknown> {
95
+ if (input && typeof input === 'object') {
96
+ const maybe = input as { input?: unknown };
97
+ if (maybe.input && typeof maybe.input === 'object')
98
+ return maybe.input as Record<string, unknown>;
99
+ return input as Record<string, unknown>;
100
+ }
101
+ return {};
102
+ }
103
+
104
+ function collectBlockStrings(
105
+ content: unknown,
106
+ type: string,
107
+ field: string,
108
+ ): string {
109
+ if (!Array.isArray(content)) return '';
110
+ return content
111
+ .map((block) => {
112
+ if (
113
+ block &&
114
+ typeof block === 'object' &&
115
+ (block as { type?: unknown }).type === type
116
+ ) {
117
+ const v = (block as Record<string, unknown>)[field];
118
+ return typeof v === 'string' ? v : '';
119
+ }
120
+ return '';
121
+ })
122
+ .join('');
123
+ }
124
+
125
+ function safeParseToolContent(
126
+ content: unknown,
127
+ ): { success?: boolean; error?: unknown } | null {
128
+ if (typeof content !== 'string') return null;
129
+ try {
130
+ const parsed = JSON.parse(content);
131
+ return parsed && typeof parsed === 'object'
132
+ ? (parsed as { success?: boolean; error?: unknown })
133
+ : null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ function toText(content: unknown): string {
140
+ if (typeof content === 'string') return content;
141
+ if (Array.isArray(content))
142
+ return collectBlockStrings(content, 'text', 'text');
143
+ return content == null ? '' : JSON.stringify(content);
144
+ }
145
+
146
+ /**
147
+ * Build the SSE body for one turn. The returned stream starts emitting the
148
+ * Thinking… ack immediately; `events` is consumed lazily once the consumer
149
+ * reads. Errors inside the run become an `error` frame followed by `done`.
150
+ */
151
+ /** SSE frame name → socket event name (Node's `serverEvents` spelling). */
152
+ const MIRRORED_EVENTS: Record<string, string | undefined> = {
153
+ tool_call: 'tool_call',
154
+ action_call: 'action_call',
155
+ 'router.update': 'router_update',
156
+ };
157
+
158
+ export function createSseTurnStream(
159
+ input: SseTurnRunnerInput,
160
+ ): ReadableStream<Uint8Array> {
161
+ const encoder = new TextEncoder();
162
+ const { sessionId, requestId, abortController } = input;
163
+ const toolCallMap = new Map<string, ToolCallPayload>();
164
+ const actionCallMap = new Map<string, ActionCallPayload>();
165
+ let closed = false;
166
+ let heartbeat: ReturnType<typeof setInterval> | undefined;
167
+
168
+ return new ReadableStream<Uint8Array>({
169
+ start: (controller) => {
170
+ const write = (
171
+ eventName: string,
172
+ payload: unknown,
173
+ opts: { force?: boolean } = {},
174
+ ) => {
175
+ if (closed || (abortController.signal.aborted && !opts.force)) return;
176
+ try {
177
+ controller.enqueue(encoder.encode(formatSSE(eventName, payload)));
178
+ } catch {
179
+ closed = true;
180
+ }
181
+ // Node's WebSocket gateway relays these to the session's sockets as
182
+ // well; message chunks and `done` stay SSE-only, as there.
183
+ const mirrored = MIRRORED_EVENTS[eventName];
184
+ if (mirrored && input.mirror && payload && typeof payload === 'object')
185
+ input.mirror(mirrored, payload as Record<string, unknown>);
186
+ };
187
+ const finish = () => {
188
+ if (closed) return;
189
+ closed = true;
190
+ if (heartbeat) clearInterval(heartbeat);
191
+ try {
192
+ controller.close();
193
+ } catch {
194
+ /* already closed */
195
+ }
196
+ };
197
+
198
+ write('router.update', {
199
+ step: pickThinkingPhrase(),
200
+ sessionId,
201
+ requestId,
202
+ });
203
+ heartbeat = setInterval(() => {
204
+ if (closed) return;
205
+ try {
206
+ controller.enqueue(encoder.encode(': heartbeat\n\n'));
207
+ } catch {
208
+ closed = true;
209
+ }
210
+ }, 15_000);
211
+
212
+ const flushOrphans = () => {
213
+ for (const [runId, evt] of actionCallMap) {
214
+ write('action_call', {
215
+ ...evt,
216
+ status: 'error',
217
+ error: 'Action did not complete',
218
+ toolCallId: runId,
219
+ });
220
+ }
221
+ actionCallMap.clear();
222
+ for (const [runId, evt] of toolCallMap) {
223
+ write('tool_call', {
224
+ ...evt,
225
+ status: 'done',
226
+ output: '⏱️ Tool did not complete',
227
+ eventId: runId,
228
+ });
229
+ }
230
+ toolCallMap.clear();
231
+ };
232
+
233
+ const run = async () => {
234
+ let fullContent = '';
235
+ try {
236
+ for await (const raw of input.events) {
237
+ if (abortController.signal.aborted) break;
238
+ const evt = raw as {
239
+ event: string;
240
+ run_id: string;
241
+ name?: string;
242
+ data: unknown;
243
+ };
244
+ if (evt.event === 'on_tool_start') {
245
+ const toolName = evt.name ?? 'tool';
246
+ const args = extractToolArgs(
247
+ (evt.data as { input?: unknown })?.input,
248
+ );
249
+ if (input.agActionNames?.has(toolName)) {
250
+ const payload: ActionCallPayload = {
251
+ requestId,
252
+ sessionId,
253
+ toolCallId: evt.run_id,
254
+ toolName,
255
+ args,
256
+ status: 'isRunning',
257
+ };
258
+ actionCallMap.set(evt.run_id, payload);
259
+ write('action_call', payload);
260
+ } else {
261
+ const payload: ToolCallPayload = {
262
+ requestId,
263
+ sessionId,
264
+ toolName,
265
+ args: { ...args, toolName },
266
+ status: 'isRunning',
267
+ eventId: evt.run_id,
268
+ };
269
+ toolCallMap.set(evt.run_id, payload);
270
+ write('tool_call', payload);
271
+ }
272
+ continue;
273
+ }
274
+ if (evt.event === 'on_tool_end') {
275
+ const output = (evt.data as { output?: ToolMessage })?.output;
276
+ const text = toText(output?.content);
277
+ const action = actionCallMap.get(evt.run_id);
278
+ if (action) {
279
+ const parsed = safeParseToolContent(output?.content);
280
+ const failed =
281
+ parsed?.success === false || Boolean(parsed?.error);
282
+ write('action_call', {
283
+ ...action,
284
+ output: text,
285
+ status: failed ? 'error' : 'done',
286
+ ...(failed && {
287
+ error:
288
+ typeof parsed?.error === 'string'
289
+ ? parsed.error
290
+ : 'Action failed',
291
+ }),
292
+ });
293
+ actionCallMap.delete(evt.run_id);
294
+ continue;
295
+ }
296
+ const tool = toolCallMap.get(evt.run_id);
297
+ if (tool) {
298
+ write('tool_call', {
299
+ ...tool,
300
+ status: 'done',
301
+ output: text,
302
+ eventId: evt.run_id,
303
+ args: {
304
+ ...tool.args,
305
+ toolName: output?.name ?? tool.toolName,
306
+ },
307
+ });
308
+ toolCallMap.delete(evt.run_id);
309
+ }
310
+ continue;
311
+ }
312
+ if (evt.event === 'on_tool_error') {
313
+ // A tool that threw (retries exhausted, upstream down, MCP
314
+ // timeout) never emits `on_tool_end`; without this branch the
315
+ // client only learns "did not complete" at stream end.
316
+ const raw = (evt.data as { error?: unknown })?.error;
317
+ const message =
318
+ raw instanceof Error
319
+ ? raw.message
320
+ : String(raw ?? 'Tool failed');
321
+ const action = actionCallMap.get(evt.run_id);
322
+ if (action) {
323
+ write('action_call', {
324
+ ...action,
325
+ output: message,
326
+ status: 'error',
327
+ error: message,
328
+ });
329
+ actionCallMap.delete(evt.run_id);
330
+ continue;
331
+ }
332
+ const tool = toolCallMap.get(evt.run_id);
333
+ if (tool) {
334
+ write('tool_call', {
335
+ ...tool,
336
+ status: 'done',
337
+ output: `⚠️ ${message}`,
338
+ eventId: evt.run_id,
339
+ });
340
+ toolCallMap.delete(evt.run_id);
341
+ }
342
+ continue;
343
+ }
344
+ if (evt.event === 'on_chat_model_stream') {
345
+ const chunk = (evt.data as { chunk?: AIMessageChunk })?.chunk;
346
+ if (!chunk) continue;
347
+ const raw = chunk.additional_kwargs?.__raw_response as
348
+ | {
349
+ choices?: Array<{
350
+ delta?: {
351
+ reasoning?: string;
352
+ reasoning_content?: string;
353
+ reasoning_details?: unknown[];
354
+ };
355
+ }>;
356
+ }
357
+ | undefined;
358
+ const delta = raw?.choices?.[0]?.delta;
359
+ const deltaReasoning =
360
+ delta?.reasoning ?? delta?.reasoning_content;
361
+ const reasoning = deltaReasoning?.trim()
362
+ ? deltaReasoning
363
+ : collectBlockStrings(chunk.content, 'reasoning', 'reasoning');
364
+ if (reasoning && reasoning.trim()) {
365
+ const details = Array.isArray(delta?.reasoning_details)
366
+ ? delta.reasoning_details
367
+ .filter(
368
+ (d): d is { type: string; text: string } =>
369
+ !!d &&
370
+ typeof d === 'object' &&
371
+ typeof (d as { type?: unknown }).type === 'string' &&
372
+ typeof (d as { text?: unknown }).text === 'string' &&
373
+ (d as { text: string }).text.trim().length > 0,
374
+ )
375
+ .map((d) => ({
376
+ type: d.type,
377
+ text: d.text,
378
+ format: 'unknown',
379
+ index: 0,
380
+ }))
381
+ : undefined;
382
+ write('reasoning', {
383
+ sessionId,
384
+ requestId,
385
+ reasoning,
386
+ ...(details && { reasoningDetails: details }),
387
+ isComplete: false,
388
+ timestamp: new Date().toISOString(),
389
+ });
390
+ }
391
+ const text =
392
+ typeof chunk.content === 'string'
393
+ ? chunk.content
394
+ : collectBlockStrings(chunk.content, 'text', 'text');
395
+ if (text) {
396
+ fullContent += text;
397
+ write('message', {
398
+ content: text,
399
+ timestamp: new Date().toISOString(),
400
+ });
401
+ }
402
+ }
403
+ }
404
+ if (!abortController.signal.aborted) {
405
+ flushOrphans();
406
+ write('reasoning', {
407
+ sessionId,
408
+ requestId,
409
+ reasoning: '',
410
+ isComplete: true,
411
+ timestamp: new Date().toISOString(),
412
+ });
413
+ write('done', {});
414
+ await input.onComplete?.(fullContent);
415
+ } else {
416
+ // Aborted by the client (POST /messages/abort): close the stream
417
+ // cleanly so the UI leaves its "thinking" state.
418
+ write('done', {}, { force: true });
419
+ }
420
+ } catch (error) {
421
+ const aborted =
422
+ error instanceof Error &&
423
+ (error.name === 'AbortError' || /abort/i.test(error.message));
424
+ if (!aborted) {
425
+ input.onError?.(error);
426
+ flushOrphans();
427
+ write('reasoning', {
428
+ sessionId,
429
+ requestId,
430
+ reasoning: '',
431
+ isComplete: true,
432
+ timestamp: new Date().toISOString(),
433
+ });
434
+ // Same wire shape as the Node runtime's `sendSSEError`: the
435
+ // classification is redacted here — the one place an LLM failure
436
+ // becomes bytes on a client's wire.
437
+ const classified = classifyLlmError(error, {
438
+ byoProvider: input.byoProvider,
439
+ });
440
+ if (isOperatorFault(classified))
441
+ input.log?.(
442
+ `OPERATOR FAULT (platform ${classified.kind}) on turn ${requestId}: ${classified.detail}`,
443
+ );
444
+ const safe = redactOperatorFault(classified);
445
+ write('error', {
446
+ error: safe.message,
447
+ kind: safe.kind,
448
+ source: safe.source,
449
+ ...(safe.provider && { provider: safe.provider }),
450
+ ...(safe.providerLabel && { providerLabel: safe.providerLabel }),
451
+ ...(safe.status !== undefined && { status: safe.status }),
452
+ retryable: safe.retryable,
453
+ detail: safe.detail,
454
+ sessionId,
455
+ requestId,
456
+ timestamp: new Date().toISOString(),
457
+ });
458
+ }
459
+ write('done', {}, { force: true });
460
+ } finally {
461
+ finish();
462
+ }
463
+ };
464
+ void run();
465
+ },
466
+ cancel: () => {
467
+ closed = true;
468
+ if (heartbeat) clearInterval(heartbeat);
469
+ abortController.abort();
470
+ },
471
+ });
472
+ }