@alisio/core 0.2.0 → 0.3.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 (40) hide show
  1. package/dist/application.d.ts +7 -0
  2. package/dist/application.js +90 -6
  3. package/dist/changelog/data.js +36 -0
  4. package/dist/commands/builtins.js +6 -0
  5. package/dist/commands/catalog.d.ts +8 -1
  6. package/dist/commands/catalog.js +62 -0
  7. package/dist/config.d.ts +37 -3
  8. package/dist/config.js +109 -7
  9. package/dist/core/contracts.d.ts +10 -1
  10. package/dist/core/human-wait.d.ts +25 -0
  11. package/dist/core/human-wait.js +54 -0
  12. package/dist/core/run-clock.d.ts +47 -0
  13. package/dist/core/run-clock.js +111 -0
  14. package/dist/core/run-stats.d.ts +2 -0
  15. package/dist/core/runner.d.ts +15 -1
  16. package/dist/core/runner.js +70 -18
  17. package/dist/core/timeout.js +2 -2
  18. package/dist/decisions/bound.d.ts +23 -0
  19. package/dist/decisions/bound.js +48 -0
  20. package/dist/decisions/breaker.d.ts +35 -0
  21. package/dist/decisions/breaker.js +61 -0
  22. package/dist/decisions/format.d.ts +15 -0
  23. package/dist/decisions/format.js +89 -0
  24. package/dist/decisions/index.d.ts +7 -0
  25. package/dist/decisions/index.js +7 -0
  26. package/dist/decisions/metrics.d.ts +45 -0
  27. package/dist/decisions/metrics.js +79 -0
  28. package/dist/decisions/registry.d.ts +22 -0
  29. package/dist/decisions/registry.js +64 -0
  30. package/dist/decisions/service.d.ts +137 -0
  31. package/dist/decisions/service.js +377 -0
  32. package/dist/decisions/validate.d.ts +26 -0
  33. package/dist/decisions/validate.js +182 -0
  34. package/dist/goal/host.d.ts +2 -0
  35. package/dist/goal/host.js +2 -1
  36. package/dist/index.d.ts +5 -2
  37. package/dist/index.js +3 -0
  38. package/dist/plugins/host.d.ts +43 -2
  39. package/dist/plugins/host.js +111 -16
  40. package/package.json +2 -2
@@ -0,0 +1,377 @@
1
+ import { DecisionRequestError, } from "@alisio/sdk";
2
+ import { CircuitBreaker } from "./breaker.js";
3
+ import { splitSupported, validateAnswers, validateRequest } from "./validate.js";
4
+ export const DEFAULT_DECISIONS_CONFIG = {
5
+ enabled: true,
6
+ provider: null,
7
+ timeoutMs: 1500,
8
+ minConfidence: 0.6,
9
+ telemetry: true,
10
+ };
11
+ /** `/decisions` asks the provider's health with its own short limit. */
12
+ export const HEALTH_TIMEOUT_MS = 1000;
13
+ /** `decide()` failure: the typed counterpart of a `null` from `tryDecide`. */
14
+ export class DecisionError extends Error {
15
+ reason;
16
+ constructor(reason, message) {
17
+ super(message ?? `Decision unavailable: ${reason}`);
18
+ this.reason = reason;
19
+ this.name = "DecisionError";
20
+ }
21
+ }
22
+ const isProviderError = (error) => !!error &&
23
+ typeof error === "object" &&
24
+ error.name === "DecisionProviderError" &&
25
+ typeof error.code === "string";
26
+ const finiteUnits = (usage) => {
27
+ if (!usage || typeof usage !== "object")
28
+ return undefined;
29
+ const out = {};
30
+ for (const key of ["inputUnits", "outputUnits"]) {
31
+ const value = usage[key];
32
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0)
33
+ out[key] = value;
34
+ }
35
+ return Object.keys(out).length ? out : undefined;
36
+ };
37
+ /**
38
+ * Decision Intelligence service: one active provider (chosen by configuration), request and answer
39
+ * validation per decision, a core-enforced time limit and a circuit breaker. Without an active
40
+ * provider it is a silent null object: no metrics, no events.
41
+ */
42
+ export class DecisionService {
43
+ registry;
44
+ config;
45
+ metrics;
46
+ now;
47
+ breaker;
48
+ /** Registration the breaker state belongs to; a change of provider resets it. */
49
+ breakerOwner;
50
+ lifecycle;
51
+ /** The registration `activate` was last requested for (the one that is active, lifecycle-wise). */
52
+ lifecycleEntry;
53
+ lifecycleOn = false;
54
+ closed = false;
55
+ /** Per-registration queue: `deactivate` never crosses a still-running `activate`. */
56
+ queues = new Map();
57
+ lifecycleError;
58
+ constructor(deps) {
59
+ this.registry = deps.registry;
60
+ this.config = deps.config;
61
+ this.metrics = deps.metrics;
62
+ this.now = deps.now ?? (() => Date.now());
63
+ this.breaker = new CircuitBreaker({ now: this.now, ...deps.breaker });
64
+ this.lifecycle = deps.lifecycle ?? {};
65
+ // Registrations that happen after the first `syncActive` (late plugins, removals) follow it.
66
+ this.registry.onChange(() => {
67
+ if (this.lifecycleOn)
68
+ this.syncActive();
69
+ });
70
+ }
71
+ /**
72
+ * Lifecycle (ADR-11): makes `activate`/`deactivate` follow what the configuration and the
73
+ * registry say is the active provider. Idempotent, synchronous and never throws: the hooks run
74
+ * in the background (bounded, serialized per provider, failures recorded). The first call (after
75
+ * the plugins loaded) also arms the follow-up on registry changes.
76
+ */
77
+ syncActive() {
78
+ if (this.closed)
79
+ return;
80
+ this.lifecycleOn = true;
81
+ const config = this.config();
82
+ const wanted = config.enabled && config.provider ? this.registry.get(config.provider) : undefined;
83
+ const previous = this.lifecycleEntry;
84
+ if (wanted === previous)
85
+ return;
86
+ this.lifecycleEntry = wanted;
87
+ if (previous)
88
+ this.enqueue(previous, "deactivate");
89
+ if (wanted)
90
+ this.enqueue(wanted, "activate");
91
+ }
92
+ /** Close: deactivates the active provider (bounded by `timeoutMs`) and stops following changes. */
93
+ async shutdown(timeoutMs) {
94
+ this.closed = true;
95
+ this.lifecycleOn = false;
96
+ const previous = this.lifecycleEntry;
97
+ this.lifecycleEntry = undefined;
98
+ if (!previous)
99
+ return;
100
+ await this.enqueue(previous, "deactivate", timeoutMs);
101
+ }
102
+ enqueue(entry, phase, timeoutMs) {
103
+ const next = (this.queues.get(entry) ?? Promise.resolve()).then(() => this.runLifecycle(entry, phase, timeoutMs));
104
+ this.queues.set(entry, next);
105
+ return next;
106
+ }
107
+ /** Runs one hook with a limit; resolves always (a failure is recorded, never propagated). */
108
+ async runLifecycle(entry, phase, timeoutMs = this.lifecycle.timeoutMs?.() ?? 15_000) {
109
+ const { provider } = entry;
110
+ const hook = provider[phase];
111
+ if (typeof hook !== "function")
112
+ return;
113
+ let timer;
114
+ try {
115
+ await Promise.race([
116
+ Promise.resolve().then(() => hook.call(provider)),
117
+ new Promise((_, reject) => {
118
+ timer = setTimeout(() => reject(new Error(`${phase} timed out after ${timeoutMs}ms`)), timeoutMs);
119
+ }),
120
+ ]);
121
+ if (phase === "activate" && this.lifecycleError?.provider === provider.id)
122
+ this.lifecycleError = undefined;
123
+ }
124
+ catch (error) {
125
+ const message = error instanceof Error ? error.message : String(error);
126
+ this.lifecycleError = { provider: provider.id, phase, message, at: this.now() };
127
+ try {
128
+ this.lifecycle.log?.(`decision provider ${provider.id}: ${phase} failed: ${message}`);
129
+ }
130
+ catch {
131
+ /* A logger cannot break the lifecycle. */
132
+ }
133
+ }
134
+ finally {
135
+ clearTimeout(timer);
136
+ }
137
+ }
138
+ active() {
139
+ const config = this.config();
140
+ if (!config.enabled || !config.provider)
141
+ return undefined;
142
+ const entry = this.registry.get(config.provider);
143
+ if (!entry)
144
+ return undefined;
145
+ if (this.breakerOwner !== entry) {
146
+ this.breakerOwner = entry;
147
+ this.breaker.reset();
148
+ }
149
+ return { provider: entry.provider, config };
150
+ }
151
+ available() {
152
+ return this.active() !== undefined;
153
+ }
154
+ activeProvider() {
155
+ const active = this.active();
156
+ return active ? { id: active.provider.id, name: active.provider.name } : null;
157
+ }
158
+ async tryDecide(req, opts) {
159
+ return (await this.attempt(req, opts)).response;
160
+ }
161
+ /** Like `tryDecide`, but a missing response throws a typed `DecisionError`. */
162
+ async decide(req, opts) {
163
+ const { response, outcome } = await this.attempt(req, opts);
164
+ if (response)
165
+ return response;
166
+ if (outcome?.status === "fallback")
167
+ throw new DecisionError(outcome.reason);
168
+ throw new DecisionError(this.config().enabled ? "no_provider" : "disabled");
169
+ }
170
+ /**
171
+ * The full result of one call, with the outcome the run integration turns into an event.
172
+ * Throws `DecisionRequestError` for a malformed request and rethrows the caller's abort.
173
+ */
174
+ async attempt(req, opts = {}, ctx = {}) {
175
+ validateRequest(req);
176
+ if (opts.timeoutMs !== undefined && !(Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0))
177
+ throw new DecisionRequestError("timeoutMs must be a positive number");
178
+ if (opts.minConfidence !== undefined &&
179
+ !(Number.isFinite(opts.minConfidence) && opts.minConfidence >= 0 && opts.minConfidence <= 1))
180
+ throw new DecisionRequestError("minConfidence must be within [0, 1]");
181
+ opts.signal?.throwIfAborted();
182
+ const active = this.active();
183
+ if (!active)
184
+ return { response: null };
185
+ const { provider, config } = active;
186
+ const timeoutMs = opts.timeoutMs ?? config.timeoutMs;
187
+ const minConfidence = opts.minConfidence ?? config.minConfidence;
188
+ const base = {
189
+ decisionId: req.id,
190
+ ...(req.pack ? { pack: { ...req.pack } } : {}),
191
+ provider: provider.id,
192
+ };
193
+ const fallback = (reason, latencyMs) => {
194
+ const outcome = { status: "fallback", ...base, latencyMs, reason };
195
+ this.metrics.record(outcome, ctx.sessionId);
196
+ return { response: null, outcome };
197
+ };
198
+ const { supported, unsupported } = splitSupported(req, provider);
199
+ if (supported.length === 0)
200
+ return fallback("unsupported", 0);
201
+ const mode = this.breaker.allow();
202
+ if (mode === "open")
203
+ return fallback("circuit_open", 0);
204
+ // From here every path must settle the breaker (probe included).
205
+ let settled = false;
206
+ const settle = (verdict) => {
207
+ if (settled)
208
+ return;
209
+ settled = true;
210
+ this.breaker[verdict]();
211
+ };
212
+ const sent = unsupported.length === 0
213
+ ? req
214
+ : {
215
+ ...req,
216
+ decisions: Object.fromEntries(supported.map((key) => [key, req.decisions[key]])),
217
+ };
218
+ const started = this.now();
219
+ const controller = new AbortController();
220
+ let timer;
221
+ let onAbort;
222
+ const outcome = await new Promise((resolve) => {
223
+ timer = setTimeout(() => {
224
+ controller.abort(new DOMException("Decision timed out", "TimeoutError"));
225
+ resolve({ kind: "timeout" });
226
+ }, timeoutMs);
227
+ if (opts.signal) {
228
+ onAbort = () => {
229
+ controller.abort(opts.signal?.reason);
230
+ resolve({ kind: "aborted" });
231
+ };
232
+ opts.signal.addEventListener("abort", onAbort, { once: true });
233
+ }
234
+ let call;
235
+ try {
236
+ call = Promise.resolve(provider.decide(sent, { signal: controller.signal, timeoutMs }));
237
+ }
238
+ catch (error) {
239
+ call = Promise.reject(error);
240
+ }
241
+ // A provider that ignores the signal must not retain or crash anything after the race ends.
242
+ call.then((value) => resolve({ kind: "result", value }), (error) => resolve({ kind: "error", error }));
243
+ }).finally(() => {
244
+ clearTimeout(timer);
245
+ if (onAbort)
246
+ opts.signal?.removeEventListener("abort", onAbort);
247
+ });
248
+ const latencyMs = Math.max(0, this.now() - started);
249
+ try {
250
+ switch (outcome.kind) {
251
+ case "aborted":
252
+ settle("neutral");
253
+ throw opts.signal?.reason ?? new DOMException("Aborted", "AbortError");
254
+ case "timeout":
255
+ settle("failure");
256
+ return fallback("timeout", latencyMs);
257
+ case "error": {
258
+ const error = outcome.error;
259
+ if (isProviderError(error)) {
260
+ switch (error.code) {
261
+ case "not_ready":
262
+ case "unavailable":
263
+ settle("neutral");
264
+ return fallback(error.code, latencyMs);
265
+ case "timeout":
266
+ case "invalid_response":
267
+ settle("failure");
268
+ return fallback(error.code, latencyMs);
269
+ default:
270
+ settle("failure");
271
+ return fallback("provider_error", latencyMs);
272
+ }
273
+ }
274
+ settle("failure");
275
+ return fallback("provider_error", latencyMs);
276
+ }
277
+ case "result": {
278
+ let accepted;
279
+ let rejected;
280
+ try {
281
+ ({ accepted, rejected } = validateAnswers(sent, provider, outcome.value, minConfidence));
282
+ }
283
+ catch {
284
+ settle("failure");
285
+ return fallback("invalid_response", latencyMs);
286
+ }
287
+ settle("success");
288
+ for (const key of unsupported)
289
+ rejected[key] = "unsupported";
290
+ const confidences = Object.values(accepted).map((a) => a.confidence);
291
+ if (confidences.length === 0)
292
+ return fallback("all_rejected", latencyMs);
293
+ const usage = finiteUnits(outcome.value.usage);
294
+ const response = {
295
+ provider: provider.id,
296
+ latencyMs,
297
+ decisions: accepted,
298
+ rejected,
299
+ ...(usage ? { usage } : {}),
300
+ };
301
+ const done = {
302
+ status: "completed",
303
+ ...base,
304
+ latencyMs,
305
+ decisionCount: confidences.length,
306
+ rejectedCount: Object.keys(rejected).length,
307
+ confidenceMin: Math.min(...confidences),
308
+ };
309
+ this.metrics.record(done, ctx.sessionId);
310
+ return { response, outcome: done };
311
+ }
312
+ }
313
+ }
314
+ finally {
315
+ settle("neutral");
316
+ }
317
+ }
318
+ /** For `/decisions` only: the one place `health()` is called, with its own short limit. */
319
+ async status(signal) {
320
+ const config = this.config();
321
+ const active = this.active();
322
+ const status = {
323
+ enabled: config.enabled,
324
+ configured: config.provider,
325
+ active: active
326
+ ? {
327
+ id: active.provider.id,
328
+ name: active.provider.name,
329
+ capabilities: { ...active.provider.capabilities },
330
+ }
331
+ : null,
332
+ registered: this.registry.list(),
333
+ circuit: this.breaker.state(),
334
+ ...(this.lifecycleError ? { lastLifecycleError: { ...this.lifecycleError } } : {}),
335
+ };
336
+ const health = active?.provider.health;
337
+ if (!active || typeof health !== "function")
338
+ return status;
339
+ const controller = new AbortController();
340
+ const abort = () => controller.abort(signal?.reason);
341
+ signal?.addEventListener("abort", abort, { once: true });
342
+ let timer;
343
+ try {
344
+ status.health = await new Promise((resolve) => {
345
+ timer = setTimeout(() => {
346
+ controller.abort(new DOMException("Health check timed out", "TimeoutError"));
347
+ resolve({ status: "unavailable", detail: "health check timed out" });
348
+ }, HEALTH_TIMEOUT_MS);
349
+ let call;
350
+ try {
351
+ call = Promise.resolve(health.call(active.provider, controller.signal));
352
+ }
353
+ catch (error) {
354
+ call = Promise.reject(error);
355
+ }
356
+ call.then((value) => {
357
+ const ok = value &&
358
+ ["ready", "starting", "unavailable"].includes(value.status);
359
+ resolve(ok
360
+ ? {
361
+ status: value.status,
362
+ ...(typeof value.detail === "string" ? { detail: value.detail } : {}),
363
+ }
364
+ : { status: "unavailable", detail: "invalid health response" });
365
+ }, (error) => resolve({
366
+ status: "unavailable",
367
+ detail: error instanceof Error ? error.message : String(error),
368
+ }));
369
+ });
370
+ }
371
+ finally {
372
+ clearTimeout(timer);
373
+ signal?.removeEventListener("abort", abort);
374
+ }
375
+ return status;
376
+ }
377
+ }
@@ -0,0 +1,26 @@
1
+ import { type DecisionAnswer, type DecisionProvider, type DecisionRejection, type DecisionRequest } from "@alisio/sdk";
2
+ export declare const DECISION_LIMITS: {
3
+ readonly maxDecisions: 16;
4
+ readonly maxOptions: 20;
5
+ readonly minOptions: 2;
6
+ readonly maxLevels: 12;
7
+ readonly minLevels: 2;
8
+ readonly stateBytes: number;
9
+ readonly requestBytes: number;
10
+ };
11
+ /** Pure. Throws `DecisionRequestError` (a `TypeError`) before any provider is called. */
12
+ export declare function validateRequest(req: unknown): asserts req is DecisionRequest;
13
+ /** Which decisions the provider can take and which it cannot (those are never sent). */
14
+ export declare function splitSupported(req: DecisionRequest, provider: Pick<DecisionProvider, "capabilities">): {
15
+ supported: string[];
16
+ unsupported: string[];
17
+ };
18
+ /**
19
+ * Pure. Validates a provider result decision by decision against the request. Answers are rebuilt
20
+ * from known fields only. A result that is not `{ decisions: {…} }` is a provider failure and
21
+ * throws `DecisionProviderError("invalid_response")`.
22
+ */
23
+ export declare function validateAnswers(req: DecisionRequest, provider: Pick<DecisionProvider, "capabilities">, raw: unknown, minConfidence: number): {
24
+ accepted: Record<string, DecisionAnswer>;
25
+ rejected: Record<string, DecisionRejection>;
26
+ };
@@ -0,0 +1,182 @@
1
+ import { DecisionProviderError, DecisionRequestError, } from "@alisio/sdk";
2
+ export const DECISION_LIMITS = {
3
+ maxDecisions: 16,
4
+ maxOptions: 20,
5
+ minOptions: 2,
6
+ maxLevels: 12,
7
+ minLevels: 2,
8
+ stateBytes: 16 * 1024,
9
+ requestBytes: 32 * 1024,
10
+ };
11
+ const DECISION_KEY = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
12
+ const REQUEST_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/;
13
+ const LANGUAGE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
14
+ const encoder = new TextEncoder();
15
+ const isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
16
+ const hasOwn = (object, key) => Object.hasOwn(object, key);
17
+ const nonEmpty = (value) => typeof value === "string" && value.trim().length > 0;
18
+ const unit = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
19
+ function bytes(value, what) {
20
+ let json;
21
+ try {
22
+ json = JSON.stringify(value);
23
+ }
24
+ catch {
25
+ throw new DecisionRequestError(`${what} is not JSON-serializable`);
26
+ }
27
+ if (json === undefined)
28
+ throw new DecisionRequestError(`${what} is not JSON-serializable`);
29
+ return encoder.encode(json).length;
30
+ }
31
+ function validateDefinition(key, definition) {
32
+ const at = `decision "${key}"`;
33
+ if (!isRecord(definition))
34
+ throw new DecisionRequestError(`${at} must be an object`);
35
+ if (!nonEmpty(definition.instruction))
36
+ throw new DecisionRequestError(`${at} needs a non-empty instruction`);
37
+ switch (definition.type) {
38
+ case "select": {
39
+ const options = definition.options;
40
+ if (!isRecord(options))
41
+ throw new DecisionRequestError(`${at} needs an options object`);
42
+ const keys = Object.keys(options);
43
+ if (keys.length < DECISION_LIMITS.minOptions || keys.length > DECISION_LIMITS.maxOptions)
44
+ throw new DecisionRequestError(`${at} needs ${DECISION_LIMITS.minOptions}..${DECISION_LIMITS.maxOptions} options`);
45
+ for (const optionKey of keys) {
46
+ if (!optionKey.trim() || optionKey === "__proto__")
47
+ throw new DecisionRequestError(`${at} has an empty or reserved option key`);
48
+ if (!nonEmpty(options[optionKey]))
49
+ throw new DecisionRequestError(`${at} option "${optionKey}" needs a description`);
50
+ }
51
+ return;
52
+ }
53
+ case "boolean":
54
+ for (const meaning of ["trueMeaning", "falseMeaning"])
55
+ if (definition[meaning] !== undefined && !nonEmpty(definition[meaning]))
56
+ throw new DecisionRequestError(`${at} ${meaning} must be a non-empty string`);
57
+ return;
58
+ case "ordinal": {
59
+ const levels = definition.levels;
60
+ if (!Array.isArray(levels) ||
61
+ levels.length < DECISION_LIMITS.minLevels ||
62
+ levels.length > DECISION_LIMITS.maxLevels)
63
+ throw new DecisionRequestError(`${at} needs ${DECISION_LIMITS.minLevels}..${DECISION_LIMITS.maxLevels} levels`);
64
+ if (!levels.every(nonEmpty))
65
+ throw new DecisionRequestError(`${at} has an empty level`);
66
+ if (new Set(levels).size !== levels.length)
67
+ throw new DecisionRequestError(`${at} has duplicate levels`);
68
+ return;
69
+ }
70
+ default:
71
+ throw new DecisionRequestError(`${at} has an unknown type`);
72
+ }
73
+ }
74
+ /** Pure. Throws `DecisionRequestError` (a `TypeError`) before any provider is called. */
75
+ export function validateRequest(req) {
76
+ if (!isRecord(req))
77
+ throw new DecisionRequestError("Decision request must be an object");
78
+ if (req.version !== 1)
79
+ throw new DecisionRequestError(`Unsupported decision request version: ${String(req.version)}`);
80
+ if (typeof req.id !== "string" || !REQUEST_ID.test(req.id))
81
+ throw new DecisionRequestError("Decision request id must match ^[a-z0-9][a-z0-9._-]{0,127}$");
82
+ if (req.language !== undefined &&
83
+ (typeof req.language !== "string" || !LANGUAGE.test(req.language)))
84
+ throw new DecisionRequestError("Decision request language must be a BCP 47 tag");
85
+ if (req.pack !== undefined) {
86
+ const pack = req.pack;
87
+ if (!isRecord(pack) ||
88
+ typeof pack.id !== "string" ||
89
+ !REQUEST_ID.test(pack.id) ||
90
+ !Number.isInteger(pack.version) ||
91
+ pack.version < 0)
92
+ throw new DecisionRequestError("Decision request pack must be { id, version }");
93
+ }
94
+ if (!("state" in req) || req.state === undefined)
95
+ throw new DecisionRequestError("Decision request needs a state");
96
+ if (bytes(req.state, "state") > DECISION_LIMITS.stateBytes)
97
+ throw new DecisionRequestError(`state exceeds ${DECISION_LIMITS.stateBytes} bytes`);
98
+ const decisions = req.decisions;
99
+ if (!isRecord(decisions))
100
+ throw new DecisionRequestError("Decision request needs decisions");
101
+ const keys = Object.keys(decisions);
102
+ if (keys.length < 1 || keys.length > DECISION_LIMITS.maxDecisions)
103
+ throw new DecisionRequestError(`Decision request needs 1..${DECISION_LIMITS.maxDecisions} decisions`);
104
+ for (const key of keys) {
105
+ if (!DECISION_KEY.test(key))
106
+ throw new DecisionRequestError(`Invalid decision key: ${key.slice(0, 80)}`);
107
+ validateDefinition(key, decisions[key]);
108
+ }
109
+ if (bytes(req, "request") > DECISION_LIMITS.requestBytes)
110
+ throw new DecisionRequestError(`Decision request exceeds ${DECISION_LIMITS.requestBytes} bytes`);
111
+ }
112
+ /** Which decisions the provider can take and which it cannot (those are never sent). */
113
+ export function splitSupported(req, provider) {
114
+ const supported = [];
115
+ const unsupported = [];
116
+ for (const [key, definition] of Object.entries(req.decisions))
117
+ (provider.capabilities[definition.type] === true ? supported : unsupported).push(key);
118
+ return { supported, unsupported };
119
+ }
120
+ function rebuild(definition, raw) {
121
+ if (!isRecord(raw) || raw.type !== definition.type || !unit(raw.confidence))
122
+ return undefined;
123
+ const confidence = raw.confidence;
124
+ switch (definition.type) {
125
+ case "select": {
126
+ if (typeof raw.value !== "string" || !hasOwn(definition.options, raw.value))
127
+ return undefined;
128
+ const answer = { type: "select", value: raw.value, confidence };
129
+ const p = raw.probabilities;
130
+ if (isRecord(p) &&
131
+ Object.entries(p).every(([k, v]) => hasOwn(definition.options, k) && unit(v)))
132
+ answer.probabilities = Object.fromEntries(Object.entries(p));
133
+ return answer;
134
+ }
135
+ case "boolean":
136
+ if (typeof raw.value !== "boolean" || !unit(raw.probability))
137
+ return undefined;
138
+ return { type: "boolean", value: raw.value, confidence, probability: raw.probability };
139
+ case "ordinal": {
140
+ if (typeof raw.level !== "string")
141
+ return undefined;
142
+ const index = definition.levels.indexOf(raw.level);
143
+ if (index < 0 || raw.index !== index)
144
+ return undefined;
145
+ const answer = { type: "ordinal", level: raw.level, index, confidence };
146
+ const d = raw.distribution;
147
+ if (Array.isArray(d) && d.length === definition.levels.length && d.every(unit))
148
+ answer.distribution = [...d];
149
+ return answer;
150
+ }
151
+ }
152
+ }
153
+ /**
154
+ * Pure. Validates a provider result decision by decision against the request. Answers are rebuilt
155
+ * from known fields only. A result that is not `{ decisions: {…} }` is a provider failure and
156
+ * throws `DecisionProviderError("invalid_response")`.
157
+ */
158
+ export function validateAnswers(req, provider, raw, minConfidence) {
159
+ if (!isRecord(raw) || !isRecord(raw.decisions))
160
+ throw new DecisionProviderError("invalid_response", "Provider result has no decisions object");
161
+ const answers = raw.decisions;
162
+ const accepted = {};
163
+ const rejected = {};
164
+ for (const [key, definition] of Object.entries(req.decisions)) {
165
+ if (provider.capabilities[definition.type] !== true) {
166
+ rejected[key] = "unsupported";
167
+ continue;
168
+ }
169
+ if (!hasOwn(answers, key)) {
170
+ rejected[key] = "missing";
171
+ continue;
172
+ }
173
+ const answer = rebuild(definition, answers[key]);
174
+ if (!answer)
175
+ rejected[key] = "invalid";
176
+ else if (answer.confidence < minConfidence)
177
+ rejected[key] = "low_confidence";
178
+ else
179
+ accepted[key] = answer;
180
+ }
181
+ return { accepted, rejected };
182
+ }
@@ -8,6 +8,8 @@ export interface RunSummary {
8
8
  output: number;
9
9
  };
10
10
  toolCalls?: number;
11
+ /** Active time of the run: waits for a person are not counted. */
12
+ activeMs?: number;
11
13
  }
12
14
  /**
13
15
  * The outcome of one run. `cancelled` is whether the run's abort signal fired (the user stopped
package/dist/goal/host.js CHANGED
@@ -30,7 +30,8 @@ export function goalOutcome(input) {
30
30
  text: result?.text ?? "",
31
31
  toolCalls: result?.toolCalls ?? stats?.toolCalls ?? 0,
32
32
  startedAt: input.startedAt,
33
- durationMs: Math.max(0, input.endedAt - input.startedAt),
33
+ // `goal.maxMinutes` counts the time a run was working, never the time it waited for you.
34
+ durationMs: Math.max(0, Math.min(input.endedAt - input.startedAt, result?.activeMs ?? stats?.activeMs ?? Number.POSITIVE_INFINITY)),
34
35
  };
35
36
  }
36
37
  /**
package/dist/index.d.ts CHANGED
@@ -33,11 +33,14 @@ export { changelogNews, compareVersions, formatChangelogMarkdown, isVersion, loa
33
33
  export { BUILTIN_COMMANDS, type BuiltinCommand, CommandCatalog, type CommandExecutionContext, type CommandHost, type CommandResult, type CommandSurface, } from "./commands/catalog.ts";
34
34
  export { type Config, type ConfigLoadResult, type ConfigProvenance, configFile, configHome, configSchema, isSettableSettingKey, type LoadedConfig, loadConfig, loadConfigWithProvenance, type McpServerSource, OCI_IMAGE_PATTERN, overridesSavedProviderProfile, type SettableSettingInfo, type SettableSettingKey, setConfigValue, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, settableSettings, stateHome, } from "./config.ts";
35
35
  export { type ContextBudget, checkpointInstructions, completeText, effectiveContextBudget, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, renderCheckpoint, serializeForSummary, shouldCompact, shouldCompactContext, summarize, } from "./core/compaction.ts";
36
- export type { ApprovalDecision, ApprovalHandler, ApprovalRequest, ArtifactPublisherFactory, BeginRunInput, CapabilityGate, ContextSource, CoreArtifactPublisher, EndRunInput, EventPage, HookFailure, MessagePage, PageOptions, Policy, PublishedArtifactInfo, RunnerExtensions, RunRecord, RunStatus, Session, SessionStore, StoredEvent, TerminalRunStatus, ToolCallMeta, } from "./core/contracts.ts";
36
+ export type { ApprovalDecision, ApprovalHandler, ApprovalRequest, ArtifactPublisherFactory, BeginRunInput, CapabilityGate, ContextSource, CoreArtifactPublisher, DecisionsFactory, EndRunInput, EventPage, HookFailure, MessagePage, PageOptions, Policy, PublishedArtifactInfo, RunnerExtensions, RunRecord, RunStatus, Session, SessionStore, StoredEvent, TerminalRunStatus, ToolCallMeta, } from "./core/contracts.ts";
37
+ export { HumanWaits } from "./core/human-wait.ts";
37
38
  export { EXIT_PLAN_TOOL, GOAL_TOOLS, OPT_IN_TOOLS } from "./core/opt-in.ts";
38
39
  export { DEFAULT_MAX_OUTPUT_TOKENS, describeOutputLimitSource, MAX_AUTO_OUTPUT_TOKENS, type OutputLimit, type OutputLimitSource, resolveMaxOutputTokens, } from "./core/output-limit.ts";
39
40
  export { ToolRegistry } from "./core/registry.ts";
41
+ export { RunClock } from "./core/run-clock.ts";
40
42
  export { AgentRunner, type CompactionResult, defaultTokenBudget, type RunnerOptions, type RunnerSettingsPatch, type RunOptions, } from "./core/runner.ts";
43
+ export { CircuitBreaker, type CircuitBreakerOptions, type CircuitState, DECISION_LIMITS, DEFAULT_DECISIONS_CONFIG, type DecisionAttempt, DecisionError, type DecisionMetricEntry, DecisionMetrics, type DecisionMetricsSnapshot, type DecisionOutcome, DecisionRegistry, DecisionService, type DecisionServiceDeps, type DecisionStatus, type DecisionsConfig, formatDecisionStatsSection, formatDecisionsReport, HEALTH_TIMEOUT_MS, type RegisteredDecisionProvider, splitSupported, validateAnswers, validateRequest, } from "./decisions/index.ts";
41
44
  export { type ExtensionConflict, ExtensionRegistry } from "./extensions/registry.ts";
42
45
  export * from "./goal/index.ts";
43
46
  export { HerdrBridge } from "./integrations/herdr.ts";
@@ -46,7 +49,7 @@ export { describePermissionStatus, type EffectState, effectState, FULL_ACCESS_WA
46
49
  export { DIAGRAM_MAX_BYTES, DIAGRAM_MAX_NODES, DIAGRAM_SYNTAXES, DIAGRAMS_MAX_ALLOWED, mermaidProblem, type PlanDiagram, validateDiagrams, } from "./plan/diagrams.ts";
47
50
  export { buildPlanManifest, parsePlanHeadings, slugifyHeading, } from "./plan/manifest.ts";
48
51
  export { claimApprovedPlan, currentPlan, discardApprovedPlan, IMPLEMENTATION_AGENT_ID, implementationPrompt, PLAN_CONTEXT_MAX_CHARS, PLAN_OPTIONS, PLAN_QUESTION_ID, PLAN_REVIEW_TITLE, PLAN_TEXT_KEY, type PlanFollowUp, type PlanState, type PlanStatus, planHash, readPlanState, settlePlanRun, withdrawPendingPlan, } from "./plan/state.ts";
49
- export { discoverPlugins, PluginHost, type PluginHostOptions, pluginPrefix, type ViewInfo, ViewRunError, } from "./plugins/host.ts";
52
+ export { discoverPlugins, type PluginEnvironment, PluginHost, type PluginHostOptions, pluginPrefix, type ViewInfo, ViewRunError, } from "./plugins/host.ts";
50
53
  export { type CliInstallOptions, cliInstall, INSTALL_TIMEOUT_MS, type InstallPluginInput, type InstallPluginResult, type InstallRunner, installedNpmPlugins, installPlugin, npmInstallTarget, type PluginSpec, parsePluginSpec, pluginTrustRemark, printInstallResult, registerPluginInstallTool, sanitizeNpmError, } from "./plugins/install.ts";
51
54
  export { ActiveProvider, ProviderRegistry, UnconfiguredProvider } from "./providers/registry.ts";
52
55
  export { availableProviderModels, type ProviderModelCatalog, resolveProviderModel, } from "./providers/routing.ts";
package/dist/index.js CHANGED
@@ -33,10 +33,13 @@ export { changelogNews, compareVersions, formatChangelogMarkdown, isVersion, loa
33
33
  export { BUILTIN_COMMANDS, CommandCatalog, } from "./commands/catalog.js";
34
34
  export { configFile, configHome, configSchema, isSettableSettingKey, loadConfig, loadConfigWithProvenance, OCI_IMAGE_PATTERN, overridesSavedProviderProfile, setConfigValue, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, settableSettings, stateHome, } from "./config.js";
35
35
  export { checkpointInstructions, completeText, effectiveContextBudget, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, renderCheckpoint, serializeForSummary, shouldCompact, shouldCompactContext, summarize, } from "./core/compaction.js";
36
+ export { HumanWaits } from "./core/human-wait.js";
36
37
  export { EXIT_PLAN_TOOL, GOAL_TOOLS, OPT_IN_TOOLS } from "./core/opt-in.js";
37
38
  export { DEFAULT_MAX_OUTPUT_TOKENS, describeOutputLimitSource, MAX_AUTO_OUTPUT_TOKENS, resolveMaxOutputTokens, } from "./core/output-limit.js";
38
39
  export { ToolRegistry } from "./core/registry.js";
40
+ export { RunClock } from "./core/run-clock.js";
39
41
  export { AgentRunner, defaultTokenBudget, } from "./core/runner.js";
42
+ export { CircuitBreaker, DECISION_LIMITS, DEFAULT_DECISIONS_CONFIG, DecisionError, DecisionMetrics, DecisionRegistry, DecisionService, formatDecisionStatsSection, formatDecisionsReport, HEALTH_TIMEOUT_MS, splitSupported, validateAnswers, validateRequest, } from "./decisions/index.js";
40
43
  export { ExtensionRegistry } from "./extensions/registry.js";
41
44
  export * from "./goal/index.js";
42
45
  export { HerdrBridge } from "./integrations/herdr.js";