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.
- package/AGENTS.md +5 -1
- package/manifest.v1.schema.json +53 -3
- package/package.json +2 -1
- package/site/content/docs/ai/mcp.mdx +1 -1
- package/site/content/docs/ai/skills.mdx +1 -1
- package/site/content/docs/elements/ai/decide.mdx +95 -417
- package/site/content/docs/elements/ai/deciders.mdx +154 -0
- package/site/content/docs/elements/ai/index.mdx +2 -1
- package/site/content/docs/elements/ai/meta.json +1 -1
- package/site/content/docs/reference/cli.mdx +2 -2
- package/site/content/docs/reference/configuration.mdx +2 -1
- package/site/content/docs/reference/fx.mdx +11 -9
- package/src/cli/agents-md.test.ts +8 -2
- package/src/cli/decide-certify.test.ts +166 -0
- package/src/cli/decide.ts +170 -3
- package/src/cli/decision-lock-watch.test.ts +4 -4
- package/src/cli/eval.ts +38 -59
- package/src/cli/mcp-from-console.ts +1 -1
- package/src/cli/registry.ts +45 -9
- package/src/compiler/decisions.extract.test.ts +86 -31
- package/src/compiler/effects-infer.ts +1 -0
- package/src/compiler/extract.ts +239 -24
- package/src/config/index.ts +6 -0
- package/src/console/server/decisions.test.ts +6 -1
- package/src/console/server/decisions.ts +32 -1
- package/src/console/ui-next/dist/assets/{access-page-zBMhZnFm.js → access-page-DX2dKLmq.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-CBj9f7Ju.js → agent-disclosure-CiRvA3M9.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-D3MY49h3.js → cache-glyph-CC1yFJ8A.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-L9JxdBhp.js → call-pii-button-CLXQgIDD.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BvFaX-Zt.js → collapsible-C0AThBZg.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-DgB76m-X.js → decisions-page-Bf3AhTAa.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-l1DSJ2Vz.js → duration-tone-Bptur2gX.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-Bb9t1lu6.js → flows-page-DRZVeTZU.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-DMvsi3Ti.js → highlighted-json-BKf0PjBK.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-BwhJBTcQ.js → http-method-uhrnWrGC.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-RhV2jT_7.js → index-Duxus_sG.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-BRfUILBq.js → observability-page-B-QM8X0O.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-BsceAzIG.js → replica-lag-DuqHCANi.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-C6lTyCXp.js → request-meta-BTBZ3Ulc.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-YmE806bJ.js → store-page-fpo7VxYb.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-Di4irS49.js → trace-detail-sheet-DKtr1-K1.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-DYeL6Rye.js → tree-expand-toggle-QeTceKKI.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-07agasRA.js → units-page-ChCp8mOy.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-CHBck4n_.js → vault-page-CEYLXhUX.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/client.ts +8 -0
- package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +16 -0
- package/src/elements/ai/deciders/presets.ts +182 -0
- package/src/elements/ai/decisions/bind.ts +6 -5
- package/src/elements/ai/decisions/catalog.ts +93 -0
- package/src/elements/ai/decisions/certificate.test.ts +47 -19
- package/src/elements/ai/decisions/certificate.ts +107 -24
- package/src/elements/ai/decisions/certify.ts +104 -9
- package/src/elements/ai/decisions/codec.test.ts +142 -0
- package/src/elements/ai/decisions/codec.ts +302 -0
- package/src/elements/ai/decisions/decide.test.ts +108 -31
- package/src/elements/ai/decisions/decider-runtime.test.ts +415 -0
- package/src/elements/ai/decisions/decider.types.test.ts +62 -0
- package/src/elements/ai/decisions/e2e.test.ts +20 -16
- package/src/elements/ai/decisions/http.ts +15 -7
- package/src/elements/ai/decisions/labels.test.ts +6 -1
- package/src/elements/ai/decisions/labels.ts +4 -3
- package/src/elements/ai/decisions/openai.live.test.ts +42 -0
- package/src/elements/ai/decisions/openrouter.live.test.ts +5 -1
- package/src/elements/ai/decisions/provider.ts +22 -1
- package/src/elements/ai/declare.ts +208 -51
- package/src/elements/ai.ts +1 -0
- package/src/index.ts +1 -0
- package/src/kernel/app.ts +8 -0
- package/src/kernel/decision-budget-entry.ts +11 -0
- package/src/kernel/element-registries.ts +3 -0
- package/src/kernel/fx-decide.ts +276 -88
- package/src/manifest/types.ts +38 -2
- package/src/release/measure.ts +47 -8
- package/src/test/create-test-app.ts +21 -1
package/src/kernel/fx-decide.ts
CHANGED
|
@@ -2,24 +2,23 @@
|
|
|
2
2
|
* `fx.decide` — one provider request, journaled. Loaded with `lazyRequire`.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { aiDecisionRegistry, gateRegistry } from "./element-registries.ts";
|
|
5
|
+
import { aiDeciderRegistry, aiDecisionRegistry, gateRegistry } from "./element-registries.ts";
|
|
6
6
|
import type { GatePolicyContext } from "../elements/gate/declare.ts";
|
|
7
|
-
import type { AiDecisionDecl, AiDecisionQuestion } from "../elements/ai/declare.ts";
|
|
7
|
+
import type { AiDeciderDecl, AiDecisionDecl, AiDecisionQuestion } from "../elements/ai/declare.ts";
|
|
8
8
|
import {
|
|
9
9
|
DecisionConfigError,
|
|
10
|
+
DecisionInputTooLarge,
|
|
10
11
|
DecisionOutageError,
|
|
11
12
|
type DecisionResponse,
|
|
12
|
-
type WireQuestion,
|
|
13
13
|
} from "../elements/ai/decisions/provider.ts";
|
|
14
14
|
import { parseDurationMs } from "../elements/clock/duration.ts";
|
|
15
|
+
import { decisionHttp } from "../elements/ai/decisions/http.ts";
|
|
15
16
|
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
TYPESAFE_JEV_MODEL,
|
|
22
|
-
} from "../elements/ai/decisions/typesafe.ts";
|
|
17
|
+
encodeDecisionRequest,
|
|
18
|
+
estimateDecisionTokens,
|
|
19
|
+
normalizeDecisionResponse,
|
|
20
|
+
wireQuestions,
|
|
21
|
+
} from "../elements/ai/decisions/codec.ts";
|
|
23
22
|
import {
|
|
24
23
|
applyTemperature,
|
|
25
24
|
calibrateBoolean,
|
|
@@ -28,7 +27,8 @@ import {
|
|
|
28
27
|
questionHash,
|
|
29
28
|
recordDecisionLabel,
|
|
30
29
|
type DecisionCertSlice,
|
|
31
|
-
type
|
|
30
|
+
type DecisionDeciderCert,
|
|
31
|
+
type DecisionWhy,
|
|
32
32
|
} from "../elements/ai/decisions/certificate.ts";
|
|
33
33
|
import { flushDecisionLabels, persistDecisionLabel } from "../elements/ai/decisions/labels.ts";
|
|
34
34
|
import { decisionExportFields, maskDecisionInput } from "../elements/ai/decisions/export.ts";
|
|
@@ -96,7 +96,6 @@ export interface DecisionReviewRecord {
|
|
|
96
96
|
readonly labelOnly: boolean;
|
|
97
97
|
readonly values?: Readonly<Record<string, unknown>>;
|
|
98
98
|
readonly reviewer?: string;
|
|
99
|
-
readonly reason?: DecisionUncertainty;
|
|
100
99
|
/** Question ids that are not auto. Resolve must answer each of them. */
|
|
101
100
|
readonly open?: readonly string[];
|
|
102
101
|
readonly model?: string;
|
|
@@ -105,6 +104,9 @@ export interface DecisionReviewRecord {
|
|
|
105
104
|
readonly modelValues?: Readonly<Record<string, unknown>>;
|
|
106
105
|
/** Calling input. Label export reads this. */
|
|
107
106
|
readonly input?: unknown;
|
|
107
|
+
readonly why?: DecisionWhy;
|
|
108
|
+
/** Decider that answered, when one did. */
|
|
109
|
+
readonly by?: string;
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
/** Result of {@link resolveDecisionReview}. */
|
|
@@ -126,10 +128,21 @@ type ProviderFn = (
|
|
|
126
128
|
decl: AiDecisionDecl,
|
|
127
129
|
input: unknown,
|
|
128
130
|
signal: AbortSignal,
|
|
131
|
+
decider: AiDeciderDecl,
|
|
129
132
|
) => Promise<DecisionResponse>;
|
|
130
133
|
|
|
134
|
+
/** Scripted probabilities, or a refusal signal that is not stored as a label. */
|
|
135
|
+
export type DecisionScriptAnswer =
|
|
136
|
+
| { readonly probabilities: Readonly<Record<string, number>> }
|
|
137
|
+
| { readonly probability: number }
|
|
138
|
+
| { readonly refusal: string };
|
|
139
|
+
|
|
131
140
|
let providerOverride: ProviderFn | undefined;
|
|
132
141
|
let providerCalls = 0;
|
|
142
|
+
let decisionTransport: "live" | "mock" = "live";
|
|
143
|
+
const decisionScripts = new Map<string, Readonly<Record<string, DecisionScriptAnswer>>>();
|
|
144
|
+
const inflight = new Map<string, number>();
|
|
145
|
+
const waiters = new Map<string, Array<() => void>>();
|
|
133
146
|
|
|
134
147
|
/**
|
|
135
148
|
* Replace the provider. Tests use this so no key is required.
|
|
@@ -153,6 +166,33 @@ export function decisionProviderCalls(): number {
|
|
|
153
166
|
export function resetDecisionProvider(): void {
|
|
154
167
|
providerOverride = undefined;
|
|
155
168
|
providerCalls = 0;
|
|
169
|
+
decisionTransport = "live";
|
|
170
|
+
decisionScripts.clear();
|
|
171
|
+
inflight.clear();
|
|
172
|
+
waiters.clear();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* `mock` answers locally. `live` calls the decider host.
|
|
177
|
+
*
|
|
178
|
+
* @param transport - Boot selection from `drivers.decide`
|
|
179
|
+
*/
|
|
180
|
+
export function setDecisionTransport(transport: "live" | "mock"): void {
|
|
181
|
+
decisionTransport = transport;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Script one decision. `{ refusal }` is the test signal; the string is not stored.
|
|
186
|
+
*
|
|
187
|
+
* @param name - Decision name
|
|
188
|
+
* @param answers - Per-question script, or undefined to clear
|
|
189
|
+
*/
|
|
190
|
+
export function setDecisionScript(
|
|
191
|
+
name: string,
|
|
192
|
+
answers: Readonly<Record<string, DecisionScriptAnswer>> | undefined,
|
|
193
|
+
): void {
|
|
194
|
+
if (!answers) decisionScripts.delete(name);
|
|
195
|
+
else decisionScripts.set(name, answers);
|
|
156
196
|
}
|
|
157
197
|
|
|
158
198
|
/**
|
|
@@ -187,9 +227,9 @@ async function executeDecide(options: FxDecideInput, name: string): Promise<unkn
|
|
|
187
227
|
: await callAndDraw(decl, options, ordinal);
|
|
188
228
|
const view = options.journal
|
|
189
229
|
? ((await options.journal.effect("decide-view", slot, () =>
|
|
190
|
-
project(decl, options.input, recorded),
|
|
230
|
+
project(decl, options.input, recorded, options.now()),
|
|
191
231
|
)) as JournaledView)
|
|
192
|
-
: project(decl, options.input, recorded);
|
|
232
|
+
: project(decl, options.input, recorded, options.now());
|
|
193
233
|
if (view.auto) {
|
|
194
234
|
if (view.audited && options.journal) {
|
|
195
235
|
const id = reviewId(options.journal.runId, ordinal, name);
|
|
@@ -238,6 +278,7 @@ interface RecordedCall {
|
|
|
238
278
|
readonly outage: boolean;
|
|
239
279
|
readonly audited: boolean;
|
|
240
280
|
readonly propensity: number;
|
|
281
|
+
readonly by?: string;
|
|
241
282
|
}
|
|
242
283
|
|
|
243
284
|
async function callAndDraw(
|
|
@@ -250,8 +291,14 @@ async function callAndDraw(
|
|
|
250
291
|
const draw = auditDraw(`${run}:${decl.name}#${ordinal}`, rate);
|
|
251
292
|
try {
|
|
252
293
|
providerCalls += 1;
|
|
253
|
-
const
|
|
254
|
-
return {
|
|
294
|
+
const called = await callProvider(decl, options);
|
|
295
|
+
return {
|
|
296
|
+
response: called.response,
|
|
297
|
+
by: called.by,
|
|
298
|
+
outage: false,
|
|
299
|
+
audited: draw.audited,
|
|
300
|
+
propensity: draw.propensity,
|
|
301
|
+
};
|
|
255
302
|
} catch (err) {
|
|
256
303
|
if (err instanceof DecisionOutageError) {
|
|
257
304
|
return { outage: true, audited: false, propensity: draw.propensity };
|
|
@@ -263,59 +310,166 @@ async function callAndDraw(
|
|
|
263
310
|
async function callProvider(
|
|
264
311
|
decl: AiDecisionDecl,
|
|
265
312
|
options: FxDecideInput,
|
|
313
|
+
): Promise<{ readonly response: DecisionResponse; readonly by: string }> {
|
|
314
|
+
const chain = deciderChain(decl);
|
|
315
|
+
let last: unknown;
|
|
316
|
+
for (let i = 0; i < chain.length; i += 1) {
|
|
317
|
+
const decider = chain[i];
|
|
318
|
+
if (!decider) continue;
|
|
319
|
+
assertDecisionContext(decider, options.input);
|
|
320
|
+
try {
|
|
321
|
+
const response = await withDeciderConcurrency(decider, () => callOne(decl, decider, options));
|
|
322
|
+
return { response, by: decider.name };
|
|
323
|
+
} catch (err) {
|
|
324
|
+
if (!(err instanceof DecisionOutageError) || i === chain.length - 1) throw err;
|
|
325
|
+
last = err;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
throw last instanceof Error ? last : new DecisionOutageError("decision backup exhausted");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function deciderChain(decl: AiDecisionDecl): AiDeciderDecl[] {
|
|
332
|
+
const names = [decl.decider, ...decl.backup];
|
|
333
|
+
return names.map((name) => {
|
|
334
|
+
const decider = aiDeciderRegistry.find((item) => item.name === name);
|
|
335
|
+
if (!decider) throw new Error(`fx.decide: unknown decider "${name}"`);
|
|
336
|
+
return decider;
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function assertDecisionContext(decider: AiDeciderDecl, input: unknown): void {
|
|
341
|
+
const max = decider.capabilities.maxContext;
|
|
342
|
+
if (typeof max !== "number") return;
|
|
343
|
+
const tokens = estimateDecisionTokens(input);
|
|
344
|
+
if (tokens > max) throw new DecisionInputTooLarge(decider.name, tokens, max);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function withDeciderConcurrency<T>(
|
|
348
|
+
decider: AiDeciderDecl,
|
|
349
|
+
body: () => Promise<T>,
|
|
350
|
+
): Promise<T> {
|
|
351
|
+
const limit = decider.concurrency;
|
|
352
|
+
if (limit === undefined || limit < 1) return body();
|
|
353
|
+
while ((inflight.get(decider.name) ?? 0) >= limit) {
|
|
354
|
+
await new Promise<void>((resolve) => {
|
|
355
|
+
const queue = waiters.get(decider.name) ?? [];
|
|
356
|
+
queue.push(resolve);
|
|
357
|
+
waiters.set(decider.name, queue);
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
inflight.set(decider.name, (inflight.get(decider.name) ?? 0) + 1);
|
|
361
|
+
try {
|
|
362
|
+
return await body();
|
|
363
|
+
} finally {
|
|
364
|
+
inflight.set(decider.name, Math.max(0, (inflight.get(decider.name) ?? 1) - 1));
|
|
365
|
+
waiters.get(decider.name)?.shift()?.();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function callOne(
|
|
370
|
+
decl: AiDecisionDecl,
|
|
371
|
+
decider: AiDeciderDecl,
|
|
372
|
+
options: FxDecideInput,
|
|
266
373
|
): Promise<DecisionResponse> {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
model,
|
|
374
|
+
const script = decisionScripts.get(decl.name);
|
|
375
|
+
if (script && decider.name === decl.decider) return scriptedResponse(decl, decider, script);
|
|
376
|
+
if (providerOverride) {
|
|
377
|
+
return normalizeDecisionResponse(
|
|
378
|
+
decider.protocol,
|
|
379
|
+
await providerOverride(decl, options.input, options.signal, decider),
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
if (decisionTransport === "mock") return mockResponse(decl, decider);
|
|
383
|
+
const apiKey = await options.getSecret?.(decider.secret);
|
|
384
|
+
if (!apiKey) throw new DecisionConfigError(decider.secret);
|
|
385
|
+
const request = {
|
|
386
|
+
model: decider.model,
|
|
280
387
|
state: options.input,
|
|
281
|
-
questions: wireQuestions(decl),
|
|
388
|
+
questions: wireQuestions(decl.ask),
|
|
282
389
|
signal: options.signal,
|
|
390
|
+
};
|
|
391
|
+
const raw = await decisionHttp({
|
|
392
|
+
url: decider.baseUrl,
|
|
393
|
+
apiKey,
|
|
394
|
+
request,
|
|
395
|
+
body: encodeDecisionRequest(decider.protocol, request),
|
|
396
|
+
timeoutMs: decisionTimeoutMs(decider.timeout),
|
|
397
|
+
breakerKey: decider.name,
|
|
283
398
|
});
|
|
399
|
+
return normalizeDecisionResponse(decider.protocol, raw);
|
|
284
400
|
}
|
|
285
401
|
|
|
286
|
-
function
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
402
|
+
function scriptedResponse(
|
|
403
|
+
decl: AiDecisionDecl,
|
|
404
|
+
decider: AiDeciderDecl,
|
|
405
|
+
script: Readonly<Record<string, DecisionScriptAnswer>>,
|
|
406
|
+
): DecisionResponse {
|
|
407
|
+
const answers: Record<string, unknown> = {};
|
|
408
|
+
for (const [id, question] of Object.entries(decl.ask)) {
|
|
409
|
+
const row = script[id];
|
|
410
|
+
if (!row) {
|
|
411
|
+
answers[id] = { type: "malformed" };
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if ("refusal" in row) {
|
|
415
|
+
answers[id] = { type: "refusal" };
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if ("probability" in row) {
|
|
419
|
+
answers[id] = { type: "boolean", probability: row.probability };
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (question.kind === "boolean") {
|
|
423
|
+
const probability = row.probabilities.true ?? row.probabilities.yes ?? 0;
|
|
424
|
+
answers[id] = { type: "boolean", probability };
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const choice = argmaxKey(row.probabilities);
|
|
428
|
+
answers[id] =
|
|
429
|
+
question.kind === "score"
|
|
430
|
+
? { type: "score", score: choice, probabilities: row.probabilities }
|
|
431
|
+
: { type: "choice", choice, probabilities: row.probabilities };
|
|
291
432
|
}
|
|
292
|
-
return
|
|
433
|
+
return { model: decider.model, provider: "script", answers, usage: {} };
|
|
293
434
|
}
|
|
294
435
|
|
|
295
|
-
function
|
|
296
|
-
const
|
|
436
|
+
function mockResponse(decl: AiDecisionDecl, decider: AiDeciderDecl): DecisionResponse {
|
|
437
|
+
const answers: Record<string, unknown> = {};
|
|
297
438
|
for (const [id, question] of Object.entries(decl.ask)) {
|
|
298
|
-
|
|
439
|
+
if (question.kind === "boolean") answers[id] = { type: "boolean", probability: 0.5 };
|
|
440
|
+
else if (question.kind === "choice") {
|
|
441
|
+
answers[id] = {
|
|
442
|
+
type: "choice",
|
|
443
|
+
choice: "none_of_these",
|
|
444
|
+
probabilities: { none_of_these: 1 },
|
|
445
|
+
};
|
|
446
|
+
} else {
|
|
447
|
+
const level = question.levels[0] ?? "";
|
|
448
|
+
answers[id] = { type: "score", score: level, probabilities: { [level]: 1 } };
|
|
449
|
+
}
|
|
299
450
|
}
|
|
300
|
-
return
|
|
451
|
+
return { model: decider.model, provider: "mock", answers, usage: {} };
|
|
301
452
|
}
|
|
302
453
|
|
|
303
|
-
function
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
454
|
+
function argmaxKey(probabilities: Readonly<Record<string, number>>): string {
|
|
455
|
+
let best = "";
|
|
456
|
+
let score = -1;
|
|
457
|
+
for (const [key, value] of Object.entries(probabilities)) {
|
|
458
|
+
if (value > score) {
|
|
459
|
+
best = key;
|
|
460
|
+
score = value;
|
|
461
|
+
}
|
|
310
462
|
}
|
|
311
|
-
|
|
312
|
-
|
|
463
|
+
return best;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function decisionTimeoutMs(timeout: AiDeciderDecl["timeout"]): number {
|
|
467
|
+
if (typeof timeout === "number" && timeout > 0) return timeout;
|
|
468
|
+
if (typeof timeout === "string") {
|
|
469
|
+
const parsed = parseDurationMs(timeout);
|
|
470
|
+
if (parsed > 0) return parsed;
|
|
313
471
|
}
|
|
314
|
-
return
|
|
315
|
-
type: "noul",
|
|
316
|
-
instructions: question.instructions,
|
|
317
|
-
...(question.criteria !== undefined ? { criteria: question.criteria } : {}),
|
|
318
|
-
};
|
|
472
|
+
return 30_000;
|
|
319
473
|
}
|
|
320
474
|
|
|
321
475
|
/** One question as journaled. Replay returns this; it is not re-projected. */
|
|
@@ -327,12 +481,14 @@ interface JournaledQuestion {
|
|
|
327
481
|
readonly audited?: boolean;
|
|
328
482
|
/** True when this question did not clear autonomy. */
|
|
329
483
|
readonly uncertain: boolean;
|
|
484
|
+
readonly why?: DecisionWhy;
|
|
330
485
|
}
|
|
331
486
|
|
|
332
487
|
/** Projection stored on the journal. The live lock is not consulted on replay. */
|
|
333
488
|
interface JournaledView {
|
|
334
489
|
readonly auto: boolean;
|
|
335
|
-
readonly
|
|
490
|
+
readonly why?: DecisionWhy;
|
|
491
|
+
readonly by?: string;
|
|
336
492
|
readonly locale?: string;
|
|
337
493
|
readonly lockModel?: string;
|
|
338
494
|
readonly audited: boolean;
|
|
@@ -345,57 +501,67 @@ interface JournaledView {
|
|
|
345
501
|
};
|
|
346
502
|
}
|
|
347
503
|
|
|
348
|
-
function project(
|
|
504
|
+
function project(
|
|
505
|
+
decl: AiDecisionDecl,
|
|
506
|
+
input: unknown,
|
|
507
|
+
recorded: RecordedCall,
|
|
508
|
+
now: number,
|
|
509
|
+
): JournaledView {
|
|
349
510
|
const locale = decl.locale?.(input);
|
|
350
|
-
|
|
351
|
-
|
|
511
|
+
const drifted = decisionDriftSuspended(decl.name);
|
|
512
|
+
const slot = recorded.by
|
|
513
|
+
? getDecisionLock()?.decisions[decl.name]?.deciders[recorded.by]
|
|
514
|
+
: undefined;
|
|
515
|
+
const expired = slot?.pinned === false && slot.expiresAt !== undefined && now >= slot.expiresAt;
|
|
516
|
+
const mismatched = slot !== undefined && slot.model !== recorded.response?.model;
|
|
517
|
+
const lock = slot && !expired && !mismatched ? slot : undefined;
|
|
518
|
+
let blocked: DecisionWhy | undefined = recorded.outage
|
|
352
519
|
? "outage"
|
|
353
|
-
:
|
|
520
|
+
: drifted
|
|
354
521
|
? "drift"
|
|
355
|
-
:
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
if (!lock || !decl.autonomy) {
|
|
359
|
-
auto = false;
|
|
360
|
-
reason = "missing-lock";
|
|
361
|
-
} else if (lock.model !== recorded.response?.model) {
|
|
362
|
-
auto = false;
|
|
363
|
-
reason = "version-mismatch";
|
|
364
|
-
}
|
|
365
|
-
}
|
|
522
|
+
: !decl.autonomy || !lock
|
|
523
|
+
? "uncertified"
|
|
524
|
+
: undefined;
|
|
366
525
|
const questions: Record<string, JournaledQuestion> = {};
|
|
526
|
+
let auto = blocked === undefined;
|
|
367
527
|
for (const [id, question] of Object.entries(decl.ask)) {
|
|
368
528
|
const slice = sliceFor(lock, id, locale);
|
|
369
|
-
const answer = recorded.response?.answers
|
|
529
|
+
const answer = answerAt(recorded.response?.answers, id);
|
|
370
530
|
const calibrated = calibrateAnswer(question, answer, slice);
|
|
531
|
+
let why: DecisionWhy | undefined = blocked;
|
|
371
532
|
let questionAuto = auto && slice !== undefined && calibrated.p >= (slice?.threshold ?? 1);
|
|
372
|
-
if (calibrated.
|
|
533
|
+
if (calibrated.refused) {
|
|
373
534
|
questionAuto = false;
|
|
374
|
-
|
|
375
|
-
} else if (
|
|
535
|
+
why = "refused";
|
|
536
|
+
} else if (calibrated.value === "none_of_these") {
|
|
376
537
|
questionAuto = false;
|
|
377
|
-
|
|
378
|
-
} else if (slice
|
|
538
|
+
why = why ?? "none_of_these";
|
|
539
|
+
} else if (!slice || questionHash(question) !== slice.hash) {
|
|
379
540
|
questionAuto = false;
|
|
380
|
-
|
|
381
|
-
} else if (
|
|
541
|
+
why = why ?? "uncertified";
|
|
542
|
+
} else if (calibrated.p < slice.threshold) {
|
|
382
543
|
questionAuto = false;
|
|
383
|
-
|
|
544
|
+
why = why ?? "uncertain";
|
|
384
545
|
}
|
|
385
546
|
if (!questionAuto) auto = false;
|
|
386
547
|
const how: DecisionHow = questionAuto ? "auto" : decl.mode === "abstain" ? "abstained" : "auto";
|
|
548
|
+
const value =
|
|
549
|
+
calibrated.refused || (!questionAuto && decl.mode === "abstain") ? null : calibrated.value;
|
|
387
550
|
questions[id] = {
|
|
388
|
-
value
|
|
551
|
+
value,
|
|
389
552
|
how,
|
|
390
553
|
p: calibrated.p,
|
|
391
554
|
raw: calibrated.raw,
|
|
392
555
|
uncertain: !questionAuto,
|
|
556
|
+
...(!questionAuto && why ? { why } : {}),
|
|
393
557
|
...(recorded.audited && questionAuto ? { audited: true } : {}),
|
|
394
558
|
};
|
|
395
559
|
}
|
|
560
|
+
const why = Object.values(questions).find((question) => question.why)?.why;
|
|
396
561
|
return {
|
|
397
562
|
auto,
|
|
398
|
-
...(
|
|
563
|
+
...(why !== undefined ? { why } : {}),
|
|
564
|
+
...(recorded.by !== undefined ? { by: recorded.by } : {}),
|
|
399
565
|
...(locale !== undefined ? { locale } : {}),
|
|
400
566
|
...(lock?.model !== undefined ? { lockModel: lock.model } : {}),
|
|
401
567
|
audited: recorded.audited,
|
|
@@ -403,12 +569,17 @@ function project(decl: AiDecisionDecl, input: unknown, recorded: RecordedCall):
|
|
|
403
569
|
questions,
|
|
404
570
|
meta: {
|
|
405
571
|
model: recorded.response?.model,
|
|
406
|
-
provider: recorded.response?.provider ??
|
|
572
|
+
provider: recorded.response?.provider ?? recorded.by ?? "",
|
|
407
573
|
usage: recorded.response?.usage ?? {},
|
|
408
574
|
},
|
|
409
575
|
};
|
|
410
576
|
}
|
|
411
577
|
|
|
578
|
+
function answerAt(answers: DecisionResponse["answers"] | undefined, id: string): unknown {
|
|
579
|
+
if (!answers || Array.isArray(answers)) return undefined;
|
|
580
|
+
return (answers as Readonly<Record<string, unknown>>)[id];
|
|
581
|
+
}
|
|
582
|
+
|
|
412
583
|
function materialize(view: JournaledView): Record<string, unknown> {
|
|
413
584
|
const dollar: Record<string, unknown> = { meta: view.meta };
|
|
414
585
|
const result: Record<string, unknown> = { $: dollar };
|
|
@@ -418,6 +589,8 @@ function materialize(view: JournaledView): Record<string, unknown> {
|
|
|
418
589
|
how: question.how,
|
|
419
590
|
p: question.p,
|
|
420
591
|
raw: question.raw,
|
|
592
|
+
...(view.by !== undefined ? { by: view.by } : {}),
|
|
593
|
+
...(question.why !== undefined ? { why: question.why } : {}),
|
|
421
594
|
...(question.audited ? { audited: true } : {}),
|
|
422
595
|
};
|
|
423
596
|
}
|
|
@@ -425,9 +598,7 @@ function materialize(view: JournaledView): Record<string, unknown> {
|
|
|
425
598
|
}
|
|
426
599
|
|
|
427
600
|
function sliceFor(
|
|
428
|
-
lock:
|
|
429
|
-
| { readonly questions: Readonly<Record<string, Readonly<Record<string, DecisionCertSlice>>>> }
|
|
430
|
-
| undefined,
|
|
601
|
+
lock: DecisionDeciderCert | undefined,
|
|
431
602
|
question: string,
|
|
432
603
|
locale: string | undefined,
|
|
433
604
|
): DecisionCertSlice | undefined {
|
|
@@ -441,10 +612,26 @@ function calibrateAnswer(
|
|
|
441
612
|
question: AiDecisionQuestion,
|
|
442
613
|
answer: unknown,
|
|
443
614
|
slice: DecisionCertSlice | undefined,
|
|
444
|
-
): {
|
|
615
|
+
): {
|
|
616
|
+
readonly value: unknown;
|
|
617
|
+
readonly p: number;
|
|
618
|
+
readonly raw: unknown;
|
|
619
|
+
readonly refused?: boolean;
|
|
620
|
+
} {
|
|
445
621
|
const record = answer && typeof answer === "object" ? (answer as Record<string, unknown>) : {};
|
|
622
|
+
if (record.type === "refusal") {
|
|
623
|
+
return { value: null, p: 0, raw: { type: "refusal" }, refused: true };
|
|
624
|
+
}
|
|
625
|
+
if (record.type === "malformed") {
|
|
626
|
+
return { value: null, p: 0, raw: { type: "malformed" } };
|
|
627
|
+
}
|
|
446
628
|
if (question.kind === "boolean") {
|
|
447
|
-
const noul =
|
|
629
|
+
const noul =
|
|
630
|
+
typeof record.probability === "number"
|
|
631
|
+
? record.probability
|
|
632
|
+
: typeof record.noul === "number"
|
|
633
|
+
? record.noul
|
|
634
|
+
: 0;
|
|
448
635
|
const calibrator =
|
|
449
636
|
slice?.calibrator.kind === "platt" || slice?.calibrator.kind === "beta"
|
|
450
637
|
? slice.calibrator
|
|
@@ -538,7 +725,8 @@ function pendingRecord(
|
|
|
538
725
|
modelValues: Object.fromEntries(
|
|
539
726
|
Object.entries(view.questions).map(([id, question]) => [id, question.value]),
|
|
540
727
|
),
|
|
541
|
-
...(view.
|
|
728
|
+
...(view.why !== undefined ? { why: view.why } : {}),
|
|
729
|
+
...(view.by !== undefined ? { by: view.by } : {}),
|
|
542
730
|
input: maskDecisionInput(options.input, decisionExportFields(decl.inputSchema)),
|
|
543
731
|
};
|
|
544
732
|
}
|
package/src/manifest/types.ts
CHANGED
|
@@ -524,11 +524,42 @@ export interface AiMcpServer {
|
|
|
524
524
|
tools: string[];
|
|
525
525
|
}
|
|
526
526
|
|
|
527
|
+
/** What one decider can answer. Numeric limits apply only when present. */
|
|
528
|
+
export interface AiDeciderCapabilities {
|
|
529
|
+
boolean: boolean;
|
|
530
|
+
choice: boolean;
|
|
531
|
+
score: boolean;
|
|
532
|
+
refusal: boolean;
|
|
533
|
+
maxChoices?: number;
|
|
534
|
+
minLevels?: number;
|
|
535
|
+
maxLevels?: number;
|
|
536
|
+
maxContext?: number;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** One model that can answer a decision. */
|
|
540
|
+
export interface AiDecider {
|
|
541
|
+
provider?: string;
|
|
542
|
+
driverId: "systemone" | "openai-decisions";
|
|
543
|
+
baseUrl: string;
|
|
544
|
+
model: string;
|
|
545
|
+
secret: string;
|
|
546
|
+
pinning: "dated" | "alias";
|
|
547
|
+
capabilities: AiDeciderCapabilities;
|
|
548
|
+
/** App-supplied. Presets have no verified region. */
|
|
549
|
+
region?: string;
|
|
550
|
+
regionStatus?: "declared" | "verified";
|
|
551
|
+
zdr?: boolean;
|
|
552
|
+
zdrStatus?: "declared" | "verified";
|
|
553
|
+
timeout?: number | string;
|
|
554
|
+
concurrency?: number;
|
|
555
|
+
}
|
|
556
|
+
|
|
527
557
|
/** AI element catalogue. */
|
|
528
558
|
export interface Ai {
|
|
529
559
|
models?: Record<string, AiModel>;
|
|
530
560
|
prompts?: Record<string, AiPrompt>;
|
|
531
561
|
agents?: Record<string, AiAgent>;
|
|
562
|
+
deciders?: Record<string, AiDecider>;
|
|
532
563
|
decisions?: Record<string, AiDecision>;
|
|
533
564
|
mcpServers?: Record<string, AiMcpServer>;
|
|
534
565
|
}
|
|
@@ -536,10 +567,15 @@ export interface Ai {
|
|
|
536
567
|
/** A declared decision. The predicate and locale function stay in author code. */
|
|
537
568
|
export interface AiDecision {
|
|
538
569
|
mode: "review" | "abstain";
|
|
570
|
+
/** Gate name when `otherwise` is a gate. */
|
|
539
571
|
review?: string;
|
|
540
|
-
|
|
541
|
-
|
|
572
|
+
decider: string;
|
|
573
|
+
backup?: string[];
|
|
574
|
+
/** `"abstain"` or the gate name. */
|
|
575
|
+
otherwise: string;
|
|
542
576
|
questions: string[];
|
|
577
|
+
/** Secret contracts inferred from the decider chain. */
|
|
578
|
+
secrets?: string[];
|
|
543
579
|
evals?: string;
|
|
544
580
|
autonomy?: { maxError: number; audit: number; risk?: number };
|
|
545
581
|
}
|