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
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdio MCP transport — newline-delimited JSON-RPC over a child process.
|
|
3
|
+
*
|
|
4
|
+
* Cancel = `notifications/cancelled` then kill the process.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { abortError } from "../../kernel/abort-scope.ts";
|
|
8
|
+
import { parseJsonRpcResponse } from "./mcp-protocol.ts";
|
|
9
|
+
import {
|
|
10
|
+
McpTransportError,
|
|
11
|
+
type McpTransport,
|
|
12
|
+
type McpTransportRequest,
|
|
13
|
+
type McpWireResult,
|
|
14
|
+
} from "./mcp-transport.ts";
|
|
15
|
+
|
|
16
|
+
/** Byte writer used by {@link StdioChild.stdin}. */
|
|
17
|
+
export interface StdioWriter {
|
|
18
|
+
write(data: Uint8Array): Promise<void> | void;
|
|
19
|
+
close(): Promise<void> | void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Options for {@link createMcpStdioTransport}. */
|
|
23
|
+
export interface CreateMcpStdioTransportOptions {
|
|
24
|
+
readonly command: string;
|
|
25
|
+
readonly args?: readonly string[];
|
|
26
|
+
readonly spawn?: (cmd: string, args: readonly string[]) => StdioChild;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Minimal child-process handle (Bun.spawn-compatible). */
|
|
30
|
+
export interface StdioChild {
|
|
31
|
+
readonly stdin: StdioWriter | WritableStream<Uint8Array> | null;
|
|
32
|
+
readonly stdout: ReadableStream<Uint8Array> | null;
|
|
33
|
+
readonly killed?: boolean;
|
|
34
|
+
kill(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Pending {
|
|
38
|
+
readonly resolve: (value: McpWireResult) => void;
|
|
39
|
+
readonly reject: (reason: unknown) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create a stdio transport. The process is spawned on first request.
|
|
44
|
+
*
|
|
45
|
+
* @param options - Command + args (never a shell string)
|
|
46
|
+
*/
|
|
47
|
+
export function createMcpStdioTransport(options: CreateMcpStdioTransportOptions): McpTransport {
|
|
48
|
+
let child: StdioChild | undefined;
|
|
49
|
+
let writer: StdioWriter | undefined;
|
|
50
|
+
const pending = new Map<string, Pending>();
|
|
51
|
+
let readLoop: Promise<void> | undefined;
|
|
52
|
+
const encoder = new TextEncoder();
|
|
53
|
+
|
|
54
|
+
async function ensureChild(): Promise<void> {
|
|
55
|
+
if (child) return;
|
|
56
|
+
const spawn = options.spawn ?? defaultSpawn;
|
|
57
|
+
child = spawn(options.command, options.args ?? []);
|
|
58
|
+
if (!child.stdin || !child.stdout) {
|
|
59
|
+
throw new Error("ai.mcp: stdio process is missing stdin/stdout");
|
|
60
|
+
}
|
|
61
|
+
writer = asWriter(child.stdin);
|
|
62
|
+
readLoop = consumeStdout(child.stdout, pending);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
kind: "stdio",
|
|
67
|
+
async request(request: McpTransportRequest): Promise<McpWireResult> {
|
|
68
|
+
if (request.signal?.aborted) throw abortError(request.signal.reason);
|
|
69
|
+
await ensureChild();
|
|
70
|
+
if (!writer) throw new Error("ai.mcp: stdio stdin is not writable");
|
|
71
|
+
const key = String(request.id);
|
|
72
|
+
const envelope = JSON.stringify({
|
|
73
|
+
jsonrpc: "2.0",
|
|
74
|
+
id: request.id,
|
|
75
|
+
method: request.method,
|
|
76
|
+
...(request.params !== undefined ? { params: request.params } : {}),
|
|
77
|
+
});
|
|
78
|
+
const result = new Promise<McpWireResult>((resolve, reject) => {
|
|
79
|
+
pending.set(key, { resolve, reject });
|
|
80
|
+
});
|
|
81
|
+
const onAbort = (): void => {
|
|
82
|
+
void writeLine(
|
|
83
|
+
writer!,
|
|
84
|
+
encoder,
|
|
85
|
+
JSON.stringify({
|
|
86
|
+
jsonrpc: "2.0",
|
|
87
|
+
method: "notifications/cancelled",
|
|
88
|
+
params: { requestId: request.id },
|
|
89
|
+
}),
|
|
90
|
+
).finally(() => {
|
|
91
|
+
child?.kill();
|
|
92
|
+
child = undefined;
|
|
93
|
+
writer = undefined;
|
|
94
|
+
rejectAll(pending, abortError(request.signal?.reason));
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
request.signal?.addEventListener("abort", onAbort, { once: true });
|
|
98
|
+
try {
|
|
99
|
+
await writeLine(writer, encoder, envelope);
|
|
100
|
+
return await result;
|
|
101
|
+
} finally {
|
|
102
|
+
request.signal?.removeEventListener("abort", onAbort);
|
|
103
|
+
pending.delete(key);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
async close() {
|
|
107
|
+
child?.kill();
|
|
108
|
+
child = undefined;
|
|
109
|
+
try {
|
|
110
|
+
await writer?.close();
|
|
111
|
+
} catch {
|
|
112
|
+
/* already closed */
|
|
113
|
+
}
|
|
114
|
+
writer = undefined;
|
|
115
|
+
rejectAll(pending, new Error("ai.mcp: stdio transport closed"));
|
|
116
|
+
await readLoop?.catch(() => undefined);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function defaultSpawn(command: string, args: readonly string[]): StdioChild {
|
|
122
|
+
const proc = Bun.spawn([command, ...args], {
|
|
123
|
+
stdin: "pipe",
|
|
124
|
+
stdout: "pipe",
|
|
125
|
+
stderr: "pipe",
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
stdin: {
|
|
129
|
+
write(data) {
|
|
130
|
+
proc.stdin.write(data);
|
|
131
|
+
},
|
|
132
|
+
close() {
|
|
133
|
+
proc.stdin.end();
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
stdout: proc.stdout,
|
|
137
|
+
kill() {
|
|
138
|
+
proc.kill();
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function asWriter(stdin: StdioWriter | WritableStream<Uint8Array>): StdioWriter {
|
|
144
|
+
if (isWritableStream(stdin)) {
|
|
145
|
+
const streamWriter = stdin.getWriter();
|
|
146
|
+
return {
|
|
147
|
+
write: (data) => streamWriter.write(data),
|
|
148
|
+
close: () => streamWriter.close(),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return stdin;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isWritableStream(
|
|
155
|
+
value: StdioWriter | WritableStream<Uint8Array>,
|
|
156
|
+
): value is WritableStream<Uint8Array> {
|
|
157
|
+
return "getWriter" in value && typeof value.getWriter === "function";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function writeLine(writer: StdioWriter, encoder: TextEncoder, line: string): Promise<void> {
|
|
161
|
+
await writer.write(encoder.encode(`${line}\n`));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function consumeStdout(
|
|
165
|
+
stdout: ReadableStream<Uint8Array>,
|
|
166
|
+
pending: Map<string, Pending>,
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
const decoder = new TextDecoder();
|
|
169
|
+
const reader = stdout.getReader();
|
|
170
|
+
let buf = "";
|
|
171
|
+
try {
|
|
172
|
+
while (true) {
|
|
173
|
+
const { done, value } = await reader.read();
|
|
174
|
+
if (done) break;
|
|
175
|
+
buf += decoder.decode(value, { stream: true });
|
|
176
|
+
const lines = buf.split("\n");
|
|
177
|
+
buf = lines.pop() ?? "";
|
|
178
|
+
for (const line of lines) {
|
|
179
|
+
const trimmed = line.trim();
|
|
180
|
+
if (!trimmed) continue;
|
|
181
|
+
let json: unknown;
|
|
182
|
+
try {
|
|
183
|
+
json = JSON.parse(trimmed) as unknown;
|
|
184
|
+
} catch {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const parsed = parseJsonRpcResponse(json);
|
|
188
|
+
if (!parsed) continue;
|
|
189
|
+
const key = parsed.id === null ? undefined : String(parsed.id);
|
|
190
|
+
if (key === undefined) continue;
|
|
191
|
+
const waiter = pending.get(key);
|
|
192
|
+
if (!waiter) continue;
|
|
193
|
+
pending.delete(key);
|
|
194
|
+
if (parsed.ok) {
|
|
195
|
+
waiter.resolve({ ok: true, result: parsed.result, rawBody: json });
|
|
196
|
+
} else {
|
|
197
|
+
waiter.resolve({ ok: false, error: parsed.error, rawBody: json });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch (err) {
|
|
202
|
+
rejectAll(pending, err);
|
|
203
|
+
} finally {
|
|
204
|
+
reader.releaseLock();
|
|
205
|
+
rejectAll(pending, new McpTransportError("ai.mcp: stdio process ended", { network: true }));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function rejectAll(pending: Map<string, Pending>, reason: unknown): void {
|
|
210
|
+
for (const waiter of pending.values()) waiter.reject(reason);
|
|
211
|
+
pending.clear();
|
|
212
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP client transport contract — HTTP, stdio, and test mock.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { McpRpcError } from "./mcp-protocol.ts";
|
|
6
|
+
|
|
7
|
+
/** Outbound JSON-RPC request. */
|
|
8
|
+
export interface McpTransportRequest {
|
|
9
|
+
readonly id: string | number;
|
|
10
|
+
readonly method: string;
|
|
11
|
+
readonly params?: unknown;
|
|
12
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
13
|
+
readonly signal?: AbortSignal;
|
|
14
|
+
/** HTTP clients MUST abort fetch / SSE. stdio writes `notifications/cancelled` then kills. */
|
|
15
|
+
readonly cancelMode: "http" | "stdio";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Wire result (HTTP status present only for Streamable HTTP). */
|
|
19
|
+
export interface McpWireResult {
|
|
20
|
+
readonly ok: boolean;
|
|
21
|
+
readonly result?: unknown;
|
|
22
|
+
readonly error?: McpRpcError;
|
|
23
|
+
readonly httpStatus?: number;
|
|
24
|
+
readonly sessionId?: string;
|
|
25
|
+
readonly rawBody?: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Bidirectional MCP transport. */
|
|
29
|
+
export interface McpTransport {
|
|
30
|
+
readonly kind: "http" | "stdio" | "mock";
|
|
31
|
+
/**
|
|
32
|
+
* Send one JSON-RPC request and wait for the matching response.
|
|
33
|
+
*
|
|
34
|
+
* @param request - Envelope + headers + abort
|
|
35
|
+
*/
|
|
36
|
+
request(request: McpTransportRequest): Promise<McpWireResult>;
|
|
37
|
+
/** Release the process / connection. */
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Transport / protocol failure with enough shape for era fallback.
|
|
43
|
+
*/
|
|
44
|
+
export class McpTransportError extends Error {
|
|
45
|
+
readonly httpStatus?: number;
|
|
46
|
+
readonly rpc?: McpRpcError;
|
|
47
|
+
readonly network?: boolean;
|
|
48
|
+
readonly timeout?: boolean;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param message - Diagnostic
|
|
52
|
+
* @param extras - HTTP / RPC / network flags
|
|
53
|
+
*/
|
|
54
|
+
constructor(
|
|
55
|
+
message: string,
|
|
56
|
+
extras: {
|
|
57
|
+
readonly httpStatus?: number;
|
|
58
|
+
readonly rpc?: McpRpcError;
|
|
59
|
+
readonly network?: boolean;
|
|
60
|
+
readonly timeout?: boolean;
|
|
61
|
+
} = {},
|
|
62
|
+
) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = "McpTransportError";
|
|
65
|
+
if (extras.httpStatus !== undefined) this.httpStatus = extras.httpStatus;
|
|
66
|
+
if (extras.rpc !== undefined) this.rpc = extras.rpc;
|
|
67
|
+
if (extras.network !== undefined) this.network = extras.network;
|
|
68
|
+
if (extras.timeout !== undefined) this.timeout = extras.timeout;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -8,11 +8,26 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { AiDriver, AiMessage, AiModelClient, AiToolDef } from "../../drivers/ai-types.ts";
|
|
11
|
+
import { currentAbortSignal, withAbortSignal } from "../../kernel/abort-scope.ts";
|
|
12
|
+
import {
|
|
13
|
+
mcpCapabilityRefFromName,
|
|
14
|
+
mcpModelToolName,
|
|
15
|
+
parseMcpToolRef,
|
|
16
|
+
} from "../../manifest/mcp-ref.ts";
|
|
17
|
+
import { createMcpClient, type McpClient } from "./mcp-client.ts";
|
|
18
|
+
import type { McpTransport } from "./mcp-transport.ts";
|
|
11
19
|
import type { IndexStore } from "../../drivers/types.ts";
|
|
12
20
|
import { maskRedactedDeep } from "../../kernel/redacted.ts";
|
|
13
21
|
import type { GatePolicyContext } from "../gate/declare.ts";
|
|
14
22
|
import type { GateRuntime } from "../gate/runtime.ts";
|
|
15
|
-
import type {
|
|
23
|
+
import type {
|
|
24
|
+
AiAgentDecl,
|
|
25
|
+
AiEmbedDecl,
|
|
26
|
+
AiMcpServerDecl,
|
|
27
|
+
AiModelDecl,
|
|
28
|
+
AiPromptDecl,
|
|
29
|
+
AiTimeout,
|
|
30
|
+
} from "./declare.ts";
|
|
16
31
|
import {
|
|
17
32
|
isRetryableAiError,
|
|
18
33
|
mergeAskAbortSignal,
|
|
@@ -102,7 +117,7 @@ export interface AiFallbackAttempt {
|
|
|
102
117
|
}
|
|
103
118
|
|
|
104
119
|
/** Ask outcome class — schema failure is not a provider error. */
|
|
105
|
-
export type AiAskOutcome = "ok" | "provider_error" | "schema_invalid";
|
|
120
|
+
export type AiAskOutcome = "ok" | "provider_error" | "schema_invalid" | "budget_exceeded";
|
|
106
121
|
|
|
107
122
|
/** Journal entry for a nondeterministic ask. */
|
|
108
123
|
export interface AiJournalEntry {
|
|
@@ -171,6 +186,11 @@ export interface CreateAiRuntimeOptions {
|
|
|
171
186
|
readonly clients?: Readonly<Record<string, AiModelClient>>;
|
|
172
187
|
/** Default driver when a model has no client (usually mock in dev). */
|
|
173
188
|
readonly defaultDriver?: AiDriver;
|
|
189
|
+
/**
|
|
190
|
+
* Protocol drivers keyed by id — used when a logical model sets
|
|
191
|
+
* {@link AiModelDecl.driverId} instead of the app default.
|
|
192
|
+
*/
|
|
193
|
+
readonly drivers?: Readonly<Record<string, AiDriver>>;
|
|
174
194
|
/** Gate runtime for agent tool calls. */
|
|
175
195
|
readonly gates?: GateRuntime;
|
|
176
196
|
/**
|
|
@@ -208,6 +228,12 @@ export interface CreateAiRuntimeOptions {
|
|
|
208
228
|
* Nondeterministic contract.
|
|
209
229
|
*/
|
|
210
230
|
readonly forceJournal?: boolean;
|
|
231
|
+
/** Declared external MCP servers. */
|
|
232
|
+
readonly mcpServers?: readonly AiMcpServerDecl[];
|
|
233
|
+
/** Resolve a vault secret by contract name (MCP bearer). */
|
|
234
|
+
readonly resolveSecret?: (name: string) => string | Promise<string>;
|
|
235
|
+
/** Injected MCP transports keyed by server name (tests). */
|
|
236
|
+
readonly mcpTransports?: Readonly<Record<string, McpTransport>>;
|
|
211
237
|
}
|
|
212
238
|
|
|
213
239
|
/** Ask options. */
|
|
@@ -242,6 +268,8 @@ export interface AiStreamOptions {
|
|
|
242
268
|
readonly prompt?: string;
|
|
243
269
|
readonly data?: unknown;
|
|
244
270
|
readonly signal?: AbortSignal;
|
|
271
|
+
/** Ordered fallback models after the primary `stream(model)` name. */
|
|
272
|
+
readonly via?: readonly string[];
|
|
245
273
|
}
|
|
246
274
|
|
|
247
275
|
/** AI runtime surface. */
|
|
@@ -291,6 +319,14 @@ export interface AiRuntime {
|
|
|
291
319
|
* @param options - Prompt / data / signal
|
|
292
320
|
*/
|
|
293
321
|
stream(model: string, options?: AiStreamOptions): AsyncIterable<string>;
|
|
322
|
+
/**
|
|
323
|
+
* Call an allowlisted MCP tool (`mcp:<server>/<tool>`).
|
|
324
|
+
*
|
|
325
|
+
* @param ref - Capability ref
|
|
326
|
+
* @param input - Tool arguments
|
|
327
|
+
* @param signal - Cancel
|
|
328
|
+
*/
|
|
329
|
+
callMcp(ref: string, input: unknown, signal?: AbortSignal): Promise<unknown>;
|
|
294
330
|
/**
|
|
295
331
|
* Embed text into the configured index store.
|
|
296
332
|
*
|
|
@@ -341,6 +377,11 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
341
377
|
for (const m of options.models ?? []) models.set(m.name, m);
|
|
342
378
|
|
|
343
379
|
const clients = new Map<string, AiModelClient>(Object.entries(options.clients ?? {}));
|
|
380
|
+
const mcpClient: McpClient = createMcpClient({
|
|
381
|
+
servers: options.mcpServers,
|
|
382
|
+
...(options.resolveSecret !== undefined ? { resolveSecret: options.resolveSecret } : {}),
|
|
383
|
+
...(options.mcpTransports !== undefined ? { transports: options.mcpTransports } : {}),
|
|
384
|
+
});
|
|
344
385
|
const denials: AgentDenial[] = [];
|
|
345
386
|
const agentRuns: AgentRunRecord[] = [];
|
|
346
387
|
const journal: AiJournalEntry[] = [];
|
|
@@ -351,11 +392,18 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
351
392
|
async function clientFor(name: string): Promise<AiModelClient> {
|
|
352
393
|
const existing = clients.get(name);
|
|
353
394
|
if (existing) return existing;
|
|
354
|
-
if (!options.defaultDriver) {
|
|
355
|
-
throw new Error(`ai: no client for model "${name}" and no defaultDriver`);
|
|
356
|
-
}
|
|
357
395
|
const model = models.get(name);
|
|
358
|
-
const
|
|
396
|
+
const driver =
|
|
397
|
+
(model?.driverId !== undefined ? options.drivers?.[model.driverId] : undefined) ??
|
|
398
|
+
options.defaultDriver;
|
|
399
|
+
if (!driver) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
model?.driverId
|
|
402
|
+
? `ai: no driver "${model.driverId}" for model "${name}" and no defaultDriver`
|
|
403
|
+
: `ai: no client for model "${name}" and no defaultDriver`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
const opened = await driver.open({
|
|
359
407
|
model: model?.model ?? name,
|
|
360
408
|
...(model?.baseUrl !== undefined ? { baseUrl: model.baseUrl } : {}),
|
|
361
409
|
...(model?.apiKey !== undefined ? { apiKey: model.apiKey } : {}),
|
|
@@ -373,12 +421,26 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
373
421
|
return options.effectsForFlow?.(tool) ?? [];
|
|
374
422
|
}
|
|
375
423
|
|
|
376
|
-
function toolDefsFor(toolNames: readonly string[]): AiToolDef[] {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
424
|
+
async function toolDefsFor(toolNames: readonly string[]): Promise<AiToolDef[]> {
|
|
425
|
+
const defs: AiToolDef[] = [];
|
|
426
|
+
for (const name of toolNames) {
|
|
427
|
+
const mcp = parseMcpToolRef(name);
|
|
428
|
+
if (mcp) {
|
|
429
|
+
const listed = await mcpClient.listedTool(mcp);
|
|
430
|
+
defs.push({
|
|
431
|
+
name: mcpModelToolName(mcp.server, mcp.tool),
|
|
432
|
+
description: listed?.description ?? `MCP tool: ${mcp.server}/${mcp.tool}`,
|
|
433
|
+
parameters: listed?.inputSchema ?? { type: "object", properties: {} },
|
|
434
|
+
});
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
defs.push({
|
|
438
|
+
name,
|
|
439
|
+
description: `Flow tool: ${name}`,
|
|
440
|
+
parameters: options.toolSchemaForFlow?.(name) ?? { type: "object", properties: {} },
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
return defs;
|
|
382
444
|
}
|
|
383
445
|
|
|
384
446
|
async function dispatchTool(opts: {
|
|
@@ -392,6 +454,7 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
392
454
|
readonly meta?: GatePolicyContext["meta"];
|
|
393
455
|
readonly trail: AgentToolStep[];
|
|
394
456
|
readonly runDenials: AgentDenial[];
|
|
457
|
+
readonly signal?: AbortSignal;
|
|
395
458
|
}): Promise<unknown> {
|
|
396
459
|
const {
|
|
397
460
|
tool,
|
|
@@ -404,10 +467,12 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
404
467
|
meta,
|
|
405
468
|
trail,
|
|
406
469
|
runDenials,
|
|
470
|
+
signal,
|
|
407
471
|
} = opts;
|
|
408
|
-
const
|
|
472
|
+
const capability = mcpCapabilityRefFromName(tool) ?? tool;
|
|
473
|
+
const effects = effectsFor(capability);
|
|
409
474
|
|
|
410
|
-
if (!allowedTools.has(tool)) {
|
|
475
|
+
if (!allowedTools.has(tool) && !allowedTools.has(capability)) {
|
|
411
476
|
const denial: AgentDenial = {
|
|
412
477
|
agent: agentLabel,
|
|
413
478
|
tool,
|
|
@@ -421,7 +486,7 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
421
486
|
throw new Error(`ai: model requested unknown tool "${tool}"`);
|
|
422
487
|
}
|
|
423
488
|
|
|
424
|
-
const requiredGates = options.gatesForFlow?.(
|
|
489
|
+
const requiredGates = options.gatesForFlow?.(capability) ?? [];
|
|
425
490
|
if (requiredGates.length > 0 && options.gates) {
|
|
426
491
|
const ctx: GatePolicyContext = {
|
|
427
492
|
auth: auth ?? { userId: null, scopes: new Set() },
|
|
@@ -460,8 +525,9 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
460
525
|
return { error: denial.reason, denied: true };
|
|
461
526
|
}
|
|
462
527
|
|
|
463
|
-
const
|
|
464
|
-
|
|
528
|
+
const invokeSignal = signal ?? currentAbortSignal();
|
|
529
|
+
const output = await withAbortSignal(invokeSignal, () => invoke(capability, args));
|
|
530
|
+
trail.push({ tool: capability, status: "ok", effects, at: now() });
|
|
465
531
|
return output;
|
|
466
532
|
}
|
|
467
533
|
|
|
@@ -491,8 +557,12 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
491
557
|
readonly outputTokens?: number;
|
|
492
558
|
}> {
|
|
493
559
|
const messages = [...opts.messages];
|
|
494
|
-
const defs = toolDefsFor(opts.tools);
|
|
560
|
+
const defs = await toolDefsFor(opts.tools);
|
|
495
561
|
const allowed = new Set(opts.tools);
|
|
562
|
+
for (const name of opts.tools) {
|
|
563
|
+
const mcp = parseMcpToolRef(name);
|
|
564
|
+
if (mcp) allowed.add(mcpModelToolName(mcp.server, mcp.tool));
|
|
565
|
+
}
|
|
496
566
|
const trail: AgentToolStep[] = [];
|
|
497
567
|
const runDenials: AgentDenial[] = [];
|
|
498
568
|
let steps = 0;
|
|
@@ -551,6 +621,7 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
551
621
|
meta: opts.meta,
|
|
552
622
|
trail,
|
|
553
623
|
runDenials,
|
|
624
|
+
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
554
625
|
});
|
|
555
626
|
lastToolResult = toolResult;
|
|
556
627
|
messages.push({
|
|
@@ -594,7 +665,10 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
594
665
|
const version = decl.version;
|
|
595
666
|
const started = now();
|
|
596
667
|
const tools = opts?.tools ?? [];
|
|
597
|
-
const signal = mergeAskAbortSignal(
|
|
668
|
+
const signal = mergeAskAbortSignal(
|
|
669
|
+
resolveTimeoutMs(opts?.timeout ?? decl.timeout),
|
|
670
|
+
currentAbortSignal(),
|
|
671
|
+
);
|
|
598
672
|
|
|
599
673
|
// Replay from journal when input matches (nondeterministic contract)
|
|
600
674
|
if (journalingForced && tools.length === 0) {
|
|
@@ -628,6 +702,28 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
628
702
|
});
|
|
629
703
|
};
|
|
630
704
|
|
|
705
|
+
const assertAskBudget = (spent: number): void => {
|
|
706
|
+
const cap = decl.budget?.maxCostPerCall;
|
|
707
|
+
if (cap === undefined || spent <= cap) return;
|
|
708
|
+
const message = `ai: prompt "${prompt}" exceeded maxCostPerCall ${cap}`;
|
|
709
|
+
if (journalingForced) {
|
|
710
|
+
pushJournal({
|
|
711
|
+
prompt,
|
|
712
|
+
...(version !== undefined ? { version } : {}),
|
|
713
|
+
input,
|
|
714
|
+
output: { error: message },
|
|
715
|
+
attempts: [...attempts],
|
|
716
|
+
outcome: "budget_exceeded",
|
|
717
|
+
cost: spent,
|
|
718
|
+
latencyMs: Math.max(0, now() - started),
|
|
719
|
+
at: now(),
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
const err = new Error(message);
|
|
723
|
+
err.name = "AiBudgetExceededError";
|
|
724
|
+
throw err;
|
|
725
|
+
};
|
|
726
|
+
|
|
631
727
|
for (const modelName of via) {
|
|
632
728
|
let sameModelTries = 0;
|
|
633
729
|
let advance = true;
|
|
@@ -656,6 +752,7 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
656
752
|
attemptCost = loop.cost;
|
|
657
753
|
totalCost += attemptCost;
|
|
658
754
|
addUsageTokens(totalTokens, loop);
|
|
755
|
+
assertAskBudget(totalCost);
|
|
659
756
|
if (loop.denials.length > 0 && loop.trail.every((t) => t.status === "denied")) {
|
|
660
757
|
throw new Error(
|
|
661
758
|
`ai: all tool calls denied for prompt "${prompt}": ${loop.denials[0]?.reason}`,
|
|
@@ -671,6 +768,7 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
671
768
|
attemptCost = result.usage?.cost ?? 0;
|
|
672
769
|
totalCost += attemptCost;
|
|
673
770
|
addUsageTokens(totalTokens, result.usage);
|
|
771
|
+
assertAskBudget(totalCost);
|
|
674
772
|
// Prefer assistant text — `raw` is often the transport envelope
|
|
675
773
|
// (OpenAI chat.completion object), which must not shadow the content.
|
|
676
774
|
raw =
|
|
@@ -815,7 +913,14 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
815
913
|
auth: runOpts.auth,
|
|
816
914
|
operator: runOpts.operator,
|
|
817
915
|
meta: runOpts.meta,
|
|
916
|
+
signal: currentAbortSignal(),
|
|
818
917
|
});
|
|
918
|
+
const runCap = decl.budget?.maxCostPerRun;
|
|
919
|
+
if (runCap !== undefined && loop.cost > runCap) {
|
|
920
|
+
const err = new Error(`ai: agent "${agent}" exceeded maxCostPerRun ${runCap}`);
|
|
921
|
+
err.name = "AiBudgetExceededError";
|
|
922
|
+
throw err;
|
|
923
|
+
}
|
|
819
924
|
|
|
820
925
|
const record: AgentRunRecord = {
|
|
821
926
|
id: `agent-run-${++runSeq}`,
|
|
@@ -842,21 +947,46 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
842
947
|
},
|
|
843
948
|
|
|
844
949
|
async *stream(model, streamOpts) {
|
|
845
|
-
const
|
|
846
|
-
if (!client.stream) {
|
|
847
|
-
throw new Error(`ai: model "${model}" (driver ${client.driverId}) does not support stream`);
|
|
848
|
-
}
|
|
950
|
+
const via = [model, ...(streamOpts?.via ?? []).filter((name) => name !== model)];
|
|
849
951
|
const content =
|
|
850
952
|
streamOpts?.data !== undefined
|
|
851
953
|
? promptContentFromInput(streamOpts.data)
|
|
852
954
|
: promptContentFromInput(streamOpts?.prompt ?? "");
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
955
|
+
let lastError: unknown;
|
|
956
|
+
for (const modelName of via) {
|
|
957
|
+
let yielded = false;
|
|
958
|
+
try {
|
|
959
|
+
const client = await clientFor(modelName);
|
|
960
|
+
if (!client.stream) {
|
|
961
|
+
throw new Error(
|
|
962
|
+
`ai: model "${modelName}" (driver ${client.driverId}) does not support stream`,
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
for await (const chunk of client.stream({
|
|
966
|
+
model: wireModel(modelName, client),
|
|
967
|
+
messages: [{ role: "user", content }],
|
|
968
|
+
signal: streamOpts?.signal,
|
|
969
|
+
})) {
|
|
970
|
+
if (chunk.text) {
|
|
971
|
+
yielded = true;
|
|
972
|
+
yield chunk.text;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
return;
|
|
976
|
+
} catch (err) {
|
|
977
|
+
lastError = err;
|
|
978
|
+
if (yielded || !isRetryableAiError(err)) {
|
|
979
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
980
|
+
}
|
|
981
|
+
}
|
|
859
982
|
}
|
|
983
|
+
throw lastError instanceof Error
|
|
984
|
+
? lastError
|
|
985
|
+
: new Error(`ai: all models failed to stream: ${String(lastError)}`);
|
|
986
|
+
},
|
|
987
|
+
|
|
988
|
+
async callMcp(ref, input, signal) {
|
|
989
|
+
return mcpClient.call(ref, input, signal ?? currentAbortSignal());
|
|
860
990
|
},
|
|
861
991
|
|
|
862
992
|
async embed(embedName, id, text) {
|