okengine 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/site/content/docs/elements/ai.mdx +82 -1
- package/site/content/docs/elements/channel.mdx +6 -1
- package/site/content/docs/elements/flow.mdx +20 -17
- package/site/content/docs/get-started/why.mdx +10 -10
- package/site/content/docs/plugins/email-otp.mdx +25 -19
- package/site/content/docs/plugins/headers.mdx +10 -10
- package/site/content/docs/plugins/magic-link.mdx +27 -21
- package/site/content/docs/plugins/passkey.mdx +36 -24
- package/site/content/docs/plugins/two-factor.mdx +2 -1
- package/site/content/docs/reference/configuration.mdx +7 -0
- package/site/content/docs/reference/environment-variables.mdx +10 -5
- package/site/content/docs/reference/errors.mdx +14 -0
- package/site/content/docs/reference/fx.mdx +68 -16
- package/site/content/docs/reference/i18n.mdx +313 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +1 -0
- package/site/content/docs/reference/plugins.mdx +1 -0
- package/src/auth/auth.test.ts +3 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/constant-time.ts +22 -0
- package/src/auth/index.ts +2 -0
- package/src/auth/method-context.ts +12 -2
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/compiler/aot.test.ts +16 -13
- package/src/compiler/effects-infer.ts +46 -0
- package/src/console/server/ai.test.ts +34 -5
- package/src/docker/compose.ts +9 -0
- package/src/docker/docker.test.ts +39 -0
- package/src/docker/index.ts +11 -1
- package/src/docker/recipes/index.ts +3 -1
- package/src/docker/recipes/ollama.ts +43 -0
- package/src/docker/stack-id.ts +2 -0
- package/src/drivers/ai-mock.ts +60 -0
- package/src/drivers/ai-ollama-tools.integration.test.ts +107 -0
- package/src/drivers/ai-ollama.integration.test.ts +197 -0
- package/src/drivers/ai-ollama.ts +327 -0
- package/src/drivers/ai-openai-compatible.ts +211 -21
- package/src/drivers/ai-providers.test.ts +179 -2
- package/src/drivers/ai-stream.test.ts +195 -0
- package/src/drivers/ai-types.ts +42 -1
- package/src/drivers/channel-smtp.ts +8 -2
- package/src/drivers/index.ts +21 -1
- package/src/drivers/ollama.ts +14 -0
- package/src/elements/ai/rate.test.ts +53 -0
- package/src/elements/ai/rate.ts +66 -0
- package/src/elements/ai/redacted-prompt.test.ts +90 -0
- package/src/elements/ai/runtime.ts +330 -100
- package/src/elements/ai/tools.test.ts +99 -0
- package/src/elements/ai.test.ts +26 -2
- package/src/elements/ai.ts +10 -1
- package/src/i18n/catalogs/ar.ts +67 -0
- package/src/i18n/catalogs/en.ts +68 -0
- package/src/i18n/failure-message.test.ts +56 -0
- package/src/i18n/failure-message.ts +93 -0
- package/src/i18n/format.ts +67 -0
- package/src/i18n/index.ts +57 -0
- package/src/i18n/locale-context.ts +48 -0
- package/src/i18n/messages.test.ts +173 -0
- package/src/i18n/messages.ts +169 -0
- package/src/i18n/types.ts +90 -0
- package/src/index.ts +26 -0
- package/src/kernel/app.ts +92 -2
- package/src/kernel/boot-bind/ai.test.ts +60 -0
- package/src/kernel/boot-bind/ai.ts +125 -2
- package/src/kernel/boot.test.ts +4 -3
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/errors.ts +56 -5
- package/src/kernel/fx.test.ts +27 -0
- package/src/kernel/fx.ts +74 -18
- package/src/kernel/pipeline.test.ts +4 -0
- package/src/kernel/pipeline.ts +1 -1
- package/src/kernel/plugin.ts +16 -0
- package/src/kernel/registry.ts +15 -0
- package/src/plugins/auth/shared.ts +5 -1
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +330 -0
- package/src/plugins/auth-methods.security.test.ts +764 -0
- package/src/plugins/compression.ts +1 -1
- package/src/plugins/config-source.test.ts +11 -11
- package/src/plugins/config-source.ts +2 -2
- package/src/plugins/cors.ts +1 -1
- package/src/plugins/email-otp.ts +54 -1
- package/src/plugins/{security-headers.test.ts → headers.test.ts} +18 -18
- package/src/plugins/headers.ts +240 -41
- package/src/plugins/index.ts +27 -5
- package/src/plugins/magic-link.ts +63 -3
- package/src/plugins/passkey-webauthn.ts +217 -0
- package/src/plugins/passkey.ts +99 -33
- package/src/plugins/response-headers.ts +54 -0
- package/src/plugins/two-factor.ts +6 -2
- package/src/plugins/username-policy.test.ts +302 -0
- package/src/plugins/username.ts +290 -9
- package/src/release/measure.ts +8 -1
- package/src/plugins/security-headers.ts +0 -255
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
* Nondeterministic ⇒ journaling forced, auto-cache disabled.
|
|
5
5
|
* Schema-validation failures are their own class (console §9.10).
|
|
6
6
|
* Agent denials are recorded on the denial ledger — not errors.
|
|
7
|
+
* Tool invocations go through a caller-supplied `callTool` (host `fx.call`).
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
|
-
import type { AiDriver, AiModelClient } from "../../drivers/ai-types.ts";
|
|
10
|
+
import type { AiDriver, AiMessage, AiModelClient, AiToolDef } from "../../drivers/ai-types.ts";
|
|
10
11
|
import type { IndexStore } from "../../drivers/types.ts";
|
|
12
|
+
import { maskRedactedDeep } from "../../kernel/redacted.ts";
|
|
11
13
|
import type { GatePolicyContext } from "../gate/declare.ts";
|
|
12
14
|
import type { GateRuntime } from "../gate/runtime.ts";
|
|
13
15
|
import type { AiAgentDecl, AiEmbedDecl, AiModelDecl, AiPromptDecl } from "./declare.ts";
|
|
@@ -18,6 +20,9 @@ import {
|
|
|
18
20
|
type AiSchemaMismatch,
|
|
19
21
|
} from "./schema.ts";
|
|
20
22
|
|
|
23
|
+
/** Default bound for tool / agent loops. */
|
|
24
|
+
export const AI_DEFAULT_MAX_STEPS = 6;
|
|
25
|
+
|
|
21
26
|
/** Recorded agent tool denial (containment proof — not an error). */
|
|
22
27
|
export interface AgentDenial {
|
|
23
28
|
readonly agent: string;
|
|
@@ -102,7 +107,8 @@ export interface CreateAiRuntimeOptions {
|
|
|
102
107
|
/** Gate runtime for agent tool calls. */
|
|
103
108
|
readonly gates?: GateRuntime;
|
|
104
109
|
/**
|
|
105
|
-
* Invoke a flow by name (agent tools
|
|
110
|
+
* Invoke a flow by name (agent tools / boot fallback). Prefer per-ask
|
|
111
|
+
* `callTool` from the host `fx.call` so caller capability applies.
|
|
106
112
|
*
|
|
107
113
|
* @param name - Flow name
|
|
108
114
|
* @param input - Tool input
|
|
@@ -116,11 +122,16 @@ export interface CreateAiRuntimeOptions {
|
|
|
116
122
|
readonly gatesForFlow?: (flowName: string) => readonly string[];
|
|
117
123
|
/**
|
|
118
124
|
* Resolve declared effects for a tool flow (Manifest).
|
|
119
|
-
* The UI must not re-derive these — they come from the runtime ledger.
|
|
120
125
|
*
|
|
121
126
|
* @param flowName - Flow name
|
|
122
127
|
*/
|
|
123
128
|
readonly effectsForFlow?: (flowName: string) => readonly AgentToolEffect[];
|
|
129
|
+
/**
|
|
130
|
+
* Resolve tool JSON-schema parameters for a flow (defaults to empty object).
|
|
131
|
+
*
|
|
132
|
+
* @param flowName - Flow name
|
|
133
|
+
*/
|
|
134
|
+
readonly toolSchemaForFlow?: (flowName: string) => unknown;
|
|
124
135
|
/** Index stores for embeds (`into` name → store). */
|
|
125
136
|
readonly indexes?: Readonly<Record<string, IndexStore>>;
|
|
126
137
|
/** Injectable clock. */
|
|
@@ -136,6 +147,15 @@ export interface CreateAiRuntimeOptions {
|
|
|
136
147
|
export interface AiAskOptions {
|
|
137
148
|
readonly via?: readonly string[];
|
|
138
149
|
readonly allowPii?: boolean;
|
|
150
|
+
/** Flow names offered as tools — each model call dispatches via `callTool`. */
|
|
151
|
+
readonly tools?: readonly string[];
|
|
152
|
+
/** Bound on tool invocations (default {@link AI_DEFAULT_MAX_STEPS}). */
|
|
153
|
+
readonly maxSteps?: number;
|
|
154
|
+
/**
|
|
155
|
+
* Host-flow dispatch — must be `fx.call` so capability + Runs apply.
|
|
156
|
+
* Falls back to runtime `callFlow` when omitted.
|
|
157
|
+
*/
|
|
158
|
+
readonly callTool?: (name: string, input: unknown) => Promise<unknown>;
|
|
139
159
|
}
|
|
140
160
|
|
|
141
161
|
/** Agent run options. */
|
|
@@ -144,6 +164,15 @@ export interface AiAgentRunOptions {
|
|
|
144
164
|
readonly auth?: GatePolicyContext["auth"];
|
|
145
165
|
readonly operator?: GatePolicyContext["operator"];
|
|
146
166
|
readonly meta?: GatePolicyContext["meta"];
|
|
167
|
+
/** Host-flow dispatch — must be `fx.call` when wired from fx.run. */
|
|
168
|
+
readonly callTool?: (name: string, input: unknown) => Promise<unknown>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Stream options. */
|
|
172
|
+
export interface AiStreamOptions {
|
|
173
|
+
readonly prompt?: string;
|
|
174
|
+
readonly data?: unknown;
|
|
175
|
+
readonly signal?: AbortSignal;
|
|
147
176
|
}
|
|
148
177
|
|
|
149
178
|
/** AI runtime surface. */
|
|
@@ -162,11 +191,11 @@ export interface AiRuntime {
|
|
|
162
191
|
/** Journal of ask results (replay without re-calling the model). */
|
|
163
192
|
readonly journal: readonly AiJournalEntry[];
|
|
164
193
|
/**
|
|
165
|
-
* Ask a prompt with optional model fallback chain.
|
|
194
|
+
* Ask a prompt with optional model fallback chain and optional tools.
|
|
166
195
|
*
|
|
167
196
|
* @param prompt - Prompt name
|
|
168
197
|
* @param input - Prompt input
|
|
169
|
-
* @param opts - via / allowPii
|
|
198
|
+
* @param opts - via / tools / callTool / allowPii
|
|
170
199
|
*/
|
|
171
200
|
ask(prompt: string, input?: unknown, opts?: AiAskOptions): Promise<Record<string, unknown>>;
|
|
172
201
|
/**
|
|
@@ -186,6 +215,13 @@ export interface AiRuntime {
|
|
|
186
215
|
readonly output?: unknown;
|
|
187
216
|
readonly cost: number;
|
|
188
217
|
}>;
|
|
218
|
+
/**
|
|
219
|
+
* Stream model tokens (real driver stream; fails loud if unsupported).
|
|
220
|
+
*
|
|
221
|
+
* @param model - Model name
|
|
222
|
+
* @param options - Prompt / data / signal
|
|
223
|
+
*/
|
|
224
|
+
stream(model: string, options?: AiStreamOptions): AsyncIterable<string>;
|
|
189
225
|
/**
|
|
190
226
|
* Embed text into the configured index store.
|
|
191
227
|
*
|
|
@@ -196,6 +232,17 @@ export interface AiRuntime {
|
|
|
196
232
|
embed(embed: string, id: string, text: string): Promise<void>;
|
|
197
233
|
}
|
|
198
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Build provider-facing prompt text — Redacted values become placeholders.
|
|
237
|
+
*
|
|
238
|
+
* @param input - Ask input or stream data
|
|
239
|
+
*/
|
|
240
|
+
export function promptContentFromInput(input: unknown): string {
|
|
241
|
+
const masked = maskRedactedDeep(input);
|
|
242
|
+
if (typeof masked === "string") return masked;
|
|
243
|
+
return JSON.stringify(masked ?? {});
|
|
244
|
+
}
|
|
245
|
+
|
|
199
246
|
/**
|
|
200
247
|
* Create an AI runtime.
|
|
201
248
|
*
|
|
@@ -233,10 +280,209 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
233
280
|
return opened;
|
|
234
281
|
}
|
|
235
282
|
|
|
283
|
+
/** Wire model id for a logical binding (never send the binding name to providers). */
|
|
284
|
+
function wireModel(logicalName: string, client: AiModelClient): string {
|
|
285
|
+
return models.get(logicalName)?.model ?? client.model;
|
|
286
|
+
}
|
|
287
|
+
|
|
236
288
|
function effectsFor(tool: string): readonly AgentToolEffect[] {
|
|
237
289
|
return options.effectsForFlow?.(tool) ?? [];
|
|
238
290
|
}
|
|
239
291
|
|
|
292
|
+
function toolDefsFor(toolNames: readonly string[]): AiToolDef[] {
|
|
293
|
+
return toolNames.map((name) => ({
|
|
294
|
+
name,
|
|
295
|
+
description: `Flow tool: ${name}`,
|
|
296
|
+
parameters: options.toolSchemaForFlow?.(name) ?? { type: "object", properties: {} },
|
|
297
|
+
}));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function dispatchTool(opts: {
|
|
301
|
+
readonly tool: string;
|
|
302
|
+
readonly args: unknown;
|
|
303
|
+
readonly agentLabel: string;
|
|
304
|
+
readonly allowedTools: ReadonlySet<string>;
|
|
305
|
+
readonly callTool?: (name: string, input: unknown) => Promise<unknown>;
|
|
306
|
+
readonly auth?: GatePolicyContext["auth"];
|
|
307
|
+
readonly operator?: GatePolicyContext["operator"];
|
|
308
|
+
readonly meta?: GatePolicyContext["meta"];
|
|
309
|
+
readonly trail: AgentToolStep[];
|
|
310
|
+
readonly runDenials: AgentDenial[];
|
|
311
|
+
}): Promise<unknown> {
|
|
312
|
+
const {
|
|
313
|
+
tool,
|
|
314
|
+
args,
|
|
315
|
+
agentLabel,
|
|
316
|
+
allowedTools,
|
|
317
|
+
callTool,
|
|
318
|
+
auth,
|
|
319
|
+
operator,
|
|
320
|
+
meta,
|
|
321
|
+
trail,
|
|
322
|
+
runDenials,
|
|
323
|
+
} = opts;
|
|
324
|
+
const effects = effectsFor(tool);
|
|
325
|
+
|
|
326
|
+
if (!allowedTools.has(tool)) {
|
|
327
|
+
const denial: AgentDenial = {
|
|
328
|
+
agent: agentLabel,
|
|
329
|
+
tool,
|
|
330
|
+
gate: "(unknown-tool)",
|
|
331
|
+
reason: `tool "${tool}" was not offered`,
|
|
332
|
+
at: now(),
|
|
333
|
+
};
|
|
334
|
+
runDenials.push(denial);
|
|
335
|
+
denials.push(denial);
|
|
336
|
+
trail.push({ tool, status: "denied", effects, denial, at: denial.at });
|
|
337
|
+
throw new Error(`ai: model requested unknown tool "${tool}"`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const requiredGates = options.gatesForFlow?.(tool) ?? [];
|
|
341
|
+
if (requiredGates.length > 0 && options.gates) {
|
|
342
|
+
const ctx: GatePolicyContext = {
|
|
343
|
+
auth: auth ?? { userId: null, scopes: new Set() },
|
|
344
|
+
operator: operator ?? { id: null },
|
|
345
|
+
meta,
|
|
346
|
+
};
|
|
347
|
+
const evaluations = await options.gates.check(requiredGates, ctx);
|
|
348
|
+
const denied = evaluations.find((e) => !e.allowed);
|
|
349
|
+
if (denied) {
|
|
350
|
+
const denial: AgentDenial = {
|
|
351
|
+
agent: agentLabel,
|
|
352
|
+
tool,
|
|
353
|
+
gate: denied.name,
|
|
354
|
+
reason: denied.reason ?? "gate denied",
|
|
355
|
+
at: now(),
|
|
356
|
+
};
|
|
357
|
+
runDenials.push(denial);
|
|
358
|
+
denials.push(denial);
|
|
359
|
+
trail.push({ tool, status: "denied", effects, denial, at: denial.at });
|
|
360
|
+
return { error: denial.reason, denied: true };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const invoke = callTool ?? options.callFlow;
|
|
365
|
+
if (!invoke) {
|
|
366
|
+
const denial: AgentDenial = {
|
|
367
|
+
agent: agentLabel,
|
|
368
|
+
tool,
|
|
369
|
+
gate: "(no-callFlow)",
|
|
370
|
+
reason: "callFlow not configured",
|
|
371
|
+
at: now(),
|
|
372
|
+
};
|
|
373
|
+
runDenials.push(denial);
|
|
374
|
+
denials.push(denial);
|
|
375
|
+
trail.push({ tool, status: "denied", effects, denial, at: denial.at });
|
|
376
|
+
return { error: denial.reason, denied: true };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const output = await invoke(tool, args);
|
|
380
|
+
trail.push({ tool, status: "ok", effects, at: now() });
|
|
381
|
+
return output;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function toolLoop(opts: {
|
|
385
|
+
readonly client: AiModelClient;
|
|
386
|
+
readonly modelName: string;
|
|
387
|
+
readonly messages: AiMessage[];
|
|
388
|
+
readonly tools: readonly string[];
|
|
389
|
+
readonly maxSteps: number;
|
|
390
|
+
readonly agentLabel: string;
|
|
391
|
+
readonly responseFormat?: unknown;
|
|
392
|
+
readonly callTool?: (name: string, input: unknown) => Promise<unknown>;
|
|
393
|
+
readonly auth?: GatePolicyContext["auth"];
|
|
394
|
+
readonly operator?: GatePolicyContext["operator"];
|
|
395
|
+
readonly meta?: GatePolicyContext["meta"];
|
|
396
|
+
}): Promise<{
|
|
397
|
+
readonly output: unknown;
|
|
398
|
+
readonly text: string;
|
|
399
|
+
readonly raw: unknown;
|
|
400
|
+
readonly lastToolResult: unknown;
|
|
401
|
+
readonly trail: AgentToolStep[];
|
|
402
|
+
readonly denials: AgentDenial[];
|
|
403
|
+
readonly steps: number;
|
|
404
|
+
readonly cost: number;
|
|
405
|
+
}> {
|
|
406
|
+
const messages = [...opts.messages];
|
|
407
|
+
const defs = toolDefsFor(opts.tools);
|
|
408
|
+
const allowed = new Set(opts.tools);
|
|
409
|
+
const trail: AgentToolStep[] = [];
|
|
410
|
+
const runDenials: AgentDenial[] = [];
|
|
411
|
+
let steps = 0;
|
|
412
|
+
let cost = 0;
|
|
413
|
+
let lastText = "";
|
|
414
|
+
let lastRaw: unknown = {};
|
|
415
|
+
let lastToolResult: unknown;
|
|
416
|
+
|
|
417
|
+
const providerModel = wireModel(opts.modelName, opts.client);
|
|
418
|
+
while (steps < opts.maxSteps) {
|
|
419
|
+
const result = await opts.client.complete({
|
|
420
|
+
model: providerModel,
|
|
421
|
+
messages,
|
|
422
|
+
tools: defs.length > 0 ? defs : undefined,
|
|
423
|
+
responseFormat: opts.responseFormat,
|
|
424
|
+
});
|
|
425
|
+
cost += result.usage?.cost ?? 0;
|
|
426
|
+
lastText = result.text;
|
|
427
|
+
lastRaw = result.raw !== undefined ? result.raw : result.text;
|
|
428
|
+
|
|
429
|
+
const toolCalls = result.toolCalls;
|
|
430
|
+
if (!toolCalls || toolCalls.length === 0) {
|
|
431
|
+
return {
|
|
432
|
+
output: lastToolResult !== undefined ? lastToolResult : lastRaw,
|
|
433
|
+
text: lastText,
|
|
434
|
+
raw: lastRaw,
|
|
435
|
+
lastToolResult,
|
|
436
|
+
trail,
|
|
437
|
+
denials: runDenials,
|
|
438
|
+
steps,
|
|
439
|
+
cost,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
messages.push({
|
|
444
|
+
role: "assistant",
|
|
445
|
+
content: result.text || "",
|
|
446
|
+
toolCalls,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
for (const tc of toolCalls) {
|
|
450
|
+
if (steps >= opts.maxSteps) break;
|
|
451
|
+
steps++;
|
|
452
|
+
const toolResult = await dispatchTool({
|
|
453
|
+
tool: tc.name,
|
|
454
|
+
args: tc.arguments,
|
|
455
|
+
agentLabel: opts.agentLabel,
|
|
456
|
+
allowedTools: allowed,
|
|
457
|
+
callTool: opts.callTool,
|
|
458
|
+
auth: opts.auth,
|
|
459
|
+
operator: opts.operator,
|
|
460
|
+
meta: opts.meta,
|
|
461
|
+
trail,
|
|
462
|
+
runDenials,
|
|
463
|
+
});
|
|
464
|
+
lastToolResult = toolResult;
|
|
465
|
+
messages.push({
|
|
466
|
+
role: "tool",
|
|
467
|
+
content: typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult ?? null),
|
|
468
|
+
toolCallId: tc.id,
|
|
469
|
+
name: tc.name,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
output: lastToolResult !== undefined ? lastToolResult : lastRaw,
|
|
476
|
+
text: lastText,
|
|
477
|
+
raw: lastRaw,
|
|
478
|
+
lastToolResult,
|
|
479
|
+
trail,
|
|
480
|
+
denials: runDenials,
|
|
481
|
+
steps,
|
|
482
|
+
cost,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
240
486
|
return {
|
|
241
487
|
prompts,
|
|
242
488
|
agents,
|
|
@@ -251,9 +497,10 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
251
497
|
if (!decl) throw new Error(`ai: unknown prompt "${prompt}"`);
|
|
252
498
|
const version = decl.version;
|
|
253
499
|
const started = now();
|
|
500
|
+
const tools = opts?.tools ?? [];
|
|
254
501
|
|
|
255
502
|
// Replay from journal when input matches (nondeterministic contract)
|
|
256
|
-
if (journalingForced) {
|
|
503
|
+
if (journalingForced && tools.length === 0) {
|
|
257
504
|
const hit = [...journal]
|
|
258
505
|
.reverse()
|
|
259
506
|
.find(
|
|
@@ -272,25 +519,46 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
272
519
|
let lastError: string | undefined;
|
|
273
520
|
let lastSchema: AiSchemaMismatch | undefined;
|
|
274
521
|
let totalCost = 0;
|
|
522
|
+
const userContent = promptContentFromInput(input);
|
|
275
523
|
|
|
276
524
|
for (const modelName of via) {
|
|
277
525
|
const attemptStart = now();
|
|
278
526
|
try {
|
|
279
527
|
const client = await clientFor(modelName);
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
528
|
+
let raw: unknown;
|
|
529
|
+
let attemptCost = 0;
|
|
530
|
+
|
|
531
|
+
if (tools.length > 0) {
|
|
532
|
+
const loop = await toolLoop({
|
|
533
|
+
client,
|
|
534
|
+
modelName,
|
|
535
|
+
messages: [{ role: "user", content: userContent }],
|
|
536
|
+
tools,
|
|
537
|
+
maxSteps: opts?.maxSteps ?? AI_DEFAULT_MAX_STEPS,
|
|
538
|
+
agentLabel: prompt,
|
|
539
|
+
responseFormat: decl.out,
|
|
540
|
+
callTool: opts?.callTool,
|
|
541
|
+
});
|
|
542
|
+
raw = loop.lastToolResult !== undefined && !loop.text ? loop.lastToolResult : loop.raw;
|
|
543
|
+
attemptCost = loop.cost;
|
|
544
|
+
totalCost += attemptCost;
|
|
545
|
+
if (loop.denials.length > 0 && loop.trail.every((t) => t.status === "denied")) {
|
|
546
|
+
throw new Error(
|
|
547
|
+
`ai: all tool calls denied for prompt "${prompt}": ${loop.denials[0]?.reason}`,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
} else {
|
|
551
|
+
const result = await client.complete({
|
|
552
|
+
model: wireModel(modelName, client),
|
|
553
|
+
messages: [{ role: "user", content: userContent }],
|
|
554
|
+
responseFormat: decl.out,
|
|
555
|
+
});
|
|
556
|
+
attemptCost = result.usage?.cost ?? 0;
|
|
557
|
+
totalCost += attemptCost;
|
|
558
|
+
raw = result.raw !== undefined ? result.raw : result.text;
|
|
559
|
+
}
|
|
560
|
+
|
|
292
561
|
const latencyMs = Math.max(0, now() - attemptStart);
|
|
293
|
-
const raw = result.raw !== undefined ? result.raw : result.text;
|
|
294
562
|
|
|
295
563
|
try {
|
|
296
564
|
const output = decl.out
|
|
@@ -379,104 +647,66 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
379
647
|
async runAgent(agent, runOpts) {
|
|
380
648
|
const decl = agents.get(agent);
|
|
381
649
|
if (!decl) throw new Error(`ai: unknown agent "${agent}"`);
|
|
382
|
-
const maxSteps = decl.maxSteps ??
|
|
383
|
-
const
|
|
384
|
-
const
|
|
385
|
-
let steps = 0;
|
|
386
|
-
let output: unknown = { message: runOpts.message };
|
|
650
|
+
const maxSteps = decl.maxSteps ?? AI_DEFAULT_MAX_STEPS;
|
|
651
|
+
const modelName = decl.model ?? [...models.keys()][0] ?? "mock";
|
|
652
|
+
const client = await clientFor(modelName);
|
|
387
653
|
const started = now();
|
|
388
654
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
operator: runOpts.operator ?? { id: null },
|
|
402
|
-
meta: runOpts.meta,
|
|
403
|
-
};
|
|
404
|
-
const evaluations = await options.gates.check(requiredGates, ctx);
|
|
405
|
-
const denied = evaluations.find((e) => !e.allowed);
|
|
406
|
-
if (denied) {
|
|
407
|
-
const denial: AgentDenial = {
|
|
408
|
-
agent,
|
|
409
|
-
tool,
|
|
410
|
-
gate: denied.name,
|
|
411
|
-
reason: denied.reason ?? "gate denied",
|
|
412
|
-
at: now(),
|
|
413
|
-
};
|
|
414
|
-
runDenials.push(denial);
|
|
415
|
-
denials.push(denial);
|
|
416
|
-
trail.push({
|
|
417
|
-
tool,
|
|
418
|
-
status: "denied",
|
|
419
|
-
effects,
|
|
420
|
-
denial,
|
|
421
|
-
at: denial.at,
|
|
422
|
-
});
|
|
423
|
-
continue;
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
if (!options.callFlow) {
|
|
427
|
-
const denial: AgentDenial = {
|
|
428
|
-
agent,
|
|
429
|
-
tool,
|
|
430
|
-
gate: "(no-callFlow)",
|
|
431
|
-
reason: "callFlow not configured",
|
|
432
|
-
at: now(),
|
|
433
|
-
};
|
|
434
|
-
runDenials.push(denial);
|
|
435
|
-
denials.push(denial);
|
|
436
|
-
trail.push({
|
|
437
|
-
tool,
|
|
438
|
-
status: "denied",
|
|
439
|
-
effects,
|
|
440
|
-
denial,
|
|
441
|
-
at: denial.at,
|
|
442
|
-
});
|
|
443
|
-
continue;
|
|
444
|
-
}
|
|
445
|
-
output = await options.callFlow(tool, {
|
|
446
|
-
message: runOpts.message,
|
|
447
|
-
});
|
|
448
|
-
trail.push({
|
|
449
|
-
tool,
|
|
450
|
-
status: "ok",
|
|
451
|
-
effects,
|
|
452
|
-
at: now(),
|
|
453
|
-
});
|
|
454
|
-
}
|
|
655
|
+
const loop = await toolLoop({
|
|
656
|
+
client,
|
|
657
|
+
modelName,
|
|
658
|
+
messages: [{ role: "user", content: promptContentFromInput(runOpts.message) }],
|
|
659
|
+
tools: decl.tools,
|
|
660
|
+
maxSteps,
|
|
661
|
+
agentLabel: agent,
|
|
662
|
+
callTool: runOpts.callTool,
|
|
663
|
+
auth: runOpts.auth,
|
|
664
|
+
operator: runOpts.operator,
|
|
665
|
+
meta: runOpts.meta,
|
|
666
|
+
});
|
|
455
667
|
|
|
456
668
|
const record: AgentRunRecord = {
|
|
457
669
|
id: `agent-run-${++runSeq}`,
|
|
458
670
|
agent,
|
|
459
671
|
message: runOpts.message,
|
|
460
|
-
ok:
|
|
461
|
-
steps,
|
|
462
|
-
trail,
|
|
463
|
-
denials:
|
|
464
|
-
output,
|
|
672
|
+
ok: loop.denials.length === 0,
|
|
673
|
+
steps: loop.steps,
|
|
674
|
+
trail: loop.trail,
|
|
675
|
+
denials: loop.denials,
|
|
676
|
+
output: loop.output,
|
|
465
677
|
at: started,
|
|
466
|
-
cost:
|
|
678
|
+
cost: loop.cost,
|
|
467
679
|
};
|
|
468
680
|
agentRuns.push(record);
|
|
469
681
|
|
|
470
682
|
return {
|
|
471
683
|
ok: record.ok,
|
|
472
|
-
steps,
|
|
473
|
-
denials:
|
|
474
|
-
trail,
|
|
475
|
-
output,
|
|
684
|
+
steps: loop.steps,
|
|
685
|
+
denials: loop.denials,
|
|
686
|
+
trail: loop.trail,
|
|
687
|
+
output: loop.output,
|
|
476
688
|
cost: record.cost,
|
|
477
689
|
};
|
|
478
690
|
},
|
|
479
691
|
|
|
692
|
+
async *stream(model, streamOpts) {
|
|
693
|
+
const client = await clientFor(model);
|
|
694
|
+
if (!client.stream) {
|
|
695
|
+
throw new Error(`ai: model "${model}" (driver ${client.driverId}) does not support stream`);
|
|
696
|
+
}
|
|
697
|
+
const content =
|
|
698
|
+
streamOpts?.data !== undefined
|
|
699
|
+
? promptContentFromInput(streamOpts.data)
|
|
700
|
+
: promptContentFromInput(streamOpts?.prompt ?? "");
|
|
701
|
+
for await (const chunk of client.stream({
|
|
702
|
+
model: wireModel(model, client),
|
|
703
|
+
messages: [{ role: "user", content }],
|
|
704
|
+
signal: streamOpts?.signal,
|
|
705
|
+
})) {
|
|
706
|
+
if (chunk.text) yield chunk.text;
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
|
|
480
710
|
async embed(embedName, id, text) {
|
|
481
711
|
const decl = embeds.get(embedName);
|
|
482
712
|
if (!decl) throw new Error(`ai: unknown embed "${embedName}"`);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-calling through the same capability path as fx.call.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { createMockAiDriver } from "../../drivers/index.ts";
|
|
7
|
+
import { createFxContext } from "../../kernel/fx.ts";
|
|
8
|
+
import { ai, createAiRuntime } from "../ai.ts";
|
|
9
|
+
|
|
10
|
+
describe("fx.ask tools via fx.call", () => {
|
|
11
|
+
test("model tool call dispatches through callTool (host fx.call)", async () => {
|
|
12
|
+
const calls: Array<{ name: string; input: unknown }> = [];
|
|
13
|
+
const smart = ai.model("smart");
|
|
14
|
+
const prompt = smart.prompt("assistant", { out: { answer: "string" } });
|
|
15
|
+
let turn = 0;
|
|
16
|
+
const runtime = createAiRuntime({
|
|
17
|
+
models: [smart],
|
|
18
|
+
prompts: [prompt],
|
|
19
|
+
defaultDriver: {
|
|
20
|
+
id: "mock",
|
|
21
|
+
async open() {
|
|
22
|
+
return {
|
|
23
|
+
driverId: "mock" as const,
|
|
24
|
+
model: "smart",
|
|
25
|
+
async complete() {
|
|
26
|
+
turn++;
|
|
27
|
+
if (turn === 1) {
|
|
28
|
+
return {
|
|
29
|
+
text: "",
|
|
30
|
+
model: "smart",
|
|
31
|
+
driverId: "mock" as const,
|
|
32
|
+
toolCalls: [{ id: "t1", name: "lookup.booking", arguments: { id: "B9" } }],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
text: JSON.stringify({ answer: "found" }),
|
|
37
|
+
raw: { answer: "found" },
|
|
38
|
+
model: "smart",
|
|
39
|
+
driverId: "mock" as const,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const { fx, ledger } = createFxContext({
|
|
48
|
+
flow: "host",
|
|
49
|
+
effects: {
|
|
50
|
+
asks: ["assistant"],
|
|
51
|
+
calls: ["lookup.booking"],
|
|
52
|
+
},
|
|
53
|
+
aiRuntime: runtime,
|
|
54
|
+
callHandler: async (name, input) => {
|
|
55
|
+
calls.push({ name, input });
|
|
56
|
+
return { booking: "B9", status: "ok" };
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const out = await fx.ask(prompt, { q: "status?" }, { tools: ["lookup.booking"], maxSteps: 4 });
|
|
61
|
+
expect(out).toEqual({ answer: "found" });
|
|
62
|
+
expect(calls).toEqual([{ name: "lookup.booking", input: { id: "B9" } }]);
|
|
63
|
+
expect(ledger.entries.some((e) => e.kind === "ask" && e.resource === "assistant")).toBe(true);
|
|
64
|
+
expect(
|
|
65
|
+
ledger.entries.some(
|
|
66
|
+
(e) => e.kind === "call" && e.resource === "lookup.booking" && e.reversibility === "portal",
|
|
67
|
+
),
|
|
68
|
+
).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("undeclared tool call is denied by capability (no extra authority)", async () => {
|
|
72
|
+
const smart = ai.model("smart");
|
|
73
|
+
const prompt = smart.prompt("assistant");
|
|
74
|
+
const runtime = createAiRuntime({
|
|
75
|
+
models: [smart],
|
|
76
|
+
prompts: [prompt],
|
|
77
|
+
defaultDriver: createMockAiDriver({
|
|
78
|
+
"*": {
|
|
79
|
+
__toolCalls: [{ id: "t1", name: "secret.wipe", arguments: {} }],
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const { fx } = createFxContext({
|
|
85
|
+
flow: "host",
|
|
86
|
+
effects: {
|
|
87
|
+
asks: ["assistant"],
|
|
88
|
+
// deliberately omit calls: ["secret.wipe"]
|
|
89
|
+
calls: [],
|
|
90
|
+
},
|
|
91
|
+
aiRuntime: runtime,
|
|
92
|
+
callHandler: async () => ({ wiped: true }),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await expect(
|
|
96
|
+
fx.ask(prompt, "wipe please", { tools: ["secret.wipe"], maxSteps: 1 }),
|
|
97
|
+
).rejects.toThrow();
|
|
98
|
+
});
|
|
99
|
+
});
|