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.
- package/manifest.v1.schema.json +7 -1
- package/package.json +2 -2
- package/site/content/docs/ai/mcp.mdx +27 -2
- package/site/content/docs/elements/ai.mdx +58 -11
- package/site/content/docs/elements/clock.mdx +27 -12
- package/site/content/docs/elements/flow.mdx +6 -3
- package/site/content/docs/elements/signal.mdx +71 -25
- package/site/content/docs/elements/store.mdx +1 -1
- package/site/content/docs/get-started/installation.mdx +2 -2
- package/site/content/docs/reference/fx.mdx +27 -22
- package/src/cli/ai-setup/recommend.test.ts +25 -0
- package/src/cli/ai-setup/recommend.ts +8 -3
- package/src/compiler/effects-infer.ts +58 -3
- package/src/compiler/extract.test.ts +54 -1
- package/src/compiler/extract.ts +69 -2
- package/src/compiler/response.ts +45 -1
- package/src/console/server/ai.ts +5 -2
- package/src/console/server/flows.ts +1 -0
- package/src/console/server/serve.ts +17 -2
- package/src/console/ui-next/dist/assets/cache-glyph-BanhLsEY.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +1 -0
- package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +1 -0
- package/src/console/ui-next/dist/assets/{index-Bp-R7jtM.js → index-Ce6WKWKM.js} +3 -3
- package/src/console/ui-next/dist/assets/observability-page-BEZDzyYh.js +4 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +2 -0
- package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +1 -0
- package/src/console/ui-next/dist/assets/{vault-page-Ca-MvcmJ.js → vault-page-DuKzqwzW.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-invoke-host.ts +2 -0
- package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
- package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
- package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
- package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
- package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
- package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
- package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +10 -1
- package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
- package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
- package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
- package/src/docker/docker.test.ts +1 -1
- package/src/docker/dockerfile.ts +1 -1
- package/src/drivers/ai-anthropic.ts +5 -0
- package/src/drivers/ai-ollama.ts +49 -30
- package/src/drivers/ai-openai-compatible.ts +57 -46
- package/src/drivers/ai-providers.test.ts +3 -0
- package/src/drivers/bun-native-completeness.test.ts +7 -9
- package/src/drivers/redis.ts +11 -4
- package/src/drivers/signal-redis.ts +24 -14
- package/src/drivers/signal-types.ts +2 -1
- package/src/elements/ai/declare.ts +109 -0
- package/src/elements/ai/errors.test.ts +5 -1
- package/src/elements/ai/errors.ts +30 -2
- package/src/elements/ai/eval.ts +4 -6
- package/src/elements/ai/mcp-client.test.ts +206 -0
- package/src/elements/ai/mcp-client.ts +362 -0
- package/src/elements/ai/mcp-http.ts +159 -0
- package/src/elements/ai/mcp-mock.ts +134 -0
- package/src/elements/ai/mcp-protocol.ts +234 -0
- package/src/elements/ai/mcp-stdio.test.ts +50 -0
- package/src/elements/ai/mcp-stdio.ts +212 -0
- package/src/elements/ai/mcp-transport.ts +70 -0
- package/src/elements/ai/runtime.ts +159 -29
- package/src/elements/ai.test.ts +139 -0
- package/src/elements/ai.ts +16 -0
- package/src/elements/clock/health.test.ts +43 -0
- package/src/elements/clock/runtime.ts +55 -2
- package/src/elements/clock/schedule.ts +71 -142
- package/src/elements/clock.test.ts +9 -40
- package/src/elements/clock.ts +6 -1
- package/src/elements/index.ts +2 -0
- package/src/elements/signal/declare.ts +2 -1
- package/src/elements/signal/runtime.ts +16 -1
- package/src/elements/signal.ts +1 -0
- package/src/elements/store/cache.test.ts +2 -0
- package/src/elements/store/cache.ts +3 -3
- package/src/elements/vault/builtin-adapter.ts +17 -0
- package/src/full.ts +2 -0
- package/src/index.ts +4 -0
- package/src/kernel/app.ts +97 -64
- package/src/kernel/auto-registry.test.ts +5 -0
- package/src/kernel/boot-bind/ai.ts +24 -0
- package/src/kernel/boot-bind/clock.ts +2 -0
- package/src/kernel/boot.ts +3 -1
- package/src/kernel/element-registries.ts +4 -1
- package/src/kernel/fx-dead-letters.test.ts +77 -0
- package/src/kernel/fx.test.ts +22 -0
- package/src/kernel/fx.ts +112 -6
- package/src/kernel/http-stream.test.ts +174 -0
- package/src/kernel/index.ts +2 -0
- package/src/manifest/mcp-ref.ts +88 -0
- package/src/manifest/types.ts +28 -2
- package/src/manifest/validate.test.ts +20 -0
- package/src/mcp/docs-server.ts +1 -1
- package/src/mcp/server.ts +1 -1
- package/src/plugins/compression.test.ts +21 -0
- package/src/plugins/compression.ts +1 -0
- package/src/runtime/bun.ts +41 -4
- package/src/test/reset-element-registries.ts +2 -0
- package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-Dg8CTE29.js +0 -1
- package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
- package/src/console/ui-next/dist/assets/observability-page-DAnpEaq1.js +0 -4
- package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
- package/src/console/ui-next/dist/assets/units-page-BtQ0bqMe.js +0 -1
package/src/drivers/ai-ollama.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
267
|
+
* @param res - Streaming response
|
|
271
268
|
* @param signal - Optional abort
|
|
272
269
|
*/
|
|
273
270
|
async function* readOllamaNdjson(
|
|
274
|
-
|
|
271
|
+
res: Response,
|
|
275
272
|
signal?: AbortSignal,
|
|
276
273
|
): AsyncGenerator<AiStreamChunk> {
|
|
277
|
-
const reader = body
|
|
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
|
|
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
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
for (const
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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.
|
|
5
|
-
*
|
|
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
|
|
49
|
+
: "Bun.RedisClient.eval unavailable — would 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
|
|
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.
|
|
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
|
|
package/src/drivers/redis.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
|
25
|
-
*
|
|
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
|
|
55
|
+
const pairs: string[] = [];
|
|
45
56
|
for (const [k, v] of Object.entries(fields)) {
|
|
46
|
-
|
|
57
|
+
pairs.push(k, v);
|
|
47
58
|
}
|
|
48
|
-
return String(await redis.
|
|
59
|
+
return String(await typedStreams(redis).xadd(key, id, ...pairs));
|
|
49
60
|
},
|
|
50
61
|
async xgroupCreate(key, group, id, opts) {
|
|
51
|
-
const
|
|
52
|
-
if (opts?.mkstream) args.push("MKSTREAM");
|
|
62
|
+
const extra = opts?.mkstream ? (["MKSTREAM"] as const) : [];
|
|
53
63
|
try {
|
|
54
|
-
await redis.
|
|
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.
|
|
71
|
+
const reply = await typedStreams(redis).xreadgroup(
|
|
62
72
|
"GROUP",
|
|
63
73
|
group,
|
|
64
74
|
consumer,
|
|
65
75
|
"COUNT",
|
|
66
|
-
|
|
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.
|
|
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(
|
|
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
|
|