okengine 0.23.2 → 0.24.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 (75) hide show
  1. package/AGENTS.md +5 -1
  2. package/manifest.v1.schema.json +53 -3
  3. package/package.json +2 -1
  4. package/site/content/docs/ai/mcp.mdx +1 -1
  5. package/site/content/docs/ai/skills.mdx +1 -1
  6. package/site/content/docs/elements/ai/decide.mdx +95 -417
  7. package/site/content/docs/elements/ai/deciders.mdx +154 -0
  8. package/site/content/docs/elements/ai/index.mdx +2 -1
  9. package/site/content/docs/elements/ai/meta.json +1 -1
  10. package/site/content/docs/reference/cli.mdx +2 -2
  11. package/site/content/docs/reference/configuration.mdx +2 -1
  12. package/site/content/docs/reference/fx.mdx +11 -9
  13. package/src/cli/agents-md.test.ts +8 -2
  14. package/src/cli/decide-certify.test.ts +166 -0
  15. package/src/cli/decide.ts +170 -3
  16. package/src/cli/decision-lock-watch.test.ts +4 -4
  17. package/src/cli/eval.ts +38 -59
  18. package/src/cli/mcp-from-console.ts +1 -1
  19. package/src/cli/registry.ts +45 -9
  20. package/src/compiler/decisions.extract.test.ts +86 -31
  21. package/src/compiler/effects-infer.ts +1 -0
  22. package/src/compiler/extract.ts +239 -24
  23. package/src/config/index.ts +6 -0
  24. package/src/console/server/decisions.test.ts +6 -1
  25. package/src/console/server/decisions.ts +32 -1
  26. package/src/console/ui-next/dist/assets/{access-page-zBMhZnFm.js → access-page-DX2dKLmq.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{agent-disclosure-CBj9f7Ju.js → agent-disclosure-CiRvA3M9.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{cache-glyph-D3MY49h3.js → cache-glyph-CC1yFJ8A.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{call-pii-button-L9JxdBhp.js → call-pii-button-CLXQgIDD.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{collapsible-BvFaX-Zt.js → collapsible-C0AThBZg.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{decisions-page-DgB76m-X.js → decisions-page-Bf3AhTAa.js} +1 -1
  32. package/src/console/ui-next/dist/assets/{duration-tone-l1DSJ2Vz.js → duration-tone-Bptur2gX.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{flows-page-Bb9t1lu6.js → flows-page-DRZVeTZU.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{highlighted-json-DMvsi3Ti.js → highlighted-json-BKf0PjBK.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{http-method-BwhJBTcQ.js → http-method-uhrnWrGC.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{index-RhV2jT_7.js → index-Duxus_sG.js} +3 -3
  37. package/src/console/ui-next/dist/assets/{observability-page-BRfUILBq.js → observability-page-B-QM8X0O.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{replica-lag-BsceAzIG.js → replica-lag-DuqHCANi.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{request-meta-C6lTyCXp.js → request-meta-BTBZ3Ulc.js} +1 -1
  40. package/src/console/ui-next/dist/assets/{store-page-YmE806bJ.js → store-page-fpo7VxYb.js} +1 -1
  41. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Di4irS49.js → trace-detail-sheet-DKtr1-K1.js} +1 -1
  42. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DYeL6Rye.js → tree-expand-toggle-QeTceKKI.js} +1 -1
  43. package/src/console/ui-next/dist/assets/{units-page-07agasRA.js → units-page-ChCp8mOy.js} +1 -1
  44. package/src/console/ui-next/dist/assets/{vault-page-CHBck4n_.js → vault-page-CEYLXhUX.js} +1 -1
  45. package/src/console/ui-next/dist/index.html +1 -1
  46. package/src/console/ui-next/src/client.ts +8 -0
  47. package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +16 -0
  48. package/src/elements/ai/deciders/presets.ts +182 -0
  49. package/src/elements/ai/decisions/bind.ts +6 -5
  50. package/src/elements/ai/decisions/catalog.ts +93 -0
  51. package/src/elements/ai/decisions/certificate.test.ts +47 -19
  52. package/src/elements/ai/decisions/certificate.ts +107 -24
  53. package/src/elements/ai/decisions/certify.ts +104 -9
  54. package/src/elements/ai/decisions/codec.test.ts +142 -0
  55. package/src/elements/ai/decisions/codec.ts +302 -0
  56. package/src/elements/ai/decisions/decide.test.ts +108 -31
  57. package/src/elements/ai/decisions/decider-runtime.test.ts +415 -0
  58. package/src/elements/ai/decisions/decider.types.test.ts +62 -0
  59. package/src/elements/ai/decisions/e2e.test.ts +20 -16
  60. package/src/elements/ai/decisions/http.ts +15 -7
  61. package/src/elements/ai/decisions/labels.test.ts +6 -1
  62. package/src/elements/ai/decisions/labels.ts +4 -3
  63. package/src/elements/ai/decisions/openai.live.test.ts +42 -0
  64. package/src/elements/ai/decisions/openrouter.live.test.ts +5 -1
  65. package/src/elements/ai/decisions/provider.ts +22 -1
  66. package/src/elements/ai/declare.ts +208 -51
  67. package/src/elements/ai.ts +1 -0
  68. package/src/index.ts +1 -0
  69. package/src/kernel/app.ts +8 -0
  70. package/src/kernel/decision-budget-entry.ts +11 -0
  71. package/src/kernel/element-registries.ts +3 -0
  72. package/src/kernel/fx-decide.ts +276 -88
  73. package/src/manifest/types.ts +38 -2
  74. package/src/release/measure.ts +47 -8
  75. package/src/test/create-test-app.ts +21 -1
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Opt-in OpenAI decisions smoke check.
3
+ *
4
+ * Skipped unless `OPENAI_API_KEY` is set. `bun test` ignores `*.live.test.ts`.
5
+ * The key is never printed, logged, or written.
6
+ */
7
+
8
+ import { afterEach, describe, expect, test } from "bun:test";
9
+ import { ai, resetAiDecls } from "../../ai.ts";
10
+ import { createFx } from "../../../kernel/fx.ts";
11
+ import { resetDecisionCertificates } from "./certificate.ts";
12
+ import { resetDecisionProvider } from "../../../kernel/fx-decide.ts";
13
+
14
+ const apiKey = process.env["CI"] === "true" ? undefined : process.env["OPENAI_API_KEY"];
15
+
16
+ afterEach(() => {
17
+ resetAiDecls();
18
+ resetDecisionCertificates();
19
+ resetDecisionProvider();
20
+ });
21
+
22
+ describe.skipIf(!apiKey)("openai decisions live", () => {
23
+ test("fx.decide posts to /v1/decisions", async () => {
24
+ const decl = ai.decision("triage-openai", {
25
+ decider: ai.decider("luna", { provider: "openai", model: "gpt-6-luna" }),
26
+ otherwise: "abstain",
27
+ ask: {
28
+ urgent: ai.boolean("Does this need a person today?"),
29
+ },
30
+ });
31
+ const fx = createFx({
32
+ flow: "triage.openai",
33
+ effects: { decides: ["triage-openai"], secrets: ["OPENAI_API_KEY"] },
34
+ secrets: { OPENAI_API_KEY: apiKey ?? "" },
35
+ });
36
+ const result = (await fx.decide(decl, "Checkout is blank after Pay.")) as {
37
+ $: { urgent: { by: string }; meta: { model: string } };
38
+ };
39
+ expect(result.$.urgent.by).toBe("luna");
40
+ expect(result.$.meta.model.length).toBeGreaterThan(0);
41
+ });
42
+ });
@@ -41,7 +41,11 @@ afterEach(() => {
41
41
 
42
42
  function decision() {
43
43
  return ai.decision("triage-live", {
44
- onUncertain: "abstain",
44
+ decider: ai.decider("jev", {
45
+ provider: "openrouter",
46
+ model: "typesafe/jev-1.13",
47
+ }),
48
+ otherwise: "abstain",
45
49
  ask: {
46
50
  team: ai.choice("Which team should own this ticket?", {
47
51
  billing: "Payments and refunds",
@@ -40,7 +40,7 @@ export interface DecisionUsage {
40
40
  export interface DecisionResponse {
41
41
  readonly model: string;
42
42
  readonly provider?: string;
43
- readonly answers: Readonly<Record<string, unknown>>;
43
+ readonly answers: Readonly<Record<string, unknown>> | readonly unknown[];
44
44
  readonly usage: DecisionUsage;
45
45
  }
46
46
 
@@ -90,6 +90,27 @@ export class DecisionConfigError extends Error {
90
90
  }
91
91
  }
92
92
 
93
+ /** State is larger than the decider's max context. Not an outage. */
94
+ export class DecisionInputTooLarge extends Error {
95
+ readonly decider: string;
96
+ readonly tokens: number;
97
+ readonly maxContext: number;
98
+ /**
99
+ * @param decider - Decider name
100
+ * @param tokens - Estimated input tokens
101
+ * @param maxContext - Capability limit
102
+ */
103
+ constructor(decider: string, tokens: number, maxContext: number) {
104
+ super(
105
+ `fx.decide: decider "${decider}" state is ${tokens} tokens; max context is ${maxContext}`,
106
+ );
107
+ this.name = "DecisionInputTooLarge";
108
+ this.decider = decider;
109
+ this.tokens = tokens;
110
+ this.maxContext = maxContext;
111
+ }
112
+ }
113
+
93
114
  /** The breaker is open. Callers take the non-auto path. */
94
115
  export class DecisionOutageError extends Error {
95
116
  /**
@@ -6,12 +6,20 @@
6
6
 
7
7
  import {
8
8
  aiAgentRegistry,
9
+ aiDeciderRegistry,
9
10
  aiDecisionRegistry,
10
11
  aiEmbedRegistry,
11
12
  aiMcpServerRegistry,
12
13
  aiModelRegistry,
13
14
  aiPromptRegistry,
14
15
  } from "../../kernel/element-registries.ts";
16
+ import {
17
+ DECIDER_PRESETS,
18
+ resolveDecider,
19
+ type DeciderCapabilities,
20
+ type DeciderProtocol,
21
+ type DeciderPinning,
22
+ } from "./deciders/presets.ts";
15
23
  import { mcpToolRef, type McpToolRef } from "../../manifest/mcp-ref.ts";
16
24
  import type { VaultSecretDecl } from "../vault/declare.ts";
17
25
  import { formatAiProviderTier2Warn, resolveAiModelBaseUrl } from "./providers.ts";
@@ -131,19 +139,78 @@ export interface AiDecisionAutonomy {
131
139
  readonly risk?: number;
132
140
  }
133
141
 
134
- /** Options for {@link ai.decision}. Exactly one of `review` and `onUncertain` is required. */
135
- export interface AiDecisionOptions {
136
- readonly model?: AiModelDecl | string;
142
+ /** Questions a capability row allows. A `false` flag removes that kind. */
143
+ export type AskForCapabilities<C> = Readonly<
144
+ Record<
145
+ string,
146
+ | (C extends { readonly boolean: true } ? AiBooleanQuestion : never)
147
+ | (C extends { readonly choice: true } ? AiChoiceQuestion : never)
148
+ | (C extends { readonly score: true } ? AiScoreQuestion : never)
149
+ >
150
+ >;
151
+
152
+ /** Options for a preset host. URL, protocol, secret, and capabilities are filled in. */
153
+ export interface AiDeciderPresetOptions {
154
+ readonly provider: "openrouter" | "openai";
155
+ readonly model: string;
156
+ readonly secret?: string;
157
+ readonly region?: string;
158
+ readonly zdr?: boolean;
159
+ readonly timeout?: AiTimeout;
160
+ readonly concurrency?: number;
161
+ }
162
+
163
+ /** Options for a host that is not a preset. */
164
+ export interface AiDeciderCustomOptions<C extends DeciderCapabilities = DeciderCapabilities> {
165
+ readonly provider?: string;
166
+ readonly driverId: DeciderProtocol;
167
+ readonly baseUrl: string;
168
+ readonly model: string;
169
+ readonly secret: string;
170
+ readonly capabilities: C;
171
+ readonly region?: string;
172
+ readonly zdr?: boolean;
173
+ readonly timeout?: AiTimeout;
174
+ readonly concurrency?: number;
175
+ }
176
+
177
+ /** Options for {@link ai.decider}. */
178
+ export type AiDeciderOptions<C extends DeciderCapabilities = DeciderCapabilities> =
179
+ | AiDeciderPresetOptions
180
+ | AiDeciderCustomOptions<C>;
181
+
182
+ /** Declared decider. One model, one breaker, one certificate. */
183
+ export interface AiDeciderDecl<C extends DeciderCapabilities = DeciderCapabilities> {
184
+ readonly kind: "decider";
185
+ readonly name: string;
186
+ readonly provider?: string;
187
+ readonly protocol: DeciderProtocol;
188
+ readonly baseUrl: string;
189
+ readonly model: string;
190
+ readonly secret: string;
191
+ readonly pinning: DeciderPinning;
192
+ readonly capabilities: C;
193
+ readonly region?: string;
194
+ readonly regionStatus?: "declared";
195
+ readonly zdr?: boolean;
196
+ readonly zdrStatus?: "declared";
197
+ readonly timeout?: AiTimeout;
198
+ readonly concurrency?: number;
199
+ }
200
+
201
+ /** Options for {@link ai.decision}. `decider` and `otherwise` are required. */
202
+ export interface AiDecisionOptions<C extends DeciderCapabilities = DeciderCapabilities> {
203
+ readonly decider: AiDeciderDecl<C>;
204
+ readonly backup?: readonly AiDeciderDecl[];
205
+ /** A gate name parks. `"abstain"` returns null and does not park. */
206
+ readonly otherwise: { readonly name: string } | string;
137
207
  readonly in?: unknown;
138
- readonly ask: Readonly<Record<string, AiDecisionQuestion>>;
208
+ readonly ask: AskForCapabilities<C>;
139
209
  readonly autonomy?: AiDecisionAutonomy;
140
- readonly review?: { readonly name: string } | string;
141
- readonly onUncertain?: "abstain";
142
210
  readonly locale?: (input: unknown) => string | undefined;
143
211
  readonly evals?: string;
144
- /** `openrouter` (default) or `typesafe`. */
145
- readonly driverId?: "openrouter" | "typesafe";
146
- readonly timeout?: AiTimeout;
212
+ /** Reserved. Using it fails with a message that says it is planned. */
213
+ readonly shadow?: never;
147
214
  }
148
215
 
149
216
  /** Declared decision handle. */
@@ -152,12 +219,14 @@ export interface AiDecisionDecl {
152
219
  readonly name: string;
153
220
  readonly ask: Readonly<Record<string, AiDecisionQuestion>>;
154
221
  readonly mode: "review" | "abstain";
222
+ /** Gate name when `otherwise` is a gate. */
155
223
  readonly review?: string;
156
- readonly model?: string;
157
- readonly driverId: "openrouter" | "typesafe";
224
+ readonly decider: string;
225
+ readonly backup: readonly string[];
226
+ readonly otherwise: string;
227
+ readonly secrets: readonly string[];
158
228
  readonly autonomy?: AiDecisionAutonomy;
159
229
  readonly evals?: string;
160
- readonly timeout?: AiTimeout;
161
230
  readonly locale?: (input: unknown) => string | undefined;
162
231
  /** Declared `in` schema. Label export keeps only these fields. */
163
232
  readonly inputSchema?: unknown;
@@ -325,24 +394,45 @@ export interface AiNamespace {
325
394
  */
326
395
  agent(name: string, options?: AiAgentOptions): AiAgentDecl;
327
396
  /**
328
- * Declare a decision. Exactly one of `review` and `onUncertain: "abstain"`.
397
+ * Declare one model that can answer a decision.
398
+ *
399
+ * @param name - Decider id
400
+ * @param options - Preset provider, or driverId + baseUrl + capabilities
401
+ */
402
+ decider(
403
+ name: string,
404
+ options: AiDeciderPresetOptions & { readonly provider: "openrouter" },
405
+ ): AiDeciderDecl<(typeof DECIDER_PRESETS)["openrouter"]["capabilities"]>;
406
+ decider(
407
+ name: string,
408
+ options: AiDeciderPresetOptions & { readonly provider: "openai" },
409
+ ): AiDeciderDecl<(typeof DECIDER_PRESETS)["openai"]["capabilities"]>;
410
+ decider<const C extends DeciderCapabilities>(
411
+ name: string,
412
+ options: AiDeciderCustomOptions<C>,
413
+ ): AiDeciderDecl<C>;
414
+ /**
415
+ * Declare a decision. `decider` and `otherwise` are required.
329
416
  *
330
417
  * @param name - Decision id
331
- * @param options - Questions, mode, and optional autonomy
418
+ * @param options - Decider, fallback, and questions
332
419
  */
333
- decision(name: string, options: AiDecisionOptions): AiDecisionDecl;
420
+ decision<C extends DeciderCapabilities>(
421
+ name: string,
422
+ options: AiDecisionOptions<C>,
423
+ ): AiDecisionDecl;
334
424
  /**
335
- * A choice question. The answer union is {@link DecisionChoiceValue}: author keys or `none_of_these`.
425
+ * A choice question. The answer union includes author keys and `none_of_these`.
336
426
  *
337
427
  * @param instructions - What to decide
338
- * @param options - At most 254 author options
428
+ * @param options - Author options. `none_of_these` is injected.
339
429
  */
340
430
  choice(instructions: string, options: Readonly<Record<string, string | null>>): AiChoiceQuestion;
341
431
  /**
342
432
  * An ordered score question.
343
433
  *
344
434
  * @param instructions - What to rate
345
- * @param levels - 2–10 level descriptions, low to high
435
+ * @param levels - Level descriptions, low to high
346
436
  */
347
437
  score(instructions: string, levels: readonly string[]): AiScoreQuestion;
348
438
  /**
@@ -372,6 +462,7 @@ export function listAiDecls(): {
372
462
  readonly prompts: readonly AiPromptDecl[];
373
463
  readonly embeds: readonly AiEmbedDecl[];
374
464
  readonly agents: readonly AiAgentDecl[];
465
+ readonly deciders: readonly AiDeciderDecl[];
375
466
  readonly decisions: readonly AiDecisionDecl[];
376
467
  readonly mcpServers: readonly AiMcpServerDecl[];
377
468
  } {
@@ -380,6 +471,7 @@ export function listAiDecls(): {
380
471
  prompts: aiPromptRegistry.slice(),
381
472
  embeds: aiEmbedRegistry.slice(),
382
473
  agents: aiAgentRegistry.slice(),
474
+ deciders: aiDeciderRegistry.slice(),
383
475
  decisions: aiDecisionRegistry.slice(),
384
476
  mcpServers: aiMcpServerRegistry.slice(),
385
477
  };
@@ -393,6 +485,7 @@ export function resetAiDecls(): void {
393
485
  aiPromptRegistry.length = 0;
394
486
  aiEmbedRegistry.length = 0;
395
487
  aiAgentRegistry.length = 0;
488
+ aiDeciderRegistry.length = 0;
396
489
  aiDecisionRegistry.length = 0;
397
490
  aiMcpServerRegistry.length = 0;
398
491
  }
@@ -400,7 +493,7 @@ export function resetAiDecls(): void {
400
493
  /**
401
494
  * AI element namespace.
402
495
  */
403
- export const ai: AiNamespace = {
496
+ export const ai = {
404
497
  /**
405
498
  * Declare a model binding.
406
499
  *
@@ -510,6 +603,12 @@ export const ai: AiNamespace = {
510
603
  return decl;
511
604
  },
512
605
 
606
+ decider(name: string, options: AiDeciderOptions): AiDeciderDecl {
607
+ const decl = buildDeciderDecl(name, options);
608
+ aiDeciderRegistry.push(decl);
609
+ return decl;
610
+ },
611
+
513
612
  decision(name: string, options: AiDecisionOptions): AiDecisionDecl {
514
613
  const decl = buildDecisionDecl(name, options);
515
614
  aiDecisionRegistry.push(decl);
@@ -571,7 +670,25 @@ export const ai: AiNamespace = {
571
670
  aiMcpServerRegistry.push(decl);
572
671
  return decl;
573
672
  },
574
- };
673
+ } as AiNamespace;
674
+
675
+ /**
676
+ * Validate a decision and return the registered shape.
677
+ *
678
+ * @param name - Decision id
679
+ * @param options - Author options
680
+ */
681
+ /**
682
+ * Validate a decider and return the registered shape.
683
+ *
684
+ * @param name - Decider id
685
+ * @param options - Author options
686
+ */
687
+ export function buildDeciderDecl(name: string, options: AiDeciderOptions): AiDeciderDecl {
688
+ if (!name) throw new TypeError("ai.decider: name is required");
689
+ const resolved = resolveDecider(name, options);
690
+ return { kind: "decider", name, ...resolved };
691
+ }
575
692
 
576
693
  /**
577
694
  * Validate a decision and return the registered shape.
@@ -581,59 +698,99 @@ export const ai: AiNamespace = {
581
698
  */
582
699
  export function buildDecisionDecl(name: string, options: AiDecisionOptions): AiDecisionDecl {
583
700
  if (!name) throw new TypeError("ai.decision: name is required");
701
+ if ("shadow" in options && options.shadow !== undefined) {
702
+ throw new TypeError(`ai.decision("${name}"): shadow is planned`);
703
+ }
704
+ if (!options.decider || options.decider.kind !== "decider") {
705
+ throw new TypeError(`ai.decision("${name}"): decider is required`);
706
+ }
707
+ if (options.otherwise === undefined) {
708
+ throw new TypeError(`ai.decision("${name}"): otherwise is required`);
709
+ }
584
710
  const keys = Object.keys(options.ask ?? {});
585
711
  if (keys.length === 0) throw new TypeError(`ai.decision("${name}"): ask is empty`);
586
- const hasReview = options.review !== undefined;
587
- const abstain = options.onUncertain === "abstain";
588
- if (hasReview === abstain) {
589
- throw new TypeError(
590
- `ai.decision("${name}"): declare exactly one of review or onUncertain: "abstain"`,
591
- );
592
- }
593
712
  if (options.autonomy !== undefined && options.autonomy.audit === undefined) {
594
713
  throw new TypeError(`ai.decision("${name}"): autonomy requires audit`);
595
714
  }
715
+ const chain = [options.decider, ...(options.backup ?? [])];
596
716
  for (const key of keys) {
597
717
  if (key === "meta" || key === "$") {
598
718
  throw new TypeError(`ai.decision("${name}"): question id "${key}" is reserved`);
599
719
  }
600
720
  const question = options.ask[key];
601
721
  if (!question) continue;
602
- if (question.kind === "choice") {
603
- const count = Object.keys(question.options).length;
604
- if (count > 254) {
605
- throw new TypeError(
606
- `ai.decision("${name}"): choice "${key}" has ${count} options; max 254`,
607
- );
608
- }
609
- if (Object.prototype.hasOwnProperty.call(question.options, "none_of_these")) {
610
- throw new TypeError(`ai.decision("${name}"): choice "${key}" must not set none_of_these`);
611
- }
612
- }
613
- if (question.kind === "score") {
614
- const count = question.levels.length;
615
- if (count < 2 || count > 10) {
616
- throw new TypeError(`ai.decision("${name}"): score "${key}" needs 2–10 levels`);
617
- }
722
+ for (const decider of chain) {
723
+ assertQuestionFits(name, key, question, decider);
618
724
  }
619
725
  }
620
- const review = typeof options.review === "string" ? options.review : options.review?.name;
621
- const model =
622
- typeof options.model === "string"
623
- ? options.model
624
- : (options.model?.model ?? options.model?.name);
726
+ const abstain = options.otherwise === "abstain";
727
+ const review = abstain
728
+ ? undefined
729
+ : typeof options.otherwise === "string"
730
+ ? options.otherwise
731
+ : options.otherwise.name;
732
+ const secrets = [...new Set(chain.map((decider) => decider.secret))].sort();
625
733
  return {
626
734
  kind: "decision",
627
735
  name,
628
736
  ask: options.ask,
629
737
  mode: abstain ? "abstain" : "review",
630
738
  ...(review !== undefined ? { review } : {}),
631
- ...(model !== undefined ? { model } : {}),
632
- driverId: options.driverId ?? "openrouter",
739
+ decider: options.decider.name,
740
+ backup: (options.backup ?? []).map((decider) => decider.name),
741
+ otherwise: abstain ? "abstain" : (review ?? ""),
742
+ secrets,
633
743
  ...(options.autonomy !== undefined ? { autonomy: options.autonomy } : {}),
634
744
  ...(options.evals !== undefined ? { evals: options.evals } : {}),
635
- ...(options.timeout !== undefined ? { timeout: options.timeout } : {}),
636
745
  ...(options.locale !== undefined ? { locale: options.locale } : {}),
637
746
  ...(options.in !== undefined ? { inputSchema: options.in } : {}),
638
747
  };
639
748
  }
749
+
750
+ function assertQuestionFits(
751
+ decision: string,
752
+ key: string,
753
+ question: AiDecisionQuestion,
754
+ decider: AiDeciderDecl,
755
+ ): void {
756
+ const caps = decider.capabilities;
757
+ if (question.kind === "boolean" && !caps.boolean) {
758
+ throw new TypeError(
759
+ `ai.decision("${decision}"): decider "${decider.name}" cannot answer boolean "${key}"`,
760
+ );
761
+ }
762
+ if (question.kind === "choice") {
763
+ if (!caps.choice) {
764
+ throw new TypeError(
765
+ `ai.decision("${decision}"): decider "${decider.name}" cannot answer choice "${key}"`,
766
+ );
767
+ }
768
+ const count = Object.keys(question.options).length;
769
+ if (typeof caps.maxChoices === "number" && count > caps.maxChoices) {
770
+ throw new TypeError(
771
+ `ai.decision("${decision}"): choice "${key}" has ${count} options; decider "${decider.name}" allows ${caps.maxChoices}`,
772
+ );
773
+ }
774
+ if (Object.prototype.hasOwnProperty.call(question.options, "none_of_these")) {
775
+ throw new TypeError(`ai.decision("${decision}"): choice "${key}" must not set none_of_these`);
776
+ }
777
+ }
778
+ if (question.kind === "score") {
779
+ if (!caps.score) {
780
+ throw new TypeError(
781
+ `ai.decision("${decision}"): decider "${decider.name}" cannot answer score "${key}"`,
782
+ );
783
+ }
784
+ const count = question.levels.length;
785
+ if (typeof caps.minLevels === "number" && count < caps.minLevels) {
786
+ throw new TypeError(
787
+ `ai.decision("${decision}"): score "${key}" has ${count} levels; decider "${decider.name}" needs at least ${caps.minLevels}`,
788
+ );
789
+ }
790
+ if (typeof caps.maxLevels === "number" && count > caps.maxLevels) {
791
+ throw new TypeError(
792
+ `ai.decision("${decision}"): score "${key}" has ${count} levels; decider "${decider.name}" allows ${caps.maxLevels}`,
793
+ );
794
+ }
795
+ }
796
+ }
@@ -18,6 +18,7 @@ export type {
18
18
  AiAgentDecl,
19
19
  AiBooleanQuestion,
20
20
  AiChoiceQuestion,
21
+ AiDeciderDecl,
21
22
  AiDecisionDecl,
22
23
  AiDecisionOptions,
23
24
  AiDecisionQuestion,
package/src/index.ts CHANGED
@@ -175,6 +175,7 @@ export {
175
175
  type AiModelDecl,
176
176
  type AiPromptDecl,
177
177
  type AiAgentDecl,
178
+ type AiDeciderDecl,
178
179
  type AiDecisionDecl,
179
180
  type AiDecisionOptions,
180
181
  type AiMcpServerDecl,
package/src/kernel/app.ts CHANGED
@@ -1704,6 +1704,14 @@ export function oke(options: OkeOptions): OkeApp {
1704
1704
  "oke boot: decisions are declared but rootDir and OKE_ROOT_DIR are unset, so the decision lockfile cannot load.",
1705
1705
  );
1706
1706
  }
1707
+ const { resolveDriverId } = await import("../config/index.ts");
1708
+ const { setDecisionTransport } = loadFxDecideModule();
1709
+ const decideDriver = resolveDriverId(
1710
+ (overrides?.config ?? options.config)?.drivers?.decide,
1711
+ bootEnv,
1712
+ { test: "mock" },
1713
+ );
1714
+ setDecisionTransport(decideDriver === "mock" ? "mock" : "live");
1707
1715
  await loadDecisionLockfile(root);
1708
1716
  setDeclaredDriftDecisions(Object.keys(declaredDecisions));
1709
1717
  if (result.journal && result.journal.store.decisions) {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Bundle entry for the reported decision-module gzip row.
3
+ * Not on the kernel edge graph.
4
+ */
5
+
6
+ import { runFxDecide } from "./fx-decide.ts";
7
+
8
+ /** Anchor so minify keeps the decision module. */
9
+ export function __okeDecisionBudgetAnchor(): typeof runFxDecide {
10
+ return runFxDecide;
11
+ }
@@ -16,6 +16,7 @@
16
16
 
17
17
  import type {
18
18
  AiAgentDecl,
19
+ AiDeciderDecl,
19
20
  AiDecisionDecl,
20
21
  AiEmbedDecl,
21
22
  AiMcpServerDecl,
@@ -53,5 +54,7 @@ export const aiEmbedRegistry: AiEmbedDecl[] = [];
53
54
  export const aiAgentRegistry: AiAgentDecl[] = [];
54
55
  /** `ai.mcpServer` declarations since the last reset. */
55
56
  export const aiMcpServerRegistry: AiMcpServerDecl[] = [];
57
+ /** `ai.decider` declarations since the last reset. */
58
+ export const aiDeciderRegistry: AiDeciderDecl[] = [];
56
59
  /** `ai.decision` declarations since the last reset. */
57
60
  export const aiDecisionRegistry: AiDecisionDecl[] = [];