okengine 0.22.0 → 0.23.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 +7 -0
- package/manifest.v1.schema.json +44 -0
- package/package.json +32 -19
- package/site/content/docs/ai/mcp.mdx +13 -9
- package/site/content/docs/ai/skills.mdx +1 -1
- package/site/content/docs/elements/ai/agents.mdx +61 -13
- package/site/content/docs/elements/ai/decide.mdx +536 -0
- package/site/content/docs/elements/ai/events.mdx +373 -0
- package/site/content/docs/elements/ai/index.mdx +9 -3
- package/site/content/docs/elements/ai/meta.json +1 -1
- package/site/content/docs/elements/ai/prompts.mdx +4 -2
- package/site/content/docs/elements/gate/auth.mdx +4 -1
- package/site/content/docs/elements/store/index.mdx +1 -1
- package/site/content/docs/elements/store/kv.mdx +5 -5
- package/site/content/docs/recipes/dragonfly.mdx +5 -5
- package/site/content/docs/recipes/index.mdx +6 -6
- package/site/content/docs/recipes/postgres.mdx +12 -8
- package/site/content/docs/recipes/redis.mdx +4 -4
- package/site/content/docs/reference/cli.mdx +2 -1
- package/site/content/docs/reference/configuration.mdx +9 -7
- package/site/content/docs/reference/fx.mdx +7 -6
- package/src/auth/api-key-sql.test.ts +44 -0
- package/src/auth/api-key-sql.ts +1 -0
- package/src/auth/api-keys.ts +30 -12
- package/src/auth/config.ts +6 -2
- package/src/auth/gate-auth.test.ts +20 -0
- package/src/cli/agents-md.test.ts +25 -0
- package/src/cli/ask-vault-gaps.test.ts +38 -1
- package/src/cli/ask-vault-gaps.ts +35 -2
- package/src/cli/decide.ts +174 -0
- package/src/cli/decision-lock-watch.test.ts +45 -0
- package/src/cli/decision-lock-watch.ts +51 -0
- package/src/cli/dev-app-runner.ts +34 -8
- package/src/cli/dev-auth-secret.test.ts +49 -0
- package/src/cli/dev-auth-secret.ts +49 -0
- package/src/cli/dev-hot.test.ts +41 -0
- package/src/cli/dev-hot.ts +48 -0
- package/src/cli/dev.test.ts +1 -1
- package/src/cli/dev.ts +24 -6
- package/src/cli/eval.ts +142 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/load-config.images.test.ts +2 -2
- package/src/cli/load-config.ts +1 -1
- package/src/cli/mcp-from-console.ts +48 -0
- package/src/cli/registry.ts +35 -1
- package/src/client/agent.ts +307 -0
- package/src/client/transport.test.ts +20 -0
- package/src/client/transport.ts +2 -1
- package/src/client/types.ts +1 -1
- package/src/client-react/index.ts +7 -1
- package/src/client-react/use-agent-run.test.ts +271 -0
- package/src/client-react/use-agent-run.ts +193 -0
- package/src/compiler/ai-approval.test.ts +36 -0
- package/src/compiler/ai-repair.test.ts +22 -0
- package/src/compiler/decisions.extract.test.ts +164 -0
- package/src/compiler/effects-infer.ts +27 -1
- package/src/compiler/extract.ts +268 -12
- package/src/compiler/response.ts +35 -2
- package/src/config/index.ts +21 -3
- package/src/console/server/ai-runs-flows.ts +269 -0
- package/src/console/server/ai-runs.test.ts +323 -0
- package/src/console/server/ai-runs.ts +562 -0
- package/src/console/server/ai.ts +12 -1
- package/src/console/server/app.ts +10 -1
- package/src/console/server/decisions-flows.ts +157 -0
- package/src/console/server/decisions.test.ts +66 -0
- package/src/console/server/decisions.ts +229 -0
- package/src/console/server/flows.ts +50 -2
- package/src/console/server/invoke-user-flow.ts +9 -0
- package/src/console/server/runs-ingest.ts +16 -0
- package/src/console/server/serve.ts +5 -0
- package/src/console/server/state.ts +25 -1
- package/src/console/server/store-stats.test.ts +47 -0
- package/src/console/server/store-stats.ts +92 -18
- package/src/console/ui-next/dist/assets/access-page-DDKkhT9v.js +4 -0
- package/src/console/ui-next/dist/assets/{agent-disclosure-Bkohc_8X.js → agent-disclosure-62Xts8Xi.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-rL0lHxat.js → cache-glyph-DyeoKHKA.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-Deo4PP18.js → call-pii-button-DvfpLXsU.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-CoJ6amHf.js → collapsible-BY03SeCg.js} +1 -1
- package/src/console/ui-next/dist/assets/confirm-sheet-DZfuJXP4.js +1 -0
- package/src/console/ui-next/dist/assets/copy-inline-button-BTjPVjRT.js +1 -0
- package/src/console/ui-next/dist/assets/decisions-page-CgkKlA56.js +1 -0
- package/src/console/ui-next/dist/assets/detail-header-Dk5LipF6.js +1 -0
- package/src/console/ui-next/dist/assets/dropdown-menu-CRGrj5JU.js +1 -0
- package/src/console/ui-next/dist/assets/duration-tone-Y6HLjyJ5.js +9 -0
- package/src/console/ui-next/dist/assets/element-icons-C6t8Vlml.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-chrome-Dslh2yE8.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-empty-CIVg7Jgb.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-DLPfNy-_.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-CPYBzDh0.js → highlighted-json-D7nNzpgB.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DTPevKmM.js → http-method-DdL19zzh.js} +1 -1
- package/src/console/ui-next/dist/assets/index-BlrN49Hs.css +2 -0
- package/src/console/ui-next/dist/assets/index-C0lc_s9d.js +58 -0
- package/src/console/ui-next/dist/assets/observability-page-CRlcyLCC.js +8 -0
- package/src/console/ui-next/dist/assets/{replica-lag-BM27lXld.js → replica-lag-Bb3FWUu9.js} +7 -7
- package/src/console/ui-next/dist/assets/request-meta-DYBMWhX8.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-keys-DTE1sjTC.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-CcE-SXC8.js +41 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-CKMSEQ4s.js +2 -0
- package/src/console/ui-next/dist/assets/tree-expand-toggle-QOfk0M0H.js +55 -0
- package/src/console/ui-next/dist/assets/units-page-DKC1CDDd.js +1 -0
- package/src/console/ui-next/dist/assets/use-vault-list-BAyQG8Re.js +1 -0
- package/src/console/ui-next/dist/assets/vault-page-B54v4Yit.js +2 -0
- package/src/console/ui-next/dist/index.html +4 -3
- package/src/console/ui-next/seed-parked-approval.ts +126 -0
- package/src/console/ui-next/src/client.ts +224 -1
- package/src/console/ui-next/src/components/explorer/detail-header.tsx +9 -0
- package/src/console/ui-next/src/components/explorer/explorer-start-toggle.tsx +5 -1
- package/src/console/ui-next/src/components/ui/sheet-form.tsx +21 -12
- package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +216 -0
- package/src/console/ui-next/src/features/flows/decisions/resolve.test.ts +48 -0
- package/src/console/ui-next/src/features/flows/decisions/resolve.ts +111 -0
- package/src/console/ui-next/src/features/flows/flows-page.tsx +10 -0
- package/src/console/ui-next/src/features/flows/graph/element-map.ts +1 -0
- package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +6 -0
- package/src/console/ui-next/src/features/flows/traces/effect-summary.test.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/http-status.ts +58 -0
- package/src/console/ui-next/src/features/flows/traces/request-client-card.tsx +355 -0
- package/src/console/ui-next/src/features/flows/traces/request-client.test.ts +114 -0
- package/src/console/ui-next/src/features/flows/traces/request-client.ts +423 -0
- package/src/console/ui-next/src/features/flows/traces/request-input-view.test.ts +52 -6
- package/src/console/ui-next/src/features/flows/traces/request-input-view.ts +85 -19
- package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +505 -80
- package/src/console/ui-next/src/features/flows/traces/trace-lanes.test.ts +61 -0
- package/src/console/ui-next/src/features/flows/traces/trace-lanes.ts +78 -0
- package/src/console/ui-next/src/features/flows/traces/trace-nav.test.ts +48 -0
- package/src/console/ui-next/src/features/flows/traces/trace-nav.ts +57 -0
- package/src/console/ui-next/src/features/flows/traces/trace-request-section.tsx +275 -96
- package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +16 -2
- package/src/console/ui-next/src/features/observability/detail/ai-runs.tsx +369 -0
- package/src/console/ui-next/src/features/observability/observability-page.tsx +31 -1
- package/src/console/ui-next/src/features/observability/state/observability-selection.ts +18 -3
- package/src/console/ui-next/src/features/store/performance/kv-performance-panel.tsx +12 -1
- package/src/console/ui-next/src/features/store/performance/performance-panel.tsx +14 -1
- package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +1 -1
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +6 -2
- package/src/console/ui-next/src/features/units/explorer/units-tree.tsx +73 -18
- package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +21 -0
- package/src/console/ui-next/src/features/units/lib/unit-tree.ts +82 -0
- package/src/console/ui-next/src/features/units/units-page.tsx +25 -7
- package/src/console/ui-next/src/router.tsx +17 -0
- package/src/console/ui-next/ui-next-seed-runs.ts +23 -0
- package/src/console/ui-next/vite-console-kernel-plugin.ts +43 -3
- package/src/console/ui-next/vite.config.ts +9 -0
- package/src/docker/recipes/dragonfly.ts +3 -2
- package/src/docker/recipes/redis.ts +1 -1
- package/src/drivers/ai-anthropic.ts +212 -21
- package/src/drivers/ai-mock.ts +34 -1
- package/src/drivers/ai-openai-compatible.ts +67 -26
- package/src/drivers/ai-preconnect.ts +18 -0
- package/src/drivers/ai-stream.test.ts +62 -0
- package/src/drivers/ai-types.ts +20 -0
- package/src/drivers/journal-postgres.ts +289 -2
- package/src/drivers/memory-ddl.test.ts +18 -0
- package/src/drivers/memory.ts +21 -1
- package/src/drivers/pg-rls.test.ts +31 -0
- package/src/drivers/pg-rls.ts +15 -0
- package/src/drivers/postgres.test.ts +9 -0
- package/src/drivers/postgres.ts +49 -11
- package/src/elements/ai/agent-event-slot.ts +27 -0
- package/src/elements/ai/approval-http.ts +207 -0
- package/src/elements/ai/approval.test.ts +741 -0
- package/src/elements/ai/approval.ts +228 -0
- package/src/elements/ai/decisions/bind.ts +163 -0
- package/src/elements/ai/decisions/certificate.test.ts +291 -0
- package/src/elements/ai/decisions/certificate.ts +377 -0
- package/src/elements/ai/decisions/certify.ts +314 -0
- package/src/elements/ai/decisions/decide.test.ts +953 -0
- package/src/elements/ai/decisions/e2e.test.ts +399 -0
- package/src/elements/ai/decisions/export.test.ts +120 -0
- package/src/elements/ai/decisions/export.ts +146 -0
- package/src/elements/ai/decisions/fixtures/openrouter-request.json +24 -0
- package/src/elements/ai/decisions/fixtures/openrouter-response.json +22 -0
- package/src/elements/ai/decisions/fixtures/typesafe-request.json +19 -0
- package/src/elements/ai/decisions/fixtures/typesafe-response.json +13 -0
- package/src/elements/ai/decisions/http.test.ts +154 -0
- package/src/elements/ai/decisions/http.ts +240 -0
- package/src/elements/ai/decisions/labels.test.ts +282 -0
- package/src/elements/ai/decisions/labels.ts +257 -0
- package/src/elements/ai/decisions/openrouter.live.test.ts +136 -0
- package/src/elements/ai/decisions/openrouter.ts +36 -0
- package/src/elements/ai/decisions/provider.ts +102 -0
- package/src/elements/ai/decisions/typesafe.ts +36 -0
- package/src/elements/ai/declare.ts +252 -1
- package/src/elements/ai/events.test.ts +379 -0
- package/src/elements/ai/events.ts +138 -0
- package/src/elements/ai/run-events.test.ts +461 -0
- package/src/elements/ai/run-events.ts +522 -0
- package/src/elements/ai/runtime.ts +1186 -105
- package/src/elements/ai/stream-turn.ts +214 -0
- package/src/elements/ai/stream.live.test.ts +76 -0
- package/src/elements/ai/subagent.test.ts +136 -0
- package/src/elements/ai.test.ts +497 -3
- package/src/elements/ai.ts +16 -1
- package/src/elements/clock/durable.ts +27 -1
- package/src/elements/store/sql-session.test.ts +51 -0
- package/src/elements/store/sql-session.ts +111 -1
- package/src/i18n/catalogs/ar.ts +5 -0
- package/src/i18n/catalogs/en.ts +5 -0
- package/src/index.ts +2 -0
- package/src/kernel/agent-event-store.ts +448 -0
- package/src/kernel/app.ts +103 -1
- package/src/kernel/boot-bind/ai.ts +3 -0
- package/src/kernel/boot-bind/store.test.ts +26 -0
- package/src/kernel/boot-bind/store.ts +82 -6
- package/src/kernel/boot.ts +15 -4
- package/src/kernel/builtin-errors.ts +2 -0
- package/src/kernel/capability.ts +3 -0
- package/src/kernel/decision-label-store.ts +441 -0
- package/src/kernel/effects.test.ts +2 -1
- package/src/kernel/effects.ts +13 -1
- package/src/kernel/element-registries.ts +3 -0
- package/src/kernel/errors-text.ts +4 -0
- package/src/kernel/errors.ts +2 -0
- package/src/kernel/flow.test.ts +2 -3
- package/src/kernel/fx-decide.ts +761 -0
- package/src/kernel/fx.test.ts +12 -5
- package/src/kernel/fx.ts +312 -29
- package/src/kernel/http-frame.test.ts +94 -0
- package/src/kernel/http-frame.ts +203 -0
- package/src/kernel/journal.ts +104 -0
- package/src/kernel/json-result.ts +10 -0
- package/src/kernel/pipeline.test.ts +45 -0
- package/src/kernel/sse-id.ts +32 -0
- package/src/manifest/diff.ts +1 -0
- package/src/manifest/types.ts +29 -2
- package/src/mcp/ai-tools.test.ts +202 -0
- package/src/mcp/authorization.ts +66 -0
- package/src/mcp/session.ts +3 -0
- package/src/mcp/tools.ts +168 -0
- package/src/release/http-graph.test.ts +2 -0
- package/src/release/http-graph.ts +5 -0
- package/src/release/measure.ts +39 -14
- package/src/runs/collect.ts +4 -1
- package/src/runs/parquet.test.ts +27 -1
- package/src/runs/parquet.ts +19 -0
- package/src/runs/types.ts +31 -0
- package/src/runtime/json-code-block.test.ts +106 -47
- package/src/runtime/json-code-block.ts +1209 -46
- package/src/term.test.ts +13 -0
- package/src/term.ts +81 -49
- package/src/console/ui-next/dist/assets/PlusSignIcon-CwG3nxfu.js +0 -1
- package/src/console/ui-next/dist/assets/access-page-Ze6ckjdd.js +0 -4
- package/src/console/ui-next/dist/assets/copy-inline-button-CWG5_ZOh.js +0 -1
- package/src/console/ui-next/dist/assets/detail-header-b_ZzIsmU.js +0 -1
- package/src/console/ui-next/dist/assets/dropdown-menu-nwXEO1Ac.js +0 -1
- package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +0 -9
- package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +0 -1
- package/src/console/ui-next/dist/assets/explorer-empty-C8sSCqYT.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-D2E7BtKK.js +0 -1
- package/src/console/ui-next/dist/assets/index-VxoEz295.css +0 -2
- package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +0 -58
- package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +0 -4
- package/src/console/ui-next/dist/assets/request-meta-BjFT7DNl.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +0 -41
- package/src/console/ui-next/dist/assets/trace-detail-sheet-Cmitq3da.js +0 -2
- package/src/console/ui-next/dist/assets/tree-expand-toggle-CbDIB-7x.js +0 -55
- package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +0 -1
- package/src/console/ui-next/dist/assets/use-vault-list-Cs45Czkt.js +0 -1
- package/src/console/ui-next/dist/assets/vault-page-BOMr0go9.js +0 -2
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable human approval for one agent tool call.
|
|
3
|
+
*
|
|
4
|
+
* The pending row is a journal step. The first approve, deny, or timeout wins.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { fail } from "../../kernel/errors.ts";
|
|
8
|
+
import {
|
|
9
|
+
hasJournalLease,
|
|
10
|
+
JOURNAL_DEFAULT_LEASE_MS,
|
|
11
|
+
type JournalEntry,
|
|
12
|
+
type JournalRun,
|
|
13
|
+
type JournalStore,
|
|
14
|
+
} from "../../kernel/journal.ts";
|
|
15
|
+
import { parseDurationMs } from "../clock/duration.ts";
|
|
16
|
+
|
|
17
|
+
/** Default wait before an unanswered approval is denied. */
|
|
18
|
+
export const AI_APPROVAL_TIMEOUT = "24h";
|
|
19
|
+
|
|
20
|
+
/** Thrown when an approval tool runs on a Flow that is not durable. */
|
|
21
|
+
export class AiDurableRequiredError extends Error {
|
|
22
|
+
/**
|
|
23
|
+
* @param flow - Calling flow name
|
|
24
|
+
* @param agent - Agent name
|
|
25
|
+
*/
|
|
26
|
+
constructor(flow: string, agent: string) {
|
|
27
|
+
super(`ai: flow "${flow}" must set durable: true to run agent "${agent}"`);
|
|
28
|
+
this.name = "AiDurableRequiredError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Persisted approval decision. */
|
|
33
|
+
export interface AgentApprovalRecord {
|
|
34
|
+
status: "pending" | "approved" | "denied";
|
|
35
|
+
/** Agent that requested the tool. Absent on rows written before this field. */
|
|
36
|
+
readonly agent?: string;
|
|
37
|
+
readonly tool: string;
|
|
38
|
+
readonly args: unknown;
|
|
39
|
+
readonly gate: string;
|
|
40
|
+
readonly tenant: string | null;
|
|
41
|
+
readonly requestedAt: number;
|
|
42
|
+
reason?: string;
|
|
43
|
+
approver?: string;
|
|
44
|
+
editedArgs?: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** How a caller resolves a pending approval. */
|
|
48
|
+
export interface AgentApprovalDecision {
|
|
49
|
+
readonly decision: "approve" | "deny";
|
|
50
|
+
readonly args?: unknown;
|
|
51
|
+
readonly reason?: string;
|
|
52
|
+
readonly approver?: string;
|
|
53
|
+
readonly tenant?: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Result of resolving one approval. */
|
|
57
|
+
export type AgentApprovalResolveResult =
|
|
58
|
+
| { readonly ok: true }
|
|
59
|
+
| { readonly ok: false; readonly status: 403 | 404 }
|
|
60
|
+
| { readonly ok: false; readonly status: 409; readonly reason: "resolved" }
|
|
61
|
+
| {
|
|
62
|
+
readonly ok: false;
|
|
63
|
+
readonly status: 409;
|
|
64
|
+
readonly reason: "lease";
|
|
65
|
+
readonly retryAfterSeconds: number;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* HTTP 409 for a run whose lease is held. `Conflict` stays the finished case.
|
|
70
|
+
*
|
|
71
|
+
* @param retryAfterSeconds - Seconds until the lease expires
|
|
72
|
+
*/
|
|
73
|
+
export function journalLeaseBusyResponse(retryAfterSeconds: number): Response {
|
|
74
|
+
const seconds = Math.max(1, Math.ceil(retryAfterSeconds));
|
|
75
|
+
const failure = fail("JournalLeaseBusy", { retryAfter: seconds });
|
|
76
|
+
const headers = new Headers();
|
|
77
|
+
headers.set("retry-after", String(seconds));
|
|
78
|
+
return Response.json({ data: null, error: failure.error }, { status: 409, headers });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Seconds a caller should wait before retrying a busy lease.
|
|
83
|
+
*
|
|
84
|
+
* @param expiresAt - Lease expiry epoch-ms, when the run has one
|
|
85
|
+
* @param now - Clock used for the attempt
|
|
86
|
+
*/
|
|
87
|
+
export function leaseRetryAfterSeconds(expiresAt: number | undefined, now: number): number {
|
|
88
|
+
if (expiresAt === undefined) return 1;
|
|
89
|
+
return Math.max(1, Math.ceil((expiresAt - now) / 1000));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Opaque approval id. The run id is the prefix so resolve can `get` one run.
|
|
94
|
+
*
|
|
95
|
+
* @param runId - Durable run id
|
|
96
|
+
* @param toolCallId - Provider tool-call id, or a deterministic fallback
|
|
97
|
+
*/
|
|
98
|
+
export function approvalId(runId: string, toolCallId: string): string {
|
|
99
|
+
return Buffer.from(`${runId}.${toolCallId}`, "utf8").toString("base64url");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Split an opaque approval id. Run ids do not contain `.`.
|
|
104
|
+
*
|
|
105
|
+
* @param id - Approval id from the interrupt
|
|
106
|
+
*/
|
|
107
|
+
export function parseApprovalId(
|
|
108
|
+
id: string,
|
|
109
|
+
): { readonly runId: string; readonly toolCallId: string } | undefined {
|
|
110
|
+
let decoded: string;
|
|
111
|
+
try {
|
|
112
|
+
decoded = Buffer.from(id, "base64url").toString("utf8");
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
const dot = decoded.indexOf(".");
|
|
117
|
+
if (dot <= 0 || dot === decoded.length - 1) return undefined;
|
|
118
|
+
return { runId: decoded.slice(0, dot), toolCallId: decoded.slice(dot + 1) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Milliseconds for a tool timeout. `24h` when omitted or unparseable.
|
|
123
|
+
*
|
|
124
|
+
* @param timeout - Duration string
|
|
125
|
+
*/
|
|
126
|
+
export function approvalTimeoutMs(timeout: string | undefined): number {
|
|
127
|
+
const parsed = parseDurationMs(timeout ?? AI_APPROVAL_TIMEOUT);
|
|
128
|
+
return parsed > 0 ? parsed : parseDurationMs(AI_APPROVAL_TIMEOUT);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Journal step name for one approval.
|
|
133
|
+
*
|
|
134
|
+
* @param id - Approval id
|
|
135
|
+
*/
|
|
136
|
+
export function approvalStepName(id: string): string {
|
|
137
|
+
return `ai-approval:${id}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Read one approval from the journal store.
|
|
142
|
+
*
|
|
143
|
+
* @param store - Durable journal
|
|
144
|
+
* @param id - Approval id
|
|
145
|
+
*/
|
|
146
|
+
export async function readAgentApproval(
|
|
147
|
+
store: JournalStore,
|
|
148
|
+
id: string,
|
|
149
|
+
): Promise<AgentApprovalRecord | undefined> {
|
|
150
|
+
const parsed = parseApprovalId(id);
|
|
151
|
+
if (!parsed) return undefined;
|
|
152
|
+
const run = await store.get(parsed.runId);
|
|
153
|
+
if (!run) return undefined;
|
|
154
|
+
const name = approvalStepName(id);
|
|
155
|
+
const entry = run.entries.find((item) => item.kind === "step" && item.name === name);
|
|
156
|
+
if (!entry || entry.kind !== "step") return undefined;
|
|
157
|
+
return entry.value as AgentApprovalRecord;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* First writer wins. A later resolution returns 409.
|
|
162
|
+
* Wakes a sleeping run by moving its sleep entry to now.
|
|
163
|
+
*
|
|
164
|
+
* @param store - Durable journal
|
|
165
|
+
* @param id - Approval id
|
|
166
|
+
* @param decision - Approve or deny
|
|
167
|
+
* @param now - Clock used for the early wake
|
|
168
|
+
* @param hold - Lease holder to renew instead of taking a new token. Timeout
|
|
169
|
+
* deny passes the resume holder's id so it does not drop that lease.
|
|
170
|
+
*/
|
|
171
|
+
export async function resolveAgentApproval(
|
|
172
|
+
store: JournalStore,
|
|
173
|
+
id: string,
|
|
174
|
+
decision: AgentApprovalDecision,
|
|
175
|
+
now: () => number = Date.now,
|
|
176
|
+
hold?: string,
|
|
177
|
+
): Promise<AgentApprovalResolveResult> {
|
|
178
|
+
const parsed = parseApprovalId(id);
|
|
179
|
+
if (!parsed || !hasJournalLease(store)) return { ok: false, status: 404 };
|
|
180
|
+
const name = approvalStepName(id);
|
|
181
|
+
const token = hold ?? crypto.randomUUID();
|
|
182
|
+
const at = now();
|
|
183
|
+
const claimed = await store.acquireLease(parsed.runId, token, at, JOURNAL_DEFAULT_LEASE_MS);
|
|
184
|
+
if (!claimed) {
|
|
185
|
+
const held = await store.get(parsed.runId);
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
status: 409,
|
|
189
|
+
reason: "lease",
|
|
190
|
+
retryAfterSeconds: leaseRetryAfterSeconds(held?.leaseExpiresAt, at),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
const run = await store.get(parsed.runId);
|
|
195
|
+
if (!run) return { ok: false, status: 404 };
|
|
196
|
+
const entry = run.entries.find((item) => item.kind === "step" && item.name === name);
|
|
197
|
+
if (!entry || entry.kind !== "step") return { ok: false, status: 404 };
|
|
198
|
+
const current = entry.value as AgentApprovalRecord;
|
|
199
|
+
if ((decision.tenant ?? null) !== (current.tenant ?? null)) {
|
|
200
|
+
return { ok: false, status: 404 };
|
|
201
|
+
}
|
|
202
|
+
if (current.status !== "pending") return { ok: false, status: 409, reason: "resolved" };
|
|
203
|
+
const next: AgentApprovalRecord = {
|
|
204
|
+
...current,
|
|
205
|
+
status: decision.decision === "approve" ? "approved" : "denied",
|
|
206
|
+
...(decision.reason !== undefined ? { reason: decision.reason } : {}),
|
|
207
|
+
...(decision.approver !== undefined ? { approver: decision.approver } : {}),
|
|
208
|
+
...(decision.args !== undefined ? { editedArgs: decision.args } : {}),
|
|
209
|
+
};
|
|
210
|
+
const entries: JournalEntry[] = run.entries.map((item) => {
|
|
211
|
+
if (item.kind === "step" && item.name === name) return { ...item, value: next };
|
|
212
|
+
if (item.kind === "sleep" && item.label === name) return { ...item, wakeAt: at };
|
|
213
|
+
return item;
|
|
214
|
+
});
|
|
215
|
+
const updated: JournalRun = {
|
|
216
|
+
...run,
|
|
217
|
+
entries,
|
|
218
|
+
wakeAt: at,
|
|
219
|
+
status: run.status === "sleeping" ? "sleeping" : run.status,
|
|
220
|
+
lockedBy: run.lockedBy,
|
|
221
|
+
leaseExpiresAt: run.leaseExpiresAt,
|
|
222
|
+
};
|
|
223
|
+
await store.put(updated);
|
|
224
|
+
return { ok: true };
|
|
225
|
+
} finally {
|
|
226
|
+
if (hold === undefined) await store.releaseLease(parsed.runId, token);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot flows for decisions that declare autonomy: a clock aggregate,
|
|
3
|
+
* a drift monitor, and the operator candidate endpoint.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { gate } from "../../gate/declare.ts";
|
|
7
|
+
import { flow } from "../../../kernel/flow.ts";
|
|
8
|
+
import type { Binding } from "../../../kernel/on.ts";
|
|
9
|
+
import { http } from "../../../kernel/triggers.ts";
|
|
10
|
+
import type { Manifest } from "../../../manifest/types.ts";
|
|
11
|
+
import { aiDecisionRegistry } from "../../../kernel/element-registries.ts";
|
|
12
|
+
import {
|
|
13
|
+
aggregateDecisionCandidate,
|
|
14
|
+
decisionDriftNames,
|
|
15
|
+
decisionDriftSuspended,
|
|
16
|
+
setDecisionDrift,
|
|
17
|
+
} from "./certificate.ts";
|
|
18
|
+
import { certifyLabels } from "./certify.ts";
|
|
19
|
+
import { decisionExportFields, exportDecisionLabels } from "./export.ts";
|
|
20
|
+
import {
|
|
21
|
+
auditDriftExceeded,
|
|
22
|
+
loadDecisionCandidate,
|
|
23
|
+
loadDecisionLabels,
|
|
24
|
+
persistDecisionCandidate,
|
|
25
|
+
persistDecisionDrift,
|
|
26
|
+
pinnedDecision,
|
|
27
|
+
} from "./labels.ts";
|
|
28
|
+
|
|
29
|
+
/** Signal the drift monitor emits. The emit is declared on that flow. */
|
|
30
|
+
export const DECISION_DRIFT_SIGNAL = "oke/decision/drift";
|
|
31
|
+
|
|
32
|
+
/** Operator gate on the candidate route. */
|
|
33
|
+
export const decisionOperatorGate = gate.policy(
|
|
34
|
+
"oke.decisions.operator",
|
|
35
|
+
(ctx) => ctx.operator.id !== null,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* True when any decision carries an autonomy block.
|
|
40
|
+
*
|
|
41
|
+
* @param manifest - Boot manifest
|
|
42
|
+
*/
|
|
43
|
+
export function manifestHasDecisionAutonomy(manifest: Manifest | undefined): boolean {
|
|
44
|
+
const decisions = manifest?.ai?.decisions;
|
|
45
|
+
if (!decisions) return false;
|
|
46
|
+
return Object.values(decisions).some((decision) => decision.autonomy !== undefined);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Register the aggregate clock, the drift monitor, and the candidate route.
|
|
51
|
+
*
|
|
52
|
+
* @param adopt - App binding sink
|
|
53
|
+
* @param manifest - Boot manifest
|
|
54
|
+
*/
|
|
55
|
+
export function bindDecisionFlows(adopt: (binding: Binding) => void, manifest: Manifest): void {
|
|
56
|
+
if (!manifestHasDecisionAutonomy(manifest)) return;
|
|
57
|
+
const names = Object.keys(manifest.ai?.decisions ?? {});
|
|
58
|
+
const aggregate = flow("oke.decisions.aggregate", {
|
|
59
|
+
plane: "operator",
|
|
60
|
+
effects: { writes: ["kv:oke-decision-candidate"] },
|
|
61
|
+
do: async () => {
|
|
62
|
+
for (const name of names) {
|
|
63
|
+
const decl = aiDecisionRegistry.find((item) => item.name === name);
|
|
64
|
+
const pinned = pinnedDecision(name);
|
|
65
|
+
const rows = await loadDecisionLabels(name);
|
|
66
|
+
const fitted = aggregateDecisionCandidate(name, () =>
|
|
67
|
+
certifyLabels({
|
|
68
|
+
model: pinned?.model ?? decl?.model ?? "",
|
|
69
|
+
maxError: decl?.autonomy?.maxError ?? 0.05,
|
|
70
|
+
delta: decl?.autonomy?.risk ?? 0.1,
|
|
71
|
+
ask: decl?.ask ?? {},
|
|
72
|
+
labels: rows,
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
await persistDecisionCandidate(name, fitted);
|
|
76
|
+
}
|
|
77
|
+
return { ok: true };
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
adopt({
|
|
81
|
+
trigger: { kind: "clock", name: "oke.decisions.aggregate" },
|
|
82
|
+
flow: aggregate,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const drift = flow("oke.decisions.drift", {
|
|
86
|
+
plane: "operator",
|
|
87
|
+
effects: { emits: [DECISION_DRIFT_SIGNAL] },
|
|
88
|
+
do: async (_input, fx) => {
|
|
89
|
+
const suspended: string[] = [];
|
|
90
|
+
for (const [name, decision] of Object.entries(manifest.ai?.decisions ?? {})) {
|
|
91
|
+
const pinned = pinnedDecision(name);
|
|
92
|
+
if (!pinned) continue;
|
|
93
|
+
const exceeded = auditDriftExceeded({
|
|
94
|
+
maxError: decision.autonomy?.maxError ?? 0.05,
|
|
95
|
+
delta: decision.autonomy?.risk ?? 0.1,
|
|
96
|
+
model: pinned.model,
|
|
97
|
+
since: pinned.since,
|
|
98
|
+
labels: await loadDecisionLabels(name),
|
|
99
|
+
});
|
|
100
|
+
const prev = decisionDriftSuspended(name);
|
|
101
|
+
if (exceeded === prev) continue;
|
|
102
|
+
setDecisionDrift(name, exceeded);
|
|
103
|
+
persistDecisionDrift(name, exceeded, pinned.since);
|
|
104
|
+
if (exceeded) suspended.push(name);
|
|
105
|
+
}
|
|
106
|
+
if (suspended.length === 0) return { suspended: decisionDriftNames() };
|
|
107
|
+
await fx.emit({ name: DECISION_DRIFT_SIGNAL }, { suspended: true, decisions: suspended });
|
|
108
|
+
return { suspended: decisionDriftNames() };
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
adopt({
|
|
112
|
+
trigger: { kind: "clock", name: "oke.decisions.drift" },
|
|
113
|
+
flow: drift,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const candidate = flow("oke.decisions.candidate", {
|
|
117
|
+
plane: "operator",
|
|
118
|
+
do: async (input: { name?: string }, fx) => {
|
|
119
|
+
if (!fx.operator.id) return fx.fail.unauthorized();
|
|
120
|
+
const name = input.name ?? "";
|
|
121
|
+
const body = await loadDecisionCandidate(name);
|
|
122
|
+
if (!body) return fx.fail.notFound();
|
|
123
|
+
return body;
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
adopt({
|
|
127
|
+
trigger: http.get("/_oke/decisions/:name/candidate").gate(decisionOperatorGate),
|
|
128
|
+
flow: candidate,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const labels = flow("oke.decisions.labels", {
|
|
132
|
+
plane: "operator",
|
|
133
|
+
do: async (input: { name?: string; tenant?: string }, fx) => {
|
|
134
|
+
if (!fx.operator.id) return fx.fail.unauthorized();
|
|
135
|
+
const name = input.name ?? "";
|
|
136
|
+
const requested = input.tenant;
|
|
137
|
+
if (requested && requested !== (fx.tenant.id ?? null)) return fx.fail.notFound();
|
|
138
|
+
const decl = aiDecisionRegistry.find((item) => item.name === name);
|
|
139
|
+
const rows = await loadDecisionLabels(name, fx.tenant.id);
|
|
140
|
+
const exported = exportDecisionLabels({
|
|
141
|
+
labels: rows,
|
|
142
|
+
callerTenant: fx.tenant.id,
|
|
143
|
+
...(requested !== undefined ? { requestedTenant: requested } : {}),
|
|
144
|
+
fields: decisionExportFields(decl?.inputSchema),
|
|
145
|
+
});
|
|
146
|
+
if (!exported.ok) return fx.fail.notFound();
|
|
147
|
+
return { lines: exported.lines };
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
adopt({
|
|
151
|
+
trigger: http.get("/_oke/decisions/:name/labels").gate(decisionOperatorGate),
|
|
152
|
+
flow: labels,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Mark the app suspended and leave the flag for the monitor to emit.
|
|
158
|
+
*
|
|
159
|
+
* @param suspended - Drift detected
|
|
160
|
+
*/
|
|
161
|
+
export function noteDecisionDrift(name: string, suspended: boolean): void {
|
|
162
|
+
setDecisionDrift(name, suspended);
|
|
163
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Certificates: Learn-then-Test, non-auto paths, and promote.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { mkdtemp, readFile } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { ai, resetAiDecls } from "../../ai.ts";
|
|
10
|
+
import {
|
|
11
|
+
binomialCdf,
|
|
12
|
+
calibrateBoolean,
|
|
13
|
+
decisionDriftSuspended,
|
|
14
|
+
learnThenTest,
|
|
15
|
+
loadDecisionLockfile,
|
|
16
|
+
questionHash,
|
|
17
|
+
resetDecisionCertificates,
|
|
18
|
+
setDecisionDrift,
|
|
19
|
+
setDecisionLock,
|
|
20
|
+
} from "./certificate.ts";
|
|
21
|
+
import { certifyLabels, certifySeed } from "./certify.ts";
|
|
22
|
+
import { runOkeCertify } from "../../../cli/eval.ts";
|
|
23
|
+
import { promoteDecision } from "../../../cli/decide.ts";
|
|
24
|
+
import { createFx } from "../../../kernel/fx.ts";
|
|
25
|
+
import { resetDecisionProvider, setDecisionProvider } from "../../../kernel/fx-decide.ts";
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
resetAiDecls();
|
|
29
|
+
resetDecisionCertificates();
|
|
30
|
+
resetDecisionProvider();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function answer() {
|
|
34
|
+
setDecisionProvider(async () => ({
|
|
35
|
+
model: "typesafe/jev-1.13.0",
|
|
36
|
+
answers: {
|
|
37
|
+
team: {
|
|
38
|
+
type: "choice",
|
|
39
|
+
probabilities: { billing: 0.08, technical: 0.9, none_of_these: 0.02 },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
usage: {},
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function run(
|
|
47
|
+
lock: boolean,
|
|
48
|
+
extra?: {
|
|
49
|
+
readonly locale?: boolean;
|
|
50
|
+
readonly drift?: boolean;
|
|
51
|
+
readonly mismatch?: boolean;
|
|
52
|
+
readonly stale?: boolean;
|
|
53
|
+
},
|
|
54
|
+
) {
|
|
55
|
+
const question = ai.choice("which team", { billing: "Billing", technical: "Technical" });
|
|
56
|
+
const decl = ai.decision("triage", {
|
|
57
|
+
onUncertain: "abstain",
|
|
58
|
+
autonomy: { maxError: 0.05, audit: 0 },
|
|
59
|
+
...(extra?.locale ? { locale: () => "ar" } : {}),
|
|
60
|
+
ask: { team: question },
|
|
61
|
+
});
|
|
62
|
+
if (lock) {
|
|
63
|
+
setDecisionLock({
|
|
64
|
+
decisions: {
|
|
65
|
+
triage: {
|
|
66
|
+
model: extra?.mismatch ? "other" : "typesafe/jev-1.13.0",
|
|
67
|
+
questions: {
|
|
68
|
+
team: {
|
|
69
|
+
"": {
|
|
70
|
+
hash: extra?.stale ? "nope" : questionHash(question),
|
|
71
|
+
calibrator: { kind: "temperature" as const, t: 1 },
|
|
72
|
+
threshold: 0.5,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (extra?.drift) setDecisionDrift("triage", true);
|
|
81
|
+
const fx = createFx({ flow: "run", effects: { decides: ["triage"] }, now: () => 1 });
|
|
82
|
+
return (await fx.decide(decl, {})) as { team: string | null; $: { team: { how: string } } };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe("decision certificates", () => {
|
|
86
|
+
test("Learn-then-Test certifies only a low error rate, at the loosest passing threshold", () => {
|
|
87
|
+
expect(learnThenTest([{ score: 0.99, loss: 0 }], 0.05)).toBeNull();
|
|
88
|
+
expect(learnThenTest([{ score: 0.99, loss: 1 }], 0.05)).toBeNull();
|
|
89
|
+
const forty = Array.from({ length: 40 }, (_, i) => ({
|
|
90
|
+
score: 0.99,
|
|
91
|
+
loss: i < 3 ? 1 : 0,
|
|
92
|
+
}));
|
|
93
|
+
expect(learnThenTest(forty, 0.05)).toBeNull();
|
|
94
|
+
const noisy = Array.from({ length: 2000 }, (_, i) => ({
|
|
95
|
+
score: 0.99,
|
|
96
|
+
loss: i < 150 ? 1 : 0,
|
|
97
|
+
}));
|
|
98
|
+
expect(learnThenTest(noisy, 0.05)).toBeNull();
|
|
99
|
+
const correct = Array.from({ length: 500 }, (_, i) => ({
|
|
100
|
+
score: 0.5 + (i % 50) / 100,
|
|
101
|
+
loss: 0,
|
|
102
|
+
}));
|
|
103
|
+
expect(learnThenTest(correct, 0.05)).toBe(0.5);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("binomial cdf is zero below zero and stable at n = 20000", () => {
|
|
107
|
+
expect(binomialCdf(-1, 10, 0.5)).toBe(0);
|
|
108
|
+
expect(Number.isFinite(binomialCdf(0, 20000, 0.5))).toBe(true);
|
|
109
|
+
expect(binomialCdf(20000, 20000, 0.5)).toBe(1);
|
|
110
|
+
expect(binomialCdf(10000, 20000, 0.5)).toBeGreaterThan(0.4);
|
|
111
|
+
expect(binomialCdf(10000, 20000, 0.5)).toBeLessThan(0.6);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("beta calibration with a = b = 1 and c = 0 is the identity", () => {
|
|
115
|
+
const calibrator = { kind: "beta" as const, a: 1, b: 1, c: 0 };
|
|
116
|
+
expect(calibrateBoolean(0.2, calibrator)).toBeCloseTo(0.2, 6);
|
|
117
|
+
expect(calibrateBoolean(0.8, calibrator)).toBeCloseTo(0.8, 6);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("missing lock, stale hash, version mismatch, locale, and drift abstain", async () => {
|
|
121
|
+
answer();
|
|
122
|
+
expect((await run(false)).$.team.how).toBe("abstained");
|
|
123
|
+
resetAiDecls();
|
|
124
|
+
expect((await run(true, { stale: true })).team).toBeNull();
|
|
125
|
+
resetAiDecls();
|
|
126
|
+
expect((await run(true, { mismatch: true })).team).toBeNull();
|
|
127
|
+
resetAiDecls();
|
|
128
|
+
expect((await run(true, { locale: true })).team).toBeNull();
|
|
129
|
+
resetAiDecls();
|
|
130
|
+
expect((await run(true, { drift: true })).team).toBeNull();
|
|
131
|
+
expect(decisionDriftSuspended("triage")).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("promote writes the fetched candidate", async () => {
|
|
135
|
+
const candidate = {
|
|
136
|
+
model: "typesafe/jev-1.13.0",
|
|
137
|
+
questions: {
|
|
138
|
+
team: {
|
|
139
|
+
"": {
|
|
140
|
+
hash: "abc",
|
|
141
|
+
calibrator: { kind: "temperature" as const, t: 1 },
|
|
142
|
+
threshold: 0.8,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-decide-"));
|
|
148
|
+
const lockPath = join(dir, "oke-decisions.lock.json");
|
|
149
|
+
const written = await promoteDecision({
|
|
150
|
+
name: "triage",
|
|
151
|
+
origin: "http://127.0.0.1:6530",
|
|
152
|
+
lockPath,
|
|
153
|
+
current: { decisions: {} },
|
|
154
|
+
fetcher: async () => new Response(JSON.stringify(candidate), { status: 200 }),
|
|
155
|
+
});
|
|
156
|
+
expect(written.decisions.triage).toEqual(candidate);
|
|
157
|
+
const disk = JSON.parse(await readFile(lockPath, "utf8")) as { decisions: { triage: unknown } };
|
|
158
|
+
expect(disk.decisions.triage).toEqual(candidate);
|
|
159
|
+
await Bun.write(lockPath, `${JSON.stringify({ decisions: { other: candidate } })}\n`);
|
|
160
|
+
const merged = await promoteDecision({
|
|
161
|
+
name: "triage",
|
|
162
|
+
origin: "http://127.0.0.1:6530",
|
|
163
|
+
lockPath,
|
|
164
|
+
fetcher: async () => new Response(JSON.stringify(candidate), { status: 200 }),
|
|
165
|
+
});
|
|
166
|
+
expect(merged.decisions.other).toEqual(candidate);
|
|
167
|
+
expect(merged.decisions.triage).toEqual(candidate);
|
|
168
|
+
expect(await loadDecisionLockfile(dir)).toEqual(merged);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("certify fits a threshold from the provider and hashes the question", async () => {
|
|
172
|
+
const question = ai.choice("which team", { billing: "Billing", technical: "Technical" });
|
|
173
|
+
const decl = ai.decision("triage", {
|
|
174
|
+
onUncertain: "abstain",
|
|
175
|
+
autonomy: { maxError: 0.05, audit: 0 },
|
|
176
|
+
evals: "seed.jsonl",
|
|
177
|
+
ask: { team: question },
|
|
178
|
+
});
|
|
179
|
+
const seed = Array.from({ length: 160 }, () =>
|
|
180
|
+
JSON.stringify({
|
|
181
|
+
input: { ticket: "1" },
|
|
182
|
+
expect: { team: "technical" },
|
|
183
|
+
}),
|
|
184
|
+
).join("\n");
|
|
185
|
+
const entry = await certifySeed({
|
|
186
|
+
model: "typesafe/jev-1.13.0",
|
|
187
|
+
maxError: 0.05,
|
|
188
|
+
jsonl: seed,
|
|
189
|
+
ask: decl.ask,
|
|
190
|
+
evaluate: async () => ({
|
|
191
|
+
model: "typesafe/jev-1.13.0",
|
|
192
|
+
answers: {
|
|
193
|
+
team: {
|
|
194
|
+
type: "choice",
|
|
195
|
+
probabilities: { billing: 0.05, technical: 0.93, none_of_these: 0.02 },
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
usage: {},
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
expect(entry.questions.team?.[""]?.hash).toBe(questionHash(question));
|
|
202
|
+
expect(entry.questions.team?.[""]?.threshold).toBeGreaterThan(0);
|
|
203
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-cert-"));
|
|
204
|
+
const seedPath = join(dir, "seed.jsonl");
|
|
205
|
+
await Bun.write(seedPath, seed);
|
|
206
|
+
const code = await runOkeCertify({
|
|
207
|
+
root: dir,
|
|
208
|
+
manifest: {
|
|
209
|
+
oke: "1",
|
|
210
|
+
app: "cert",
|
|
211
|
+
ai: {
|
|
212
|
+
decisions: {
|
|
213
|
+
triage: {
|
|
214
|
+
mode: "abstain",
|
|
215
|
+
questions: ["team"],
|
|
216
|
+
evals: seedPath,
|
|
217
|
+
autonomy: { maxError: 0.05, audit: 0 },
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
} as never,
|
|
222
|
+
evaluate: async () => ({
|
|
223
|
+
model: "typesafe/jev-1.13.0",
|
|
224
|
+
answers: {
|
|
225
|
+
team: {
|
|
226
|
+
type: "choice",
|
|
227
|
+
probabilities: { billing: 0.05, technical: 0.93, none_of_these: 0.02 },
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
usage: {},
|
|
231
|
+
}),
|
|
232
|
+
});
|
|
233
|
+
expect(code).toBe(0);
|
|
234
|
+
const loaded = await loadDecisionLockfile(dir);
|
|
235
|
+
expect(loaded?.decisions.triage?.questions.team?.[""]?.hash).toBe(questionHash(question));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("one seed row does not certify a slice", async () => {
|
|
239
|
+
const question = ai.choice("which team", { billing: "Billing", technical: "Technical" });
|
|
240
|
+
const entry = await certifySeed({
|
|
241
|
+
model: "typesafe/jev-1.13.0",
|
|
242
|
+
maxError: 0.05,
|
|
243
|
+
jsonl: JSON.stringify({ input: { ticket: "1" }, expect: { team: "technical" } }),
|
|
244
|
+
ask: { team: question },
|
|
245
|
+
evaluate: async () => ({
|
|
246
|
+
model: "typesafe/jev-1.13.0",
|
|
247
|
+
answers: {
|
|
248
|
+
team: {
|
|
249
|
+
type: "choice",
|
|
250
|
+
probabilities: { billing: 0.05, technical: 0.93, none_of_these: 0.02 },
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
usage: {},
|
|
254
|
+
}),
|
|
255
|
+
});
|
|
256
|
+
expect(entry.questions.team).toBeUndefined();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("choice and score labels certify from the stored distribution", () => {
|
|
260
|
+
const choice = ai.choice("which team", { billing: "Billing", technical: "Technical" });
|
|
261
|
+
const score = ai.score("how sure", ["low", "high"]);
|
|
262
|
+
const labels = Array.from({ length: 160 }, () => [
|
|
263
|
+
{
|
|
264
|
+
decision: "triage",
|
|
265
|
+
question: "team",
|
|
266
|
+
value: "technical",
|
|
267
|
+
propensity: 1,
|
|
268
|
+
reviewer: "a",
|
|
269
|
+
model: "typesafe/jev-1.13.0",
|
|
270
|
+
raw: { billing: 0.05, technical: 0.93, none_of_these: 0.02 },
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
decision: "triage",
|
|
274
|
+
question: "rank",
|
|
275
|
+
value: "high",
|
|
276
|
+
propensity: 1,
|
|
277
|
+
reviewer: "a",
|
|
278
|
+
model: "typesafe/jev-1.13.0",
|
|
279
|
+
raw: { low: 0.08, high: 0.92 },
|
|
280
|
+
},
|
|
281
|
+
]).flat();
|
|
282
|
+
const entry = certifyLabels({
|
|
283
|
+
model: "typesafe/jev-1.13.0",
|
|
284
|
+
maxError: 0.05,
|
|
285
|
+
ask: { team: choice, rank: score },
|
|
286
|
+
labels,
|
|
287
|
+
});
|
|
288
|
+
expect(entry.questions.team?.[""]?.threshold).toBeGreaterThan(0);
|
|
289
|
+
expect(entry.questions.rank?.[""]?.threshold).toBeGreaterThan(0);
|
|
290
|
+
});
|
|
291
|
+
});
|