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.
Files changed (94) hide show
  1. package/package.json +2 -1
  2. package/site/content/docs/elements/ai.mdx +82 -1
  3. package/site/content/docs/elements/channel.mdx +6 -1
  4. package/site/content/docs/elements/flow.mdx +20 -17
  5. package/site/content/docs/get-started/why.mdx +10 -10
  6. package/site/content/docs/plugins/email-otp.mdx +25 -19
  7. package/site/content/docs/plugins/headers.mdx +10 -10
  8. package/site/content/docs/plugins/magic-link.mdx +27 -21
  9. package/site/content/docs/plugins/passkey.mdx +36 -24
  10. package/site/content/docs/plugins/two-factor.mdx +2 -1
  11. package/site/content/docs/reference/configuration.mdx +7 -0
  12. package/site/content/docs/reference/environment-variables.mdx +10 -5
  13. package/site/content/docs/reference/errors.mdx +14 -0
  14. package/site/content/docs/reference/fx.mdx +68 -16
  15. package/site/content/docs/reference/i18n.mdx +313 -0
  16. package/site/content/docs/reference/index.mdx +6 -1
  17. package/site/content/docs/reference/meta.json +1 -0
  18. package/site/content/docs/reference/plugins.mdx +1 -0
  19. package/src/auth/auth.test.ts +3 -0
  20. package/src/auth/bindings.ts +1 -1
  21. package/src/auth/constant-time.ts +22 -0
  22. package/src/auth/index.ts +2 -0
  23. package/src/auth/method-context.ts +12 -2
  24. package/src/cli/competitor-mention-removal.test.ts +3 -3
  25. package/src/compiler/aot.test.ts +16 -13
  26. package/src/compiler/effects-infer.ts +46 -0
  27. package/src/console/server/ai.test.ts +34 -5
  28. package/src/docker/compose.ts +9 -0
  29. package/src/docker/docker.test.ts +39 -0
  30. package/src/docker/index.ts +11 -1
  31. package/src/docker/recipes/index.ts +3 -1
  32. package/src/docker/recipes/ollama.ts +43 -0
  33. package/src/docker/stack-id.ts +2 -0
  34. package/src/drivers/ai-mock.ts +60 -0
  35. package/src/drivers/ai-ollama-tools.integration.test.ts +107 -0
  36. package/src/drivers/ai-ollama.integration.test.ts +197 -0
  37. package/src/drivers/ai-ollama.ts +327 -0
  38. package/src/drivers/ai-openai-compatible.ts +211 -21
  39. package/src/drivers/ai-providers.test.ts +179 -2
  40. package/src/drivers/ai-stream.test.ts +195 -0
  41. package/src/drivers/ai-types.ts +42 -1
  42. package/src/drivers/channel-smtp.ts +8 -2
  43. package/src/drivers/index.ts +21 -1
  44. package/src/drivers/ollama.ts +14 -0
  45. package/src/elements/ai/rate.test.ts +53 -0
  46. package/src/elements/ai/rate.ts +66 -0
  47. package/src/elements/ai/redacted-prompt.test.ts +90 -0
  48. package/src/elements/ai/runtime.ts +330 -100
  49. package/src/elements/ai/tools.test.ts +99 -0
  50. package/src/elements/ai.test.ts +26 -2
  51. package/src/elements/ai.ts +10 -1
  52. package/src/i18n/catalogs/ar.ts +67 -0
  53. package/src/i18n/catalogs/en.ts +68 -0
  54. package/src/i18n/failure-message.test.ts +56 -0
  55. package/src/i18n/failure-message.ts +93 -0
  56. package/src/i18n/format.ts +67 -0
  57. package/src/i18n/index.ts +57 -0
  58. package/src/i18n/locale-context.ts +48 -0
  59. package/src/i18n/messages.test.ts +173 -0
  60. package/src/i18n/messages.ts +169 -0
  61. package/src/i18n/types.ts +90 -0
  62. package/src/index.ts +26 -0
  63. package/src/kernel/app.ts +92 -2
  64. package/src/kernel/boot-bind/ai.test.ts +60 -0
  65. package/src/kernel/boot-bind/ai.ts +125 -2
  66. package/src/kernel/boot.test.ts +4 -3
  67. package/src/kernel/boot.ts +1 -1
  68. package/src/kernel/errors.ts +56 -5
  69. package/src/kernel/fx.test.ts +27 -0
  70. package/src/kernel/fx.ts +74 -18
  71. package/src/kernel/pipeline.test.ts +4 -0
  72. package/src/kernel/pipeline.ts +1 -1
  73. package/src/kernel/plugin.ts +16 -0
  74. package/src/kernel/registry.ts +15 -0
  75. package/src/plugins/auth/shared.ts +5 -1
  76. package/src/plugins/auth-delivery.mailpit.integration.test.ts +330 -0
  77. package/src/plugins/auth-methods.security.test.ts +764 -0
  78. package/src/plugins/compression.ts +1 -1
  79. package/src/plugins/config-source.test.ts +11 -11
  80. package/src/plugins/config-source.ts +2 -2
  81. package/src/plugins/cors.ts +1 -1
  82. package/src/plugins/email-otp.ts +54 -1
  83. package/src/plugins/{security-headers.test.ts → headers.test.ts} +18 -18
  84. package/src/plugins/headers.ts +240 -41
  85. package/src/plugins/index.ts +27 -5
  86. package/src/plugins/magic-link.ts +63 -3
  87. package/src/plugins/passkey-webauthn.ts +217 -0
  88. package/src/plugins/passkey.ts +99 -33
  89. package/src/plugins/response-headers.ts +54 -0
  90. package/src/plugins/two-factor.ts +6 -2
  91. package/src/plugins/username-policy.test.ts +302 -0
  92. package/src/plugins/username.ts +290 -9
  93. package/src/release/measure.ts +8 -1
  94. 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). Must honour the flow's gates.
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
- const result = await client.complete({
281
- model: modelName,
282
- messages: [
283
- {
284
- role: "user",
285
- content: typeof input === "string" ? input : JSON.stringify(input ?? {}),
286
- },
287
- ],
288
- responseFormat: decl.out,
289
- });
290
- const attemptCost = result.usage?.cost ?? 0;
291
- totalCost += attemptCost;
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 ?? 6;
383
- const runDenials: AgentDenial[] = [];
384
- const trail: AgentToolStep[] = [];
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
- // Simple tool loop: try each declared tool once (bounded).
390
- for (const tool of decl.tools) {
391
- if (steps >= maxSteps) break;
392
- steps++;
393
- const effects = effectsFor(tool);
394
- const requiredGates = options.gatesForFlow?.(tool) ?? [];
395
- if (requiredGates.length > 0 && options.gates) {
396
- const ctx: GatePolicyContext = {
397
- auth: runOpts.auth ?? {
398
- userId: null,
399
- scopes: new Set(),
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: runDenials.length === 0,
461
- steps,
462
- trail,
463
- denials: runDenials,
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: 0,
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: runDenials,
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
+ });