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
@@ -20,7 +20,7 @@ describe("decision lockfile watch", () => {
20
20
  const path = join(root, DECISION_LOCK_FILENAME);
21
21
  await Bun.write(
22
22
  path,
23
- `${JSON.stringify({ decisions: { triage: { model: "m", questions: {} } } })}\n`,
23
+ `${JSON.stringify({ version: 2, decisions: { triage: { deciders: { jev: { model: "m", pinned: true, questions: {} } } } } })}\n`,
24
24
  );
25
25
  let listener: ((event: "rename" | "change", filename: string | null) => void) | undefined;
26
26
  const watch = watchDecisionLockfile(root, (_path, _options, next) => {
@@ -29,15 +29,15 @@ describe("decision lockfile watch", () => {
29
29
  });
30
30
  listener?.("change", DECISION_LOCK_FILENAME);
31
31
  await watch.flushed();
32
- expect(getDecisionLock()?.decisions.triage?.model).toBe("m");
32
+ expect(getDecisionLock()?.decisions.triage?.deciders.jev?.model).toBe("m");
33
33
 
34
34
  await Bun.write(
35
35
  path,
36
- `${JSON.stringify({ decisions: { route: { model: "n", questions: {} } } })}\n`,
36
+ `${JSON.stringify({ version: 2, decisions: { route: { deciders: { jev: { model: "n", pinned: true, questions: {} } } } } })}\n`,
37
37
  );
38
38
  listener?.("change", DECISION_LOCK_FILENAME);
39
39
  await watch.flushed();
40
- expect(getDecisionLock()?.decisions.route?.model).toBe("n");
40
+ expect(getDecisionLock()?.decisions.route?.deciders.jev?.model).toBe("n");
41
41
  expect(getDecisionLock()?.decisions.triage).toBeUndefined();
42
42
  watch.close();
43
43
  resetDecisionCertificates();
package/src/cli/eval.ts CHANGED
@@ -97,23 +97,19 @@ export async function runOkeEval(options: OkeEvalOptions = {}): Promise<number>
97
97
  */
98
98
  export async function evalCli(args: string[]): Promise<number> {
99
99
  let manifestPath: string | undefined;
100
- let certify = false;
101
100
  for (let i = 0; i < args.length; i++) {
102
101
  const a = args[i]!;
103
102
  if (a === "--manifest" || a === "-m") {
104
103
  manifestPath = args[++i];
105
- } else if (a === "--certify") {
106
- certify = true;
107
104
  } else if (a === "--help" || a === "-h") {
108
- console.log(`oke eval [--manifest oke.manifest.json] [--certify]
105
+ console.log(`oke eval [--manifest oke.manifest.json]
109
106
 
110
107
  Run prompt eval sets declared in the Manifest. Fails CI on regression.
111
- --certify builds a decision certificate from each decision seed file.
108
+ Decision certificates are \`oke decide certify\`.
112
109
  `);
113
110
  return 0;
114
111
  }
115
112
  }
116
- if (certify) return runOkeCertify({ manifestPath });
117
113
  return runOkeEval({ manifestPath });
118
114
  }
119
115
 
@@ -131,7 +127,7 @@ export async function runOkeCertify(
131
127
  } = {},
132
128
  ): Promise<number> {
133
129
  const { certifySeed } = await import("../elements/ai/decisions/certify.ts");
134
- const { aiDecisionRegistry } = await import("../kernel/element-registries.ts");
130
+ const { aiDeciderRegistry, aiDecisionRegistry } = await import("../kernel/element-registries.ts");
135
131
  const { DECISION_LOCK_FILENAME, parseDecisionLockfile } =
136
132
  await import("../elements/ai/decisions/certificate.ts");
137
133
  const { resolveStartEntry } = await import("./start.ts");
@@ -168,28 +164,39 @@ export async function runOkeCertify(
168
164
  const current = (await existingFile.exists())
169
165
  ? parseDecisionLockfile(await existingFile.json())
170
166
  : undefined;
171
- const next = { decisions: { ...(current?.decisions ?? {}) } };
167
+ const next: {
168
+ version: 2;
169
+ decisions: Record<string, { deciders: Record<string, unknown> }>;
170
+ } = { version: 2, decisions: { ...(current?.decisions ?? {}) } };
172
171
  for (const name of names) {
173
172
  const decision = decisions[name];
174
173
  if (!decision?.evals) {
175
- process.stdout.write(`oke eval: skip ${name} (no evals)\n`);
174
+ process.stdout.write(`oke decide certify: skip ${name} (no evals)\n`);
176
175
  continue;
177
176
  }
178
177
  const decl = aiDecisionRegistry.find((item) => item.name === name);
179
178
  if (!decl) {
180
- console.error(`oke eval: decision "${name}" is not loaded`);
179
+ console.error(`oke decide certify: decision "${name}" is not loaded`);
180
+ return 1;
181
+ }
182
+ const decider = aiDeciderRegistry.find((item) => item.name === decl.decider);
183
+ if (!decider) {
184
+ console.error(`oke decide certify: decider "${decl.decider}" is not loaded`);
181
185
  return 1;
182
186
  }
183
187
  const text = await Bun.file(resolve(root, decision.evals)).text();
184
- const evaluate = injected ?? (await providerForDecision(decl));
185
- next.decisions[name] = await certifySeed({
186
- model: decl.model ?? decision.model ?? "",
188
+ const evaluate = injected ?? (await providerForDecider(decl, decider));
189
+ const result = await certifySeed({
190
+ model: decider.model,
191
+ pinned: decider.pinning !== "alias",
187
192
  maxError: decision.autonomy?.maxError ?? 0.05,
188
193
  delta: decision.autonomy?.risk ?? decl.autonomy?.risk ?? 0.1,
189
194
  jsonl: text,
190
195
  ask: decl.ask,
191
196
  evaluate,
192
197
  });
198
+ const prev = next.decisions[name]?.deciders ?? {};
199
+ next.decisions[name] = { deciders: { ...prev, [decider.name]: result.cert } };
193
200
  }
194
201
  await Bun.write(lockPath, `${JSON.stringify(next, null, 2)}\n`);
195
202
  const { setDecisionDrift } = await import("../elements/ai/decisions/certificate.ts");
@@ -201,53 +208,25 @@ export async function runOkeCertify(
201
208
  return 0;
202
209
  }
203
210
 
204
- async function providerForDecision(
211
+ async function providerForDecider(
205
212
  decl: import("../elements/ai/declare.ts").AiDecisionDecl,
213
+ decider: import("../elements/ai/declare.ts").AiDeciderDecl,
206
214
  ): Promise<(input: unknown) => Promise<DecisionResponse>> {
207
- const typesafe = decl.driverId === "typesafe";
208
- const keyName = typesafe ? "TYPESAFE_API_KEY" : "OPENROUTER_API_KEY";
209
- const apiKey = process.env[keyName];
210
- if (!apiKey) throw new Error(`oke eval: secret "${keyName}" is not configured`);
211
- const { createTypesafeDecisionProvider, TYPESAFE_JEV_MODEL } =
212
- await import("../elements/ai/decisions/typesafe.ts");
213
- const { createOpenRouterDecisionProvider, OPENROUTER_JEV_MODEL } =
214
- await import("../elements/ai/decisions/openrouter.ts");
215
- const provider = typesafe
216
- ? createTypesafeDecisionProvider(apiKey)
217
- : createOpenRouterDecisionProvider(apiKey);
218
- const model = decl.model ?? (typesafe ? TYPESAFE_JEV_MODEL : OPENROUTER_JEV_MODEL);
219
- return (input) =>
220
- provider.evaluate({
221
- model,
222
- state: input,
223
- questions: wireFromDecl(decl),
215
+ const apiKey = process.env[decider.secret];
216
+ if (!apiKey) throw new Error(`oke decide certify: secret "${decider.secret}" is not configured`);
217
+ return async (input) => {
218
+ const { decisionHttp } = await import("../elements/ai/decisions/http.ts");
219
+ const { encodeDecisionRequest, normalizeDecisionResponse, wireQuestions } =
220
+ await import("../elements/ai/decisions/codec.ts");
221
+ const request = { model: decider.model, state: input, questions: wireQuestions(decl.ask) };
222
+ const raw = await decisionHttp({
223
+ url: decider.baseUrl,
224
+ apiKey,
225
+ request,
226
+ body: encodeDecisionRequest(decider.protocol, request),
227
+ timeoutMs: 30_000,
228
+ breakerKey: decider.name,
224
229
  });
225
- }
226
-
227
- function wireFromDecl(
228
- decl: import("../elements/ai/declare.ts").AiDecisionDecl,
229
- ): import("../elements/ai/decisions/provider.ts").DecisionRequest["questions"] {
230
- const questions: Record<string, import("../elements/ai/decisions/provider.ts").WireQuestion> = {};
231
- for (const [id, question] of Object.entries(decl.ask)) {
232
- if (question.kind === "choice") {
233
- questions[id] = {
234
- type: "choice",
235
- instructions: question.instructions,
236
- criteria: { ...question.options, none_of_these: null },
237
- };
238
- } else if (question.kind === "score") {
239
- questions[id] = {
240
- type: "score",
241
- instructions: question.instructions,
242
- criteria: question.levels,
243
- };
244
- } else {
245
- questions[id] = {
246
- type: "noul",
247
- instructions: question.instructions,
248
- ...(question.criteria !== undefined ? { criteria: question.criteria } : {}),
249
- };
250
- }
251
- }
252
- return questions;
230
+ return normalizeDecisionResponse(decider.protocol, raw);
231
+ };
253
232
  }
@@ -79,7 +79,7 @@ export function mcpContextFromConsole(state: McpConsoleSurface): McpContext {
79
79
  if (row.status !== "pending" || row.labelOnly) continue;
80
80
  pending[row.decision] = (pending[row.decision] ?? 0) + 1;
81
81
  }
82
- return projectMcpDecisions(state.manifest, pending, fitted);
82
+ return projectMcpDecisions(state.manifest, pending, fitted, queue);
83
83
  },
84
84
  proposeStructural: async (input) =>
85
85
  emitStructuralDiff({
@@ -550,15 +550,7 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
550
550
  name: "eval",
551
551
  summary: "prompt eval sets (CI gate)",
552
552
  leaf: true,
553
- flags: [
554
- MANIFEST,
555
- {
556
- long: "--certify",
557
- takesValue: false,
558
- summary: "Build a decision certificate from the seed file",
559
- },
560
- HELP,
561
- ],
553
+ flags: [MANIFEST, HELP],
562
554
  },
563
555
  {
564
556
  name: "decide",
@@ -584,6 +576,50 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
584
576
  HELP,
585
577
  ],
586
578
  },
579
+ {
580
+ name: "labels",
581
+ summary: "Export reviewed labels as seed JSONL",
582
+ positionals: "<name>",
583
+ flags: [
584
+ {
585
+ long: "--export",
586
+ takesValue: false,
587
+ summary: "Write the JSONL file",
588
+ },
589
+ {
590
+ long: "--out",
591
+ takesValue: true,
592
+ valueName: "file",
593
+ summary: "Output path",
594
+ },
595
+ {
596
+ long: "--origin",
597
+ takesValue: true,
598
+ valueName: "url",
599
+ summary: "App origin",
600
+ },
601
+ HELP,
602
+ ],
603
+ },
604
+ {
605
+ name: "certify",
606
+ summary: "Write one certificate per decider",
607
+ positionals: "<name>",
608
+ flags: [
609
+ {
610
+ long: "--deciders",
611
+ takesValue: true,
612
+ valueName: "a,b",
613
+ summary: "Decider names",
614
+ },
615
+ HELP,
616
+ ],
617
+ },
618
+ {
619
+ name: "models",
620
+ summary: "List decision models",
621
+ flags: [HELP],
622
+ },
587
623
  ],
588
624
  },
589
625
  {
@@ -1,27 +1,37 @@
1
1
  /**
2
- * Decision extraction: exclusive modes, and review cannot sit on HTTP.
2
+ * Decision extraction: decider, otherwise, and capability limits.
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
6
  import { extractFromSources } from "./extract.ts";
7
7
 
8
- const header = `const triage = ai.decision("triage", {\n`;
8
+ const decider = `const jev = ai.decider("jev", { provider: "openrouter", model: "typesafe/jev-1.13" });\n`;
9
9
 
10
10
  describe("ai.decision extract", () => {
11
- test("review and abstain together fail to compile", async () => {
11
+ test("a decision without otherwise fails to compile", async () => {
12
12
  await expect(
13
13
  extractFromSources({
14
- "src/flows/run.ts": `${header} review: "ops",\n onUncertain: "abstain",\n ask: { team: ai.choice("which", { a: "A" }) },\n});\n`,
14
+ "src/flows/run.ts": `${decider}ai.decision("triage", {\n decider: jev,\n ask: { team: ai.choice("which", { a: "A" }) },\n});\n`,
15
15
  }),
16
- ).rejects.toThrow(/exactly one of review/);
16
+ ).rejects.toThrow(/otherwise is required/);
17
17
  });
18
18
 
19
- test("an HTTP trigger that reviews fails to compile", async () => {
19
+ test("shadow fails to compile as planned", async () => {
20
+ await expect(
21
+ extractFromSources({
22
+ "src/flows/run.ts": `${decider}ai.decision("triage", {\n decider: jev,\n otherwise: "abstain",\n shadow: "other",\n ask: { team: ai.choice("which", { a: "A" }) },\n});\n`,
23
+ }),
24
+ ).rejects.toThrow(/planned/);
25
+ });
26
+
27
+ test("an HTTP trigger that parks fails to compile", async () => {
20
28
  await expect(
21
29
  extractFromSources({
22
30
  "src/flows/run.ts": `
31
+ const jev = ai.decider("jev", { provider: "openrouter", model: "m" });
23
32
  const triage = ai.decision("triage", {
24
- review: "ops",
33
+ decider: jev,
34
+ otherwise: "ops",
25
35
  ask: { team: ai.choice("which", { a: "A" }) },
26
36
  });
27
37
  on(http.post("/t"), flow("run", {
@@ -33,38 +43,52 @@ describe("ai.decision extract", () => {
33
43
  ).rejects.toThrow(/fx\.emit/);
34
44
  });
35
45
 
36
- test("choice, score, and duplicate names fail at compile time", async () => {
37
- const many = Array.from({ length: 255 }, (_, i) => `o${i}: "x"`).join(", ");
46
+ test("a custom decider's max choices fails at compile time", async () => {
38
47
  await expect(
39
48
  extractFromSources({
40
- "src/flows/run.ts": `${header} onUncertain: "abstain",\n ask: { team: ai.choice("which", { ${many} }) },\n});\n`,
49
+ "src/flows/run.ts": `
50
+ const host = ai.decider("host", {
51
+ driverId: "systemone",
52
+ baseUrl: "https://example.test/decisions",
53
+ model: "m",
54
+ secret: "HOST_KEY",
55
+ capabilities: { boolean: true, choice: true, score: true, refusal: false, maxChoices: 1 },
56
+ });
57
+ ai.decision("triage", {
58
+ decider: host,
59
+ otherwise: "abstain",
60
+ ask: { team: ai.choice("which", { a: "A", b: "B" }) },
61
+ });
62
+ `,
41
63
  }),
42
- ).rejects.toThrow(/max 254/);
64
+ ).rejects.toThrow(/allows 1/);
65
+ });
66
+
67
+ test("author none_of_these fails to compile", async () => {
43
68
  await expect(
44
69
  extractFromSources({
45
- "src/flows/run.ts": `${header} onUncertain: "abstain",\n ask: { team: ai.choice("which", { none_of_these: null }) },\n});\n`,
70
+ "src/flows/run.ts": `${decider}ai.decision("triage", {\n decider: jev,\n otherwise: "abstain",\n ask: { team: ai.choice("which", { none_of_these: null }) },\n});\n`,
46
71
  }),
47
72
  ).rejects.toThrow(/none_of_these/);
73
+ });
74
+
75
+ test("duplicate names fail to compile", async () => {
48
76
  await expect(
49
77
  extractFromSources({
50
- "src/flows/run.ts": `${header} onUncertain: "abstain",\n ask: { rank: ai.score("rank", ["only"]) },\n});\n`,
51
- }),
52
- ).rejects.toThrow(/2–10 levels/);
53
- await expect(
54
- extractFromSources({
55
- "src/flows/a.ts": `${header} onUncertain: "abstain",\n ask: { team: ai.choice("which", { a: "A", b: "B" }) },\n});\n`,
56
- "src/flows/b.ts": `${header} onUncertain: "abstain",\n ask: { team: ai.choice("which", { a: "A", b: "B" }) },\n});\n`,
78
+ "src/flows/a.ts": `${decider}ai.decision("triage", {\n decider: jev,\n otherwise: "abstain",\n ask: { team: ai.choice("which", { a: "A", b: "B" }) },\n});\n`,
79
+ "src/flows/b.ts": `${decider}ai.decision("triage", {\n decider: jev,\n otherwise: "abstain",\n ask: { team: ai.choice("which", { a: "A", b: "B" }) },\n});\n`,
57
80
  }),
58
- ).rejects.toThrow(/duplicate decision name/);
81
+ ).rejects.toThrow(/duplicate/);
59
82
  });
60
83
 
61
- test("review records the gate name", async () => {
84
+ test("otherwise resolves a gate and a durable flow parks", async () => {
62
85
  const manifest = await extractFromSources({
63
86
  "src/flows/run.ts": `
64
- const ops = gate.policy("ops", () => true);
87
+ const jev = ai.decider("jev", { provider: "openrouter", model: "m" });
65
88
  const triage = ai.decision("triage", {
66
- review: ops,
67
- ask: { team: ai.choice("which", { a: "A", b: "B" }) },
89
+ decider: jev,
90
+ otherwise: "ops",
91
+ ask: { team: ai.choice("which", { a: "A" }) },
68
92
  });
69
93
  on(signal.once("job"), flow("run", {
70
94
  durable: true,
@@ -72,15 +96,20 @@ describe("ai.decision extract", () => {
72
96
  }));
73
97
  `,
74
98
  });
99
+ expect(manifest.ai?.decisions?.triage?.otherwise).toBe("ops");
75
100
  expect(manifest.ai?.decisions?.triage?.review).toBe("ops");
101
+ expect(manifest.flows?.run?.effects?.secrets).toEqual(["OPENROUTER_API_KEY"]);
102
+ expect(manifest.ai?.deciders?.jev?.pinning).toBe("dated");
76
103
  });
77
104
 
78
- test("review resolves a variable to the gate's declared name", async () => {
105
+ test("otherwise resolves a variable to the gate's declared name", async () => {
79
106
  const manifest = await extractFromSources({
80
107
  "src/flows/run.ts": `
108
+ const jev = ai.decider("jev", { provider: "openrouter", model: "m" });
81
109
  const someVar = gate.policy("ops", () => true);
82
110
  const triage = ai.decision("triage", {
83
- review: someVar,
111
+ decider: jev,
112
+ otherwise: someVar,
84
113
  ask: { team: ai.choice("which", { a: "A", b: "B" }) },
85
114
  });
86
115
  on(signal.once("job"), flow("run", {
@@ -95,8 +124,10 @@ describe("ai.decision extract", () => {
95
124
  test("autonomy and evals survive extraction", async () => {
96
125
  const manifest = await extractFromSources({
97
126
  "src/flows/run.ts": `
127
+ const jev = ai.decider("jev", { provider: "openrouter", model: "m", region: "eu" });
98
128
  const triage = ai.decision("triage", {
99
- onUncertain: "abstain",
129
+ decider: jev,
130
+ otherwise: "abstain",
100
131
  evals: "evals/triage.jsonl",
101
132
  autonomy: { maxError: 0.05, audit: 0.1, risk: 0.1 },
102
133
  ask: { team: ai.choice("which", { a: "A", b: "B" }) },
@@ -109,15 +140,19 @@ describe("ai.decision extract", () => {
109
140
  audit: 0.1,
110
141
  risk: 0.1,
111
142
  });
143
+ expect(manifest.ai?.deciders?.jev?.region).toBe("eu");
144
+ expect(manifest.ai?.deciders?.jev?.regionStatus).toBe("declared");
112
145
  });
113
146
 
114
147
  test("choice options passed through a variable fail to compile", async () => {
115
148
  await expect(
116
149
  extractFromSources({
117
150
  "src/flows/run.ts": `
151
+ const jev = ai.decider("jev", { provider: "openrouter", model: "m" });
118
152
  const options = { a: "A", b: "B" };
119
153
  ai.decision("triage", {
120
- onUncertain: "abstain",
154
+ decider: jev,
155
+ otherwise: "abstain",
121
156
  ask: { team: ai.choice("which", options) },
122
157
  });
123
158
  `,
@@ -128,10 +163,12 @@ describe("ai.decision extract", () => {
128
163
  test("score levels and a whole question resolve a same-file const", async () => {
129
164
  const manifest = await extractFromSources({
130
165
  "src/flows/run.ts": `
166
+ const jev = ai.decider("jev", { provider: "openrouter", model: "m" });
131
167
  const levels = ["low", "high"];
132
168
  const team = ai.choice("which", { a: "A", b: "B" });
133
169
  ai.decision("triage", {
134
- onUncertain: "abstain",
170
+ decider: jev,
171
+ otherwise: "abstain",
135
172
  ask: { team, rank: ai.score("rank", levels) },
136
173
  });
137
174
  `,
@@ -143,8 +180,10 @@ describe("ai.decision extract", () => {
143
180
  await expect(
144
181
  extractFromSources({
145
182
  "src/flows/run.ts": `
183
+ const jev = ai.decider("jev", { provider: "openrouter", model: "m" });
146
184
  ai.decision("triage", {
147
- onUncertain: "abstain",
185
+ decider: jev,
186
+ otherwise: "abstain",
148
187
  ask: { rank: ai.score("rank", missing) },
149
188
  });
150
189
  `,
@@ -153,12 +192,28 @@ describe("ai.decision extract", () => {
153
192
  await expect(
154
193
  extractFromSources({
155
194
  "src/flows/run.ts": `
195
+ const jev = ai.decider("jev", { provider: "openrouter", model: "m" });
156
196
  ai.decision("triage", {
157
- onUncertain: "abstain",
197
+ decider: jev,
198
+ otherwise: "abstain",
158
199
  ask: { team: missingQuestion },
159
200
  });
160
201
  `,
161
202
  }),
162
203
  ).rejects.toThrow(/not a same-file const/);
163
204
  });
205
+
206
+ test("a string passed as decider fails to compile", async () => {
207
+ await expect(
208
+ extractFromSources({
209
+ "src/flows/run.ts": `
210
+ ai.decision("triage", {
211
+ decider: "jev",
212
+ otherwise: "abstain",
213
+ ask: { team: ai.choice("which", { a: "A" }) },
214
+ });
215
+ `,
216
+ }),
217
+ ).rejects.toThrow(/not a decider/);
218
+ });
164
219
  });
@@ -63,6 +63,7 @@ export interface InferBinding {
63
63
  | "flow"
64
64
  | "embed"
65
65
  | "decision"
66
+ | "decider"
66
67
  | "table"
67
68
  | "mcp-server"
68
69
  | "mcp-tool"