okengine 0.13.0 → 0.14.1

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 (105) hide show
  1. package/manifest.v1.schema.json +7 -1
  2. package/package.json +2 -2
  3. package/site/content/docs/ai/mcp.mdx +27 -2
  4. package/site/content/docs/elements/ai.mdx +58 -11
  5. package/site/content/docs/elements/clock.mdx +27 -12
  6. package/site/content/docs/elements/flow.mdx +6 -3
  7. package/site/content/docs/elements/signal.mdx +71 -25
  8. package/site/content/docs/elements/store.mdx +1 -1
  9. package/site/content/docs/get-started/installation.mdx +2 -2
  10. package/site/content/docs/reference/fx.mdx +27 -22
  11. package/src/cli/ai-setup/recommend.test.ts +25 -0
  12. package/src/cli/ai-setup/recommend.ts +8 -3
  13. package/src/compiler/effects-infer.ts +58 -3
  14. package/src/compiler/extract.test.ts +54 -1
  15. package/src/compiler/extract.ts +69 -2
  16. package/src/compiler/response.ts +45 -1
  17. package/src/console/server/ai.ts +5 -2
  18. package/src/console/server/flows.ts +1 -0
  19. package/src/console/server/serve.ts +17 -2
  20. package/src/console/ui-next/dist/assets/cache-glyph-BanhLsEY.js +1 -0
  21. package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +1 -0
  22. package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +1 -0
  23. package/src/console/ui-next/dist/assets/{index-Bp-R7jtM.js → index-Ce6WKWKM.js} +3 -3
  24. package/src/console/ui-next/dist/assets/observability-page-BEZDzyYh.js +4 -0
  25. package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +2 -0
  26. package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +1 -0
  27. package/src/console/ui-next/dist/assets/{vault-page-Ca-MvcmJ.js → vault-page-DuKzqwzW.js} +1 -1
  28. package/src/console/ui-next/dist/index.html +1 -1
  29. package/src/console/ui-next/seed-invoke-host.ts +2 -0
  30. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
  31. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
  32. package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
  33. package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
  34. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
  35. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  36. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
  37. package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +10 -1
  38. package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
  39. package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
  40. package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
  41. package/src/docker/docker.test.ts +1 -1
  42. package/src/docker/dockerfile.ts +1 -1
  43. package/src/drivers/ai-anthropic.ts +5 -0
  44. package/src/drivers/ai-ollama.ts +49 -30
  45. package/src/drivers/ai-openai-compatible.ts +57 -46
  46. package/src/drivers/ai-providers.test.ts +3 -0
  47. package/src/drivers/bun-native-completeness.test.ts +7 -9
  48. package/src/drivers/redis.ts +11 -4
  49. package/src/drivers/signal-redis.ts +24 -14
  50. package/src/drivers/signal-types.ts +2 -1
  51. package/src/elements/ai/declare.ts +109 -0
  52. package/src/elements/ai/errors.test.ts +5 -1
  53. package/src/elements/ai/errors.ts +30 -2
  54. package/src/elements/ai/eval.ts +4 -6
  55. package/src/elements/ai/mcp-client.test.ts +206 -0
  56. package/src/elements/ai/mcp-client.ts +362 -0
  57. package/src/elements/ai/mcp-http.ts +159 -0
  58. package/src/elements/ai/mcp-mock.ts +134 -0
  59. package/src/elements/ai/mcp-protocol.ts +234 -0
  60. package/src/elements/ai/mcp-stdio.test.ts +50 -0
  61. package/src/elements/ai/mcp-stdio.ts +212 -0
  62. package/src/elements/ai/mcp-transport.ts +70 -0
  63. package/src/elements/ai/runtime.ts +159 -29
  64. package/src/elements/ai.test.ts +139 -0
  65. package/src/elements/ai.ts +16 -0
  66. package/src/elements/clock/health.test.ts +43 -0
  67. package/src/elements/clock/runtime.ts +55 -2
  68. package/src/elements/clock/schedule.ts +71 -142
  69. package/src/elements/clock.test.ts +9 -40
  70. package/src/elements/clock.ts +6 -1
  71. package/src/elements/index.ts +2 -0
  72. package/src/elements/signal/declare.ts +2 -1
  73. package/src/elements/signal/runtime.ts +16 -1
  74. package/src/elements/signal.ts +1 -0
  75. package/src/elements/store/cache.test.ts +2 -0
  76. package/src/elements/store/cache.ts +3 -3
  77. package/src/elements/vault/builtin-adapter.ts +17 -0
  78. package/src/full.ts +2 -0
  79. package/src/index.ts +4 -0
  80. package/src/kernel/app.ts +97 -64
  81. package/src/kernel/auto-registry.test.ts +5 -0
  82. package/src/kernel/boot-bind/ai.ts +24 -0
  83. package/src/kernel/boot-bind/clock.ts +2 -0
  84. package/src/kernel/boot.ts +3 -1
  85. package/src/kernel/element-registries.ts +4 -1
  86. package/src/kernel/fx-dead-letters.test.ts +77 -0
  87. package/src/kernel/fx.test.ts +22 -0
  88. package/src/kernel/fx.ts +112 -6
  89. package/src/kernel/http-stream.test.ts +174 -0
  90. package/src/kernel/index.ts +2 -0
  91. package/src/manifest/mcp-ref.ts +88 -0
  92. package/src/manifest/types.ts +28 -2
  93. package/src/manifest/validate.test.ts +20 -0
  94. package/src/mcp/docs-server.ts +1 -1
  95. package/src/mcp/server.ts +1 -1
  96. package/src/plugins/compression.test.ts +21 -0
  97. package/src/plugins/compression.ts +1 -0
  98. package/src/runtime/bun.ts +41 -4
  99. package/src/test/reset-element-registries.ts +2 -0
  100. package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
  101. package/src/console/ui-next/dist/assets/flows-page-Dg8CTE29.js +0 -1
  102. package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
  103. package/src/console/ui-next/dist/assets/observability-page-DAnpEaq1.js +0 -4
  104. package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
  105. package/src/console/ui-next/dist/assets/units-page-BtQ0bqMe.js +0 -1
@@ -156,10 +156,7 @@ export async function openOllama(options: AiOpenOptions = {}): Promise<AiModelCl
156
156
  const msg = raw.error ?? `ollama HTTP ${res.status}`;
157
157
  throw new OllamaUnavailableError(`ollama: ${msg}`);
158
158
  }
159
- if (!res.body) {
160
- throw new OllamaUnavailableError("ollama: stream response has no body");
161
- }
162
- yield* readOllamaNdjson(res.body, opts.signal);
159
+ yield* readOllamaNdjson(res, opts.signal);
163
160
  },
164
161
  async embed(opts: AiEmbedOptions): Promise<AiEmbedResult> {
165
162
  const resolvedModel = resolveOllamaModel(options, opts.model);
@@ -265,45 +262,45 @@ function parseOllamaToolCalls(
265
262
  }
266
263
 
267
264
  /**
268
- * Parse Ollama NDJSON stream lines.
265
+ * Parse Ollama NDJSON stream lines via {@link Bun.JSONL.parseChunk}.
269
266
  *
270
- * @param body - Response body
267
+ * @param res - Streaming response
271
268
  * @param signal - Optional abort
272
269
  */
273
270
  async function* readOllamaNdjson(
274
- body: ReadableStream<Uint8Array>,
271
+ res: Response,
275
272
  signal?: AbortSignal,
276
273
  ): AsyncGenerator<AiStreamChunk> {
277
- const reader = body.getReader();
274
+ const reader = res.body?.getReader();
275
+ if (!reader) {
276
+ throw new OllamaUnavailableError("ollama: stream response has no body");
277
+ }
278
278
  const decoder = new TextDecoder();
279
- let buffer = "";
279
+ let pending = "";
280
280
  try {
281
281
  while (true) {
282
- if (signal?.aborted) {
283
- throw abortAsError(signal.reason);
284
- }
282
+ if (signal?.aborted) throw abortAsError(signal.reason);
285
283
  const { done, value } = await reader.read();
286
284
  if (done) break;
287
- buffer += decoder.decode(value, { stream: true });
288
- const lines = buffer.split("\n");
289
- buffer = lines.pop() ?? "";
290
- for (const line of lines) {
291
- const trimmed = line.trim();
292
- if (!trimmed) continue;
293
- try {
294
- const chunk = JSON.parse(trimmed) as OllamaChatResponse;
295
- const delta = chunk.message?.content;
296
- if (typeof delta === "string" && delta.length > 0) {
297
- yield { text: delta };
298
- }
299
- if (chunk.done) {
300
- yield { text: "", done: true };
301
- return;
302
- }
303
- } catch {
304
- // ignore malformed lines
285
+ pending += decoder.decode(value, { stream: true });
286
+ const parsed = bunJsonl().parseChunk(pending);
287
+ pending = pending.slice(parsed.read);
288
+ for (const row of parsed.values) {
289
+ const chunk = row as OllamaChatResponse;
290
+ const delta = chunk.message?.content;
291
+ if (typeof delta === "string" && delta.length > 0) {
292
+ yield { text: delta };
293
+ }
294
+ if (chunk.done) {
295
+ yield { text: "", done: true };
296
+ return;
305
297
  }
306
298
  }
299
+ if (parsed.error) {
300
+ const nl = pending.indexOf("\n");
301
+ if (nl === -1) continue;
302
+ pending = pending.slice(nl + 1);
303
+ }
307
304
  }
308
305
  yield { text: "", done: true };
309
306
  } finally {
@@ -311,6 +308,28 @@ async function* readOllamaNdjson(
311
308
  }
312
309
  }
313
310
 
311
+ interface BunJsonlParse {
312
+ readonly values: readonly unknown[];
313
+ readonly read: number;
314
+ readonly done: boolean;
315
+ readonly error: unknown;
316
+ }
317
+
318
+ function bunJsonl(): {
319
+ parse(text: string): unknown[];
320
+ parseChunk(text: string): BunJsonlParse;
321
+ } {
322
+ const jsonl = (
323
+ Bun as typeof Bun & {
324
+ JSONL?: { parse: (t: string) => unknown[]; parseChunk: (t: string) => BunJsonlParse };
325
+ }
326
+ ).JSONL;
327
+ if (!jsonl) {
328
+ throw new Error("ollama: Bun.JSONL is required (Bun >= 1.4.0)");
329
+ }
330
+ return jsonl;
331
+ }
332
+
314
333
  /**
315
334
  * Probe Ollama — fail loud before the first completion.
316
335
  *
@@ -89,6 +89,7 @@ export async function openOpenaiCompatible(options: AiOpenOptions = {}): Promise
89
89
  const model = options.model ?? "gpt-4o-mini";
90
90
  const fetchFn = options.fetch ?? globalThis.fetch;
91
91
  const extraHeaders = options.headers;
92
+ preconnectFetch(fetchFn, baseUrl);
92
93
 
93
94
  return {
94
95
  driverId: "openai-compatible",
@@ -162,10 +163,7 @@ export async function openOpenaiCompatible(options: AiOpenOptions = {}): Promise
162
163
  const msg = raw.error?.message ?? `openai-compatible HTTP ${res.status}`;
163
164
  throwHttp(`openai-compatible: ${msg}`, res.status);
164
165
  }
165
- if (!res.body) {
166
- throw new Error("openai-compatible: stream response has no body");
167
- }
168
- yield* readOpenaiSse(res.body, opts.signal);
166
+ yield* readOpenaiSse(res, opts.signal);
169
167
  },
170
168
  async embed(opts: AiEmbedOptions): Promise<AiEmbedResult> {
171
169
  const resolvedModel = opts.model ?? model;
@@ -262,57 +260,70 @@ function parseToolCalls(
262
260
  /**
263
261
  * Parse OpenAI SSE chat.completion.chunk stream.
264
262
  *
265
- * @param body - Response body
263
+ * @param res - Streaming response
266
264
  * @param signal - Optional abort
267
265
  */
268
- async function* readOpenaiSse(
269
- body: ReadableStream<Uint8Array>,
270
- signal?: AbortSignal,
271
- ): AsyncGenerator<AiStreamChunk> {
272
- const reader = body.getReader();
273
- const decoder = new TextDecoder();
266
+ async function* readOpenaiSse(res: Response, signal?: AbortSignal): AsyncGenerator<AiStreamChunk> {
274
267
  let buffer = "";
275
- try {
276
- while (true) {
277
- if (signal?.aborted) {
278
- throw abortAsError(signal.reason);
268
+ for await (const piece of responseTextStream(res)) {
269
+ if (signal?.aborted) throw abortAsError(signal.reason);
270
+ buffer += piece;
271
+ const lines = buffer.split("\n");
272
+ buffer = lines.pop() ?? "";
273
+ for (const line of lines) {
274
+ const trimmed = line.trim();
275
+ if (!trimmed.startsWith("data:")) continue;
276
+ const data = trimmed.slice(5).trim();
277
+ if (data === "[DONE]") {
278
+ yield { text: "", done: true };
279
+ return;
279
280
  }
280
- const { done, value } = await reader.read();
281
- if (done) break;
282
- buffer += decoder.decode(value, { stream: true });
283
- const lines = buffer.split("\n");
284
- buffer = lines.pop() ?? "";
285
- for (const line of lines) {
286
- const trimmed = line.trim();
287
- if (!trimmed.startsWith("data:")) continue;
288
- const data = trimmed.slice(5).trim();
289
- if (data === "[DONE]") {
281
+ try {
282
+ const chunk = JSON.parse(data) as {
283
+ choices?: readonly {
284
+ delta?: { content?: string | null };
285
+ finish_reason?: string | null;
286
+ }[];
287
+ };
288
+ const delta = chunk.choices?.[0]?.delta?.content;
289
+ if (typeof delta === "string" && delta.length > 0) {
290
+ yield { text: delta };
291
+ }
292
+ if (chunk.choices?.[0]?.finish_reason) {
290
293
  yield { text: "", done: true };
291
294
  return;
292
295
  }
293
- try {
294
- const chunk = JSON.parse(data) as {
295
- choices?: readonly {
296
- delta?: { content?: string | null };
297
- finish_reason?: string | null;
298
- }[];
299
- };
300
- const delta = chunk.choices?.[0]?.delta?.content;
301
- if (typeof delta === "string" && delta.length > 0) {
302
- yield { text: delta };
303
- }
304
- if (chunk.choices?.[0]?.finish_reason) {
305
- yield { text: "", done: true };
306
- return;
307
- }
308
- } catch {
309
- // ignore malformed SSE lines
310
- }
296
+ } catch {
297
+ // ignore malformed SSE lines
311
298
  }
312
299
  }
313
- yield { text: "", done: true };
314
- } finally {
315
- reader.releaseLock();
300
+ }
301
+ yield { text: "", done: true };
302
+ }
303
+
304
+ /**
305
+ * UTF-8 text chunks from a Response (`textStream` on Bun ≥1.4).
306
+ *
307
+ * @param res - HTTP response
308
+ */
309
+ function responseTextStream(res: Response): AsyncIterable<string> {
310
+ const stream = (res as Response & { textStream?: () => AsyncIterable<string> }).textStream;
311
+ if (typeof stream !== "function") {
312
+ throw new Error("openai-compatible: Response.textStream is required (Bun >= 1.4.0)");
313
+ }
314
+ return stream.call(res);
315
+ }
316
+
317
+ /**
318
+ * Warm DNS+TCP+TLS for a cloud origin. No-op when `fetch` is a test stub.
319
+ *
320
+ * @param fetchFn - Fetch implementation
321
+ * @param url - Provider base URL
322
+ */
323
+ function preconnectFetch(fetchFn: typeof fetch, url: string): void {
324
+ const preconnect = (fetchFn as { preconnect?: (href: string) => void }).preconnect;
325
+ if (typeof preconnect === "function") {
326
+ preconnect(url);
316
327
  }
317
328
  }
318
329
 
@@ -43,12 +43,14 @@ describe("anthropic driver", () => {
43
43
  model: "claude-test",
44
44
  fetch: fetchFn,
45
45
  });
46
+ const ac = new AbortController();
46
47
  const result = await client.complete({
47
48
  messages: [
48
49
  { role: "system", content: "be brief" },
49
50
  { role: "user", content: "hi" },
50
51
  ],
51
52
  maxTokens: 64,
53
+ signal: ac.signal,
52
54
  });
53
55
 
54
56
  expect(result.driverId).toBe("anthropic");
@@ -62,6 +64,7 @@ describe("anthropic driver", () => {
62
64
  };
63
65
  expect(body.system).toBe("be brief");
64
66
  expect(body.messages).toEqual([{ role: "user", content: "hi" } as { role: string }]);
67
+ expect(calls[0]!.init?.signal).toBe(ac.signal);
65
68
  });
66
69
 
67
70
  test("HTTP error surfaces provider message", async () => {
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Bun native-client completeness — gaps closed vs platform limitations.
3
3
  *
4
- * Checked against Bun 1.3.14 (runtime + bun-types). Do not invent custom
5
- * protocol clients where Bun still lacks an API.
4
+ * Checked against Bun 1.4 (runtime). Do not invent custom protocol clients
5
+ * where Bun still lacks an API.
6
6
  */
7
7
 
8
8
  import { describe, expect, test } from "bun:test";
@@ -46,7 +46,7 @@ export function bunNativeCompletenessReport(): readonly BunNativeGapRow[] {
46
46
  status: hasTypedEval ? "closed" : "bun_limitation",
47
47
  note: hasTypedEval
48
48
  ? "typed eval bound"
49
- : "Bun 1.3.14 has no typed eval driver uses send(EVAL)",
49
+ : "Bun.RedisClient.eval unavailablewould keep send(EVAL)",
50
50
  },
51
51
  {
52
52
  surface: "redis signal",
@@ -63,7 +63,7 @@ export function bunNativeCompletenessReport(): readonly BunNativeGapRow[] {
63
63
  status: hasTypedXadd ? "closed" : "bun_limitation",
64
64
  note: hasTypedXadd
65
65
  ? "typed stream helpers bound"
66
- : "Bun 1.3.14 has no typed xadd — Streams use send (native, not a custom client)",
66
+ : "Bun.RedisClient.xadd unavailable — Streams would keep send",
67
67
  },
68
68
  {
69
69
  surface: "redis signal",
@@ -95,23 +95,21 @@ export function bunNativeCompletenessReport(): readonly BunNativeGapRow[] {
95
95
  }
96
96
 
97
97
  describe("Bun native client completeness", () => {
98
- test("report table is exact and matches runtime Bun 1.3.x", () => {
98
+ test("report table is exact and matches runtime Bun 1.4", () => {
99
99
  const rows = bunNativeCompletenessReport();
100
100
  expect(rows.length).toBe(8);
101
101
 
102
102
  const closed = rows.filter((r) => r.status === "closed").map((r) => r.capability);
103
103
  const limited = rows.filter((r) => r.status === "bun_limitation").map((r) => r.capability);
104
104
 
105
- // Closed on Bun 1.3.14
106
105
  expect(closed).toContain("SCAN (typed)");
106
+ expect(closed).toContain("EVAL (typed)");
107
107
  expect(closed).toContain("native Bun bind (publish/subscribe)");
108
+ expect(closed).toContain("XADD / XREADGROUP (typed)");
108
109
  expect(closed).toContain("send escape hatch");
109
110
  expect(closed).toContain("Bun.SQL.unsafe query/exec");
110
111
  expect(closed).toContain("Bun.S3Client file/list");
111
112
 
112
- // Genuine Bun platform limitations (do not invent workarounds)
113
- expect(limited).toContain("EVAL (typed)");
114
- expect(limited).toContain("XADD / XREADGROUP (typed)");
115
113
  expect(limited).toContain("LISTEN / NOTIFY on Bun.SQL");
116
114
  });
117
115
 
@@ -140,11 +140,19 @@ function ttlToSeconds(ttl: string): number {
140
140
  }
141
141
  }
142
142
 
143
+ /** Bun 1.4 typed methods — `@types/bun` may lag the runtime. */
144
+ interface BunRedisEval {
145
+ eval(script: string, numkeys: number, ...keysAndArgs: string[]): Promise<unknown>;
146
+ }
147
+
148
+ function typedRedis(redis: Bun.RedisClient): Bun.RedisClient & BunRedisEval {
149
+ return redis as Bun.RedisClient & BunRedisEval;
150
+ }
151
+
143
152
  /**
144
153
  * Bind {@link Bun.RedisClient} / {@link Bun.redis} to {@link KvClientLike}.
145
154
  *
146
- * Uses typed `scan` (Bun ≥1.3). `EVAL` still goes through `send` — Bun 1.3.14
147
- * has no typed `eval` yet (platform limitation, not an OKE stub).
155
+ * Uses typed `scan` / `eval` (Bun ≥1.4). `send` remains the escape hatch.
148
156
  *
149
157
  * @param url - Optional Redis URL
150
158
  */
@@ -174,8 +182,7 @@ export function createBunRedisClient(url?: string): KvClientLike {
174
182
  return redis.scan(cursor);
175
183
  },
176
184
  async eval(script, numkeys, ...keysAndArgs) {
177
- // Bun 1.3.14: no typed eval raw send remains the native path.
178
- return redis.send("EVAL", [script, String(numkeys), ...keysAndArgs]);
185
+ return typedRedis(redis).eval(script, numkeys, ...keysAndArgs);
179
186
  },
180
187
  send: (command, args) => redis.send(command, args),
181
188
  };
@@ -7,7 +7,7 @@
7
7
  * exactly-once / fan-out physics stay correct under `drain()`.
8
8
  *
9
9
  * Production bind: {@link createBunSignalRedisClient} (typed `publish` /
10
- * `subscribe`; Streams via `send` Bun 1.3.14 has no typed `xadd` yet).
10
+ * `subscribe` / `xadd` / `xreadgroup` / `xack` / `xgroup` on Bun ≥1.4).
11
11
  */
12
12
 
13
13
  import { createSignalEngine } from "./signal-engine.ts";
@@ -18,12 +18,23 @@ import type {
18
18
  SignalRedisClientLike,
19
19
  } from "./signal-types.ts";
20
20
 
21
+ /** Bun 1.4 typed stream methods — `@types/bun` may lag the runtime. */
22
+ interface BunRedisStreams {
23
+ xadd(key: string, ...args: (string | number)[]): Promise<unknown>;
24
+ xreadgroup(...args: (string | number)[]): Promise<unknown>;
25
+ xack(key: string, group: string, id: string): Promise<unknown>;
26
+ xgroup(subcommand: string, ...args: (string | number)[]): Promise<unknown>;
27
+ }
28
+
29
+ function typedStreams(redis: Bun.RedisClient): Bun.RedisClient & BunRedisStreams {
30
+ return redis as Bun.RedisClient & BunRedisStreams;
31
+ }
32
+
21
33
  /**
22
34
  * Bind {@link Bun.RedisClient} to {@link SignalRedisClientLike}.
23
35
  *
24
- * Streams (`XADD` / `XGROUP` / `XREADGROUP` / `XACK`) use `send` because
25
- * Bun 1.3.14 still lacks typed stream helpers — that is a Bun limitation.
26
- * Pub/sub uses typed `publish` / `subscribe` (gap closed vs earlier fake-only).
36
+ * Streams use typed `xadd` / `xgroup` / `xreadgroup` / `xack` (Bun ≥1.4).
37
+ * Pub/sub uses typed `publish` / `subscribe`.
27
38
  *
28
39
  * @param url - Optional Redis URL
29
40
  */
@@ -41,37 +52,36 @@ export function createBunSignalRedisClient(url?: string): SignalRedisClientLike
41
52
 
42
53
  return {
43
54
  async xadd(key, id, fields) {
44
- const args: string[] = [key, id];
55
+ const pairs: string[] = [];
45
56
  for (const [k, v] of Object.entries(fields)) {
46
- args.push(k, v);
57
+ pairs.push(k, v);
47
58
  }
48
- return String(await redis.send("XADD", args));
59
+ return String(await typedStreams(redis).xadd(key, id, ...pairs));
49
60
  },
50
61
  async xgroupCreate(key, group, id, opts) {
51
- const args = ["CREATE", key, group, id];
52
- if (opts?.mkstream) args.push("MKSTREAM");
62
+ const extra = opts?.mkstream ? (["MKSTREAM"] as const) : [];
53
63
  try {
54
- await redis.send("XGROUP", args);
64
+ await typedStreams(redis).xgroup("CREATE", key, group, id, ...extra);
55
65
  } catch (err) {
56
66
  const msg = err instanceof Error ? err.message : String(err);
57
67
  if (!/BUSYGROUP/i.test(msg)) throw err;
58
68
  }
59
69
  },
60
70
  async xreadgroup(group, consumer, key, count) {
61
- const reply = await redis.send("XREADGROUP", [
71
+ const reply = await typedStreams(redis).xreadgroup(
62
72
  "GROUP",
63
73
  group,
64
74
  consumer,
65
75
  "COUNT",
66
- String(count),
76
+ count,
67
77
  "STREAMS",
68
78
  key,
69
79
  ">",
70
- ]);
80
+ );
71
81
  return parseXreadgroupReply(reply);
72
82
  },
73
83
  async xack(key, group, id) {
74
- return Number(await redis.send("XACK", [key, group, id]));
84
+ return Number(await typedStreams(redis).xack(key, group, id));
75
85
  },
76
86
  async publish(channel, message) {
77
87
  return redis.publish(channel, message);
@@ -69,8 +69,9 @@ export interface SignalMessage {
69
69
  }
70
70
 
71
71
  /** Dead-letter entry with full attempt history. */
72
- export interface DeadLetter extends SignalMessage {
72
+ export interface DeadLetter<T = unknown> extends Omit<SignalMessage, "payload" | "status"> {
73
73
  readonly status: "dead";
74
+ readonly payload: T;
74
75
  }
75
76
 
76
77
  /** Per-subscriber lag / errors (broadcast physics). */
@@ -7,9 +7,12 @@
7
7
  import {
8
8
  aiAgentRegistry,
9
9
  aiEmbedRegistry,
10
+ aiMcpServerRegistry,
10
11
  aiModelRegistry,
11
12
  aiPromptRegistry,
12
13
  } from "../../kernel/element-registries.ts";
14
+ import { mcpToolRef, type McpToolRef } from "../../manifest/mcp-ref.ts";
15
+ import type { VaultSecretDecl } from "../vault/declare.ts";
13
16
 
14
17
  /** Budget for a prompt or agent. */
15
18
  export interface AiBudgetDecl {
@@ -30,6 +33,11 @@ export interface AiModelOptions {
30
33
  readonly baseUrl?: string;
31
34
  /** Optional API key override for this binding (cloud providers). */
32
35
  readonly apiKey?: string;
36
+ /**
37
+ * Protocol driver for this binding (`anthropic`, `ollama`, …).
38
+ * When omitted, the app-level default driver is used.
39
+ */
40
+ readonly driverId?: string;
33
41
  }
34
42
 
35
43
  /**
@@ -77,6 +85,7 @@ export interface AiModelDecl {
77
85
  readonly model?: string;
78
86
  readonly baseUrl?: string;
79
87
  readonly apiKey?: string;
88
+ readonly driverId?: string;
80
89
  /**
81
90
  * Declare a versioned prompt artifact on this model.
82
91
  *
@@ -118,6 +127,54 @@ export interface AiAgentDecl {
118
127
  readonly budget?: AiBudgetDecl;
119
128
  }
120
129
 
130
+ /** Bearer auth for {@link ai.mcpServer} — secret contract, never a token literal. */
131
+ export interface AiMcpServerAuthOptions {
132
+ readonly bearer: VaultSecretDecl | string;
133
+ }
134
+
135
+ /** Options for {@link ai.mcpServer}. */
136
+ export interface AiMcpServerOptions {
137
+ /** Streamable HTTP endpoint. */
138
+ readonly url?: string;
139
+ /** stdio executable (no shell string). */
140
+ readonly command?: string;
141
+ /** Arguments for {@link command}. */
142
+ readonly args?: readonly string[];
143
+ /** Bearer secret contract (`vault.secret` handle or name). */
144
+ readonly auth?: AiMcpServerAuthOptions;
145
+ /**
146
+ * Required allowlist of tool names on this server.
147
+ * The runtime never offers whatever `tools/list` happens to expose.
148
+ */
149
+ readonly tools: readonly string[];
150
+ }
151
+
152
+ /** Named capability ref returned by {@link AiMcpServerDecl.tool}. */
153
+ export interface AiMcpToolRef {
154
+ readonly name: McpToolRef;
155
+ }
156
+
157
+ /**
158
+ * Declared external MCP server — tools join `fx.call` / `toolLoop` as
159
+ * `mcp:<server>/<tool>`.
160
+ */
161
+ export interface AiMcpServerDecl {
162
+ readonly kind: "mcp-server";
163
+ readonly name: string;
164
+ readonly url?: string;
165
+ readonly command?: string;
166
+ readonly args?: readonly string[];
167
+ /** Secret contract name when bearer auth is declared. */
168
+ readonly auth?: string;
169
+ readonly tools: readonly string[];
170
+ /**
171
+ * Capability ref for one allowlisted tool (`mcp:<server>/<tool>`).
172
+ *
173
+ * @param tool - Tool name on this server
174
+ */
175
+ tool(tool: string): AiMcpToolRef;
176
+ }
177
+
121
178
  /**
122
179
  * Resolve a tool ref to a flow name.
123
180
  *
@@ -152,6 +209,13 @@ export interface AiNamespace {
152
209
  * @param options - Tools / maxSteps / model / budget
153
210
  */
154
211
  agent(name: string, options?: AiAgentOptions): AiAgentDecl;
212
+ /**
213
+ * Declare an external MCP server whose allowlisted tools join `fx.call`.
214
+ *
215
+ * @param name - Server id (`github`, `linear`, …)
216
+ * @param options - Transport + required tool allowlist
217
+ */
218
+ mcpServer(name: string, options: AiMcpServerOptions): AiMcpServerDecl;
155
219
  }
156
220
 
157
221
  /**
@@ -162,12 +226,14 @@ export function listAiDecls(): {
162
226
  readonly prompts: readonly AiPromptDecl[];
163
227
  readonly embeds: readonly AiEmbedDecl[];
164
228
  readonly agents: readonly AiAgentDecl[];
229
+ readonly mcpServers: readonly AiMcpServerDecl[];
165
230
  } {
166
231
  return {
167
232
  models: aiModelRegistry.slice(),
168
233
  prompts: aiPromptRegistry.slice(),
169
234
  embeds: aiEmbedRegistry.slice(),
170
235
  agents: aiAgentRegistry.slice(),
236
+ mcpServers: aiMcpServerRegistry.slice(),
171
237
  };
172
238
  }
173
239
 
@@ -179,6 +245,7 @@ export function resetAiDecls(): void {
179
245
  aiPromptRegistry.length = 0;
180
246
  aiEmbedRegistry.length = 0;
181
247
  aiAgentRegistry.length = 0;
248
+ aiMcpServerRegistry.length = 0;
182
249
  }
183
250
 
184
251
  /**
@@ -201,6 +268,7 @@ export const ai: AiNamespace = {
201
268
  ...(options.model !== undefined ? { model: options.model } : {}),
202
269
  ...(options.baseUrl !== undefined ? { baseUrl: options.baseUrl } : {}),
203
270
  ...(options.apiKey !== undefined ? { apiKey: options.apiKey } : {}),
271
+ ...(options.driverId !== undefined ? { driverId: options.driverId } : {}),
204
272
  prompt(promptName, promptOpts = {}) {
205
273
  const promptDecl: AiPromptDecl = {
206
274
  kind: "prompt",
@@ -263,4 +331,45 @@ export const ai: AiNamespace = {
263
331
  aiAgentRegistry.push(decl);
264
332
  return decl;
265
333
  },
334
+
335
+ /**
336
+ * Declare an external MCP server whose allowlisted tools join `fx.call`.
337
+ *
338
+ * @param name - Server id
339
+ * @param options - Transport + required tool allowlist
340
+ */
341
+ mcpServer(name: string, options: AiMcpServerOptions): AiMcpServerDecl {
342
+ if (!name) throw new TypeError("ai.mcpServer: name is required");
343
+ if (name.includes("/") || name.includes("__")) {
344
+ throw new TypeError(`ai.mcpServer: name "${name}" must not contain "/" or "__"`);
345
+ }
346
+ if (!options.tools || !Array.isArray(options.tools)) {
347
+ throw new TypeError("ai.mcpServer: tools allowlist is required");
348
+ }
349
+ const hasUrl = typeof options.url === "string" && options.url.length > 0;
350
+ const hasCommand = typeof options.command === "string" && options.command.length > 0;
351
+ if (hasUrl === hasCommand) {
352
+ throw new TypeError("ai.mcpServer: declare exactly one of url or command");
353
+ }
354
+ const allow = new Set(options.tools);
355
+ const authName =
356
+ typeof options.auth?.bearer === "string" ? options.auth.bearer : options.auth?.bearer?.name;
357
+ const decl: AiMcpServerDecl = {
358
+ kind: "mcp-server",
359
+ name,
360
+ ...(hasUrl ? { url: options.url } : {}),
361
+ ...(hasCommand ? { command: options.command } : {}),
362
+ ...(options.args !== undefined ? { args: options.args } : {}),
363
+ ...(authName !== undefined ? { auth: authName } : {}),
364
+ tools: options.tools,
365
+ tool(tool: string): AiMcpToolRef {
366
+ if (!allow.has(tool)) {
367
+ throw new TypeError(`ai.mcpServer("${name}"): tool "${tool}" is not in the allowlist`);
368
+ }
369
+ return { name: mcpToolRef(name, tool) };
370
+ },
371
+ };
372
+ aiMcpServerRegistry.push(decl);
373
+ return decl;
374
+ },
266
375
  };
@@ -22,7 +22,11 @@ describe("isRetryableAiError", () => {
22
22
  expect(isRetryableAiError(new Error("openai-compatible HTTP 401"))).toBe(false);
23
23
  const abort = new Error("aborted");
24
24
  abort.name = "AbortError";
25
- expect(isRetryableAiError(abort)).toBe(true);
25
+ expect(isRetryableAiError(abort)).toBe(false);
26
+ const timeout = new Error("deadline");
27
+ timeout.name = "TimeoutError";
28
+ expect(isRetryableAiError(timeout)).toBe(true);
29
+ expect(isRetryableAiError(new Error("request timeout"))).toBe(true);
26
30
  });
27
31
  });
28
32