@ngockhoale/ukit 3.1.8 → 3.2.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/CHANGELOG.md +18 -0
- package/package.json +1 -1
- package/src/cli/commands/telemetry.js +45 -3
- package/src/core/agentRuntime/adapters.js +177 -0
- package/src/core/agentRuntime/contract.js +77 -0
- package/src/core/agentRuntime/diagnostics.js +343 -42
- package/src/core/agentRuntime/eventStore.js +139 -0
- package/src/core/agentRuntime/planCompiler.js +45 -6
- package/src/core/agentRuntime/planLibrary.js +43 -7
- package/src/core/agentRuntime/plans/bugfix-loop.json +1 -0
- package/src/core/agentRuntime/plans/flag-promotion.json +138 -0
- package/src/core/agentRuntime/plans/handoff-review-batch.json +1 -0
- package/src/core/agentRuntime/plans/release-check.json +2 -1
- package/src/core/agentRuntime/promotion.js +147 -9
- package/src/core/agentRuntime/runtimeSupport.js +18 -0
- package/src/core/agentRuntime/supervisor.js +44 -2
- package/src/core/agentRuntime/telemetry.js +123 -0
- package/src/core/agentRuntime/vmEngine.js +51 -10
- package/src/core/runtimeConfig.js +18 -6
- package/src/decision/client.js +8 -10
- package/src/decision/runtimeDecide.js +183 -5
- package/template_project/.claude/ukit/index/unic-decision.mjs +5 -6
- package/template_project/ukit/storage/config.json +2 -2
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* emitSpanRecord(telemetry, ctx, semanticName, payload) → EmitResult
|
|
8
8
|
* durationMs(startNs, nowNs) → positive ms | null
|
|
9
9
|
* projectRootFromRuntimeDir(runtimeDir) → projectRoot | null
|
|
10
|
+
* buildTraceExcerpt(records, opts) → {entries, gaps, stats} (V-06)
|
|
10
11
|
*
|
|
11
12
|
* Contracts:
|
|
12
13
|
* - Emission is fire-and-forget: every helper is synchronous, wraps
|
|
@@ -31,6 +32,11 @@ import path from 'node:path';
|
|
|
31
32
|
import { getRecorder } from '../observability/emit/lifecycle.js';
|
|
32
33
|
import { RESOURCE_SOURCES } from '../observability/schema/constants.js';
|
|
33
34
|
import { REASON_CODES } from '../observability/schema/registry.js';
|
|
35
|
+
import { validateSemanticRecord } from '../observability/schema/validate.js';
|
|
36
|
+
import { sanitizeForSupport } from '../observability/privacy/sanitizeForSupport.js';
|
|
37
|
+
import { MAX_SUPPORT_STRING_CHARS } from '../observability/privacy/allowlist.js';
|
|
38
|
+
import { redactString } from '../observability/privacy/redaction.js';
|
|
39
|
+
import { scanText } from '../sensitiveValueScanner.js';
|
|
34
40
|
|
|
35
41
|
/** Monotonic nanoseconds — BigInt where available (process.hrtime). */
|
|
36
42
|
export function nowNs() {
|
|
@@ -55,6 +61,10 @@ function isNonEmptyString(value) {
|
|
|
55
61
|
return typeof value === 'string' && value.length > 0;
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
function isPlainObject(value) {
|
|
65
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
/**
|
|
59
69
|
* agent_id source (SPEC §14): UKIT_AGENT_ID env first, config.agent.id
|
|
60
70
|
* second, absent otherwise. The field is omitted entirely — never ''.
|
|
@@ -202,3 +212,116 @@ export function typedResource(resource) {
|
|
|
202
212
|
if (source && source !== 'UNKNOWN') return { resource: { value, source }, complete: true };
|
|
203
213
|
return { resource: { value, source: 'ESTIMATED' }, complete: true };
|
|
204
214
|
}
|
|
215
|
+
|
|
216
|
+
// --- V-06 trace excerpt for support bundles --------------------------------
|
|
217
|
+
// `buildTraceExcerpt` is the read-side counterpart of emitSpanRecord: it
|
|
218
|
+
// turns recorder-fed SemanticRecords into bounded, sanitized trace lines
|
|
219
|
+
// for a support bundle. Every record passes validateSemanticRecord AND
|
|
220
|
+
// the independent sanitizeForSupport second gate; survivors keep only
|
|
221
|
+
// allowlisted envelope/payload fields, strings pass redactString, and
|
|
222
|
+
// caller-supplied pseudonyms replace raw ids. Non-conforming records are
|
|
223
|
+
// reported as typed gap entries — never silently smuggled, never thrown.
|
|
224
|
+
|
|
225
|
+
const TRACE_ID_FIELDS = Object.freeze([
|
|
226
|
+
'record_id', 'trace_id', 'span_id', 'parent_span_id',
|
|
227
|
+
'execution_id', 'session_id', 'project_ref', 'boot_id', 'writer_id',
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
const TRACE_ENVELOPE_KEEP = new Set([
|
|
231
|
+
'record_type', 'semantic_name', 'schema_version', 'record_id', 'trace_id',
|
|
232
|
+
'span_id', 'parent_span_id', 'execution_id', 'session_id', 'project_ref',
|
|
233
|
+
'boot_id', 'writer_id', 'sequence', 'wall_time_utc', 'monotonic_ns',
|
|
234
|
+
'importance', 'privacy_class', 'redaction_version', 'sampling',
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Build a sanitized trace excerpt from SemanticRecords. Never throws.
|
|
239
|
+
*
|
|
240
|
+
* Every record passes the FULL two-gate chain — validateSemanticRecord
|
|
241
|
+
* then the independent sanitizeForSupport default-deny gate — so a
|
|
242
|
+
* survivor's payload is already allowlist-clean (codes, counts, timings
|
|
243
|
+
* only). The excerpt keeps only the envelope fields named in
|
|
244
|
+
* TRACE_ENVELOPE_KEEP, re-passes strings through redactString, and
|
|
245
|
+
* substitutes caller-supplied pseudonyms for raw ids. Rejects land as
|
|
246
|
+
* typed gap entries — never silently smuggled, never thrown.
|
|
247
|
+
*
|
|
248
|
+
* @param {unknown} records array of records (non-array → 'trace_unavailable')
|
|
249
|
+
* @param {object} [opts]
|
|
250
|
+
* @param {number} [opts.maxRecords=256] entry cap (gaps share the budget)
|
|
251
|
+
* @param {(value:string)=>{hasSecret:boolean}} [opts.scanner] defaults scanText
|
|
252
|
+
* @param {(value:string,kind:string)=>string} [opts.pseudonymize] id rewrite
|
|
253
|
+
* @returns {{ entries: Array, gaps: Array, stats: {in:number, kept:number,
|
|
254
|
+
* rejected:number, truncated:number} }}
|
|
255
|
+
*/
|
|
256
|
+
|
|
257
|
+
export function buildTraceExcerpt(records, opts = {}) {
|
|
258
|
+
const maxRecords = Number.isInteger(opts.maxRecords) ? Math.max(0, opts.maxRecords) : 256;
|
|
259
|
+
const scanner = typeof opts.scanner === 'function' ? opts.scanner : scanText;
|
|
260
|
+
const pseudonymize = typeof opts.pseudonymize === 'function' ? opts.pseudonymize : null;
|
|
261
|
+
const local = { scanner, maxChars: MAX_SUPPORT_STRING_CHARS };
|
|
262
|
+
|
|
263
|
+
const entries = [];
|
|
264
|
+
const gaps = [];
|
|
265
|
+
const stats = { in: 0, kept: 0, rejected: 0, truncated: 0 };
|
|
266
|
+
|
|
267
|
+
if (!Array.isArray(records)) {
|
|
268
|
+
gaps.push({ index: 0, code: 'trace_unavailable' });
|
|
269
|
+
return { entries, gaps, stats };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (const record of records) {
|
|
273
|
+
stats.in += 1;
|
|
274
|
+
if (entries.length + gaps.length >= maxRecords) {
|
|
275
|
+
stats.truncated += 1;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const index = stats.in - 1;
|
|
279
|
+
if (!isPlainObject(record)) {
|
|
280
|
+
stats.rejected += 1;
|
|
281
|
+
gaps.push({ index, code: 'record_not_object' });
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const valid = validateSemanticRecord(record);
|
|
285
|
+
if (!valid.ok) {
|
|
286
|
+
stats.rejected += 1;
|
|
287
|
+
gaps.push({ index, code: 'schema_rejected' });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const gate = sanitizeForSupport(record, { scanner });
|
|
291
|
+
if (!gate.ok) {
|
|
292
|
+
stats.rejected += 1;
|
|
293
|
+
gaps.push({ index, code: 'support_gate_rejected' });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const out = {};
|
|
298
|
+
for (const key of Object.keys(gate.record)) {
|
|
299
|
+
if (!TRACE_ENVELOPE_KEEP.has(key)) continue;
|
|
300
|
+
const value = gate.record[key];
|
|
301
|
+
if (key === 'sampling') {
|
|
302
|
+
const s = isPlainObject(value) ? value : {};
|
|
303
|
+
out.sampling = {
|
|
304
|
+
policy_version: typeof s.policy_version === 'string'
|
|
305
|
+
? redactString(s.policy_version, local) : null,
|
|
306
|
+
rate: typeof s.rate === 'number' && Number.isFinite(s.rate) ? s.rate : null,
|
|
307
|
+
};
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (TRACE_ID_FIELDS.includes(key) && typeof value === 'string') {
|
|
311
|
+
out[key] = pseudonymize ? pseudonymize(value, key) : redactString(value, local);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (typeof value === 'string') {
|
|
315
|
+
out[key] = redactString(value, local);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
out[key] = value;
|
|
319
|
+
}
|
|
320
|
+
// The gate already allowlist-pruned the payload — copy it verbatim.
|
|
321
|
+
out.payload = gate.record.payload;
|
|
322
|
+
|
|
323
|
+
entries.push(out);
|
|
324
|
+
stats.kept += 1;
|
|
325
|
+
}
|
|
326
|
+
return { entries, gaps, stats };
|
|
327
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* agentRuntime/vmEngine.js — decision-first-runtime G4 (DR-06), VM vertical
|
|
3
|
-
* slice, omp-scoped, off-rollout (
|
|
4
|
-
*
|
|
3
|
+
* slice, omp-scoped, off-rollout (`decisionRuntime.vm` gates the V-05
|
|
4
|
+
* decision-plane classifyFn default below; deterministic escalation stays
|
|
5
|
+
* the default lane regardless of the flag).
|
|
5
6
|
*
|
|
6
7
|
* Executes a compiled ValidatedPlan (SPEC §4 frozen shape — literal nodes
|
|
7
8
|
* Map, entryNodes, topological order) purely against G1 durable
|
|
@@ -39,7 +40,11 @@ import {
|
|
|
39
40
|
validateTransition,
|
|
40
41
|
validateSemanticEvent,
|
|
41
42
|
validateRetry,
|
|
43
|
+
validateClassifyVerdict,
|
|
42
44
|
} from './contract.js';
|
|
45
|
+
import { resolveDecisionRuntimeStage } from '../runtimeConfig.js';
|
|
46
|
+
import { createDecisionClient } from '../../decision/client.js';
|
|
47
|
+
import { createVmClassifyFn } from '../../decision/runtimeDecide.js';
|
|
43
48
|
import {
|
|
44
49
|
registerContinuation,
|
|
45
50
|
consumeContinuation,
|
|
@@ -88,18 +93,48 @@ async function writeFileAtomic(target, data) {
|
|
|
88
93
|
await fs.rename(tmp, target);
|
|
89
94
|
}
|
|
90
95
|
|
|
96
|
+
|
|
91
97
|
/**
|
|
92
98
|
* Migration contract (decision plane): `classifyFn` is invoked only when the
|
|
93
99
|
* IR itself cannot route an event (see deliver()). It may only consult the
|
|
94
100
|
* decision plane (unic-decision / createDecisionClient registry keys) for a
|
|
95
101
|
* bounded route/escalate answer — never a general LLM call.
|
|
96
102
|
*
|
|
97
|
-
*
|
|
103
|
+
* V-05 default: when no `classifyFn` is injected AND
|
|
104
|
+
* `decisionRuntime.vm.stage` resolves non-'off', the engine installs the
|
|
105
|
+
* bounded runtimeDecide adapter (runtime.node_route.v1 /
|
|
106
|
+
* runtime.node_classify.v1 through `decisionClient` — or the default
|
|
107
|
+
* createDecisionClient when none is injected). Stage 'off' (absent or
|
|
108
|
+
* malformed) leaves `classifyFn` unset → zero decision calls, byte-identical
|
|
109
|
+
* behaviour to the deterministic escalation lane.
|
|
110
|
+
*
|
|
111
|
+
* @param {{dir:string, now?:()=>Date, classifyFn?:Function, startFn?:Function,
|
|
112
|
+
* hooks?:object, config?:object, decisionClient?:object,
|
|
113
|
+
* decider?:object}} opts
|
|
98
114
|
*/
|
|
99
|
-
export function createVmEngine({
|
|
115
|
+
export function createVmEngine({
|
|
116
|
+
dir,
|
|
117
|
+
now,
|
|
118
|
+
classifyFn,
|
|
119
|
+
startFn,
|
|
120
|
+
hooks,
|
|
121
|
+
config,
|
|
122
|
+
decisionClient,
|
|
123
|
+
decider,
|
|
124
|
+
} = {}) {
|
|
100
125
|
if (typeof dir !== 'string' || dir === '') {
|
|
101
126
|
throw new VmEngineError('malformed_opts', 'dir required');
|
|
102
127
|
}
|
|
128
|
+
// The injected classifyFn always wins; the decision-plane adapter is a
|
|
129
|
+
// stage-gated default for embedders that opt in via runtime config.
|
|
130
|
+
const classify =
|
|
131
|
+
classifyFn
|
|
132
|
+
?? (resolveDecisionRuntimeStage(config ?? null, 'vm') === 'off'
|
|
133
|
+
? undefined
|
|
134
|
+
: createVmClassifyFn({
|
|
135
|
+
decider,
|
|
136
|
+
client: decisionClient ?? createDecisionClient({ config }),
|
|
137
|
+
}));
|
|
103
138
|
const clock = typeof now === 'function' ? now : () => new Date();
|
|
104
139
|
const fire = typeof startFn === 'function' ? startFn : async () => {};
|
|
105
140
|
const instances = new Map(); // planInstanceId -> instance
|
|
@@ -712,20 +747,26 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
712
747
|
|
|
713
748
|
// classify only when the IR itself cannot route the event (G4-FR05);
|
|
714
749
|
// classifyFn may consult the decision plane for bounded route/escalate
|
|
715
|
-
// answers only — never a general LLM call (see createVmEngine opts).
|
|
750
|
+
// answers only — never a general LLM call (see createVmEngine opts). A
|
|
751
|
+
// throwing classifyFn or a malformed verdict is an unclassifiable event,
|
|
752
|
+
// never a hard failure (V-05 never-throws contract).
|
|
716
753
|
let outcome = event.safePayload?.outcome;
|
|
717
754
|
let targetMatched = matched;
|
|
718
755
|
if (!targetMatched) {
|
|
719
|
-
if (typeof
|
|
756
|
+
if (typeof classify !== 'function') {
|
|
720
757
|
return escalateUnclassified(inst, event);
|
|
721
758
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
759
|
+
let verdict = null;
|
|
760
|
+
try {
|
|
761
|
+
verdict = await classify(event, { planInstanceId: pi, nodeId, node });
|
|
762
|
+
} catch {
|
|
763
|
+
verdict = null;
|
|
725
764
|
}
|
|
726
|
-
if (verdict
|
|
765
|
+
if (validateClassifyVerdict(verdict).ok && verdict.action === 'route') {
|
|
727
766
|
outcome = verdict.outcome;
|
|
728
767
|
targetMatched = node != null && inst.nodes[nodeId]?.state === 'running';
|
|
768
|
+
} else {
|
|
769
|
+
return escalateUnclassified(inst, event);
|
|
729
770
|
}
|
|
730
771
|
}
|
|
731
772
|
if (!targetMatched) return escalateUnclassified(inst, event);
|
|
@@ -204,6 +204,18 @@ export function resolveConfigStage(config = null, path) {
|
|
|
204
204
|
return VALID_ROUTE_STAGES.has(node) ? node : 'off';
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
// decisionRuntime family stage resolver (V-03): resolves
|
|
208
|
+
// `decisionRuntime.<key>.stage` for one pinned family flag ('vm',
|
|
209
|
+
// 'supervisor', 'scheduler', 'contract', 'adapters', 'context',
|
|
210
|
+
// 'completion', 'quality', 'promotion', 'resourcePolicy',
|
|
211
|
+
// 'diagnostics'). Same semantics as resolveConfigStage: absent config,
|
|
212
|
+
// absent family, non-string key, or malformed stage all resolve 'off'
|
|
213
|
+
// — a bad config can never promote a decisionRuntime slice.
|
|
214
|
+
export function resolveDecisionRuntimeStage(config = null, key = 'vm') {
|
|
215
|
+
if (typeof key !== 'string' || key.trim() === '') return 'off';
|
|
216
|
+
return resolveConfigStage(config, `decisionRuntime.${key}.stage`);
|
|
217
|
+
}
|
|
218
|
+
|
|
207
219
|
// Pushes a stage-enum error when `node.stage` is present but not reserved.
|
|
208
220
|
// Absent stage is valid (absence = 'off'); a non-object node is reported by
|
|
209
221
|
// the caller's own "must be an object" check.
|
|
@@ -295,16 +307,16 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
|
|
|
295
307
|
},
|
|
296
308
|
// C52 M07: `unic-decision` is the owner's local non-LLM Lava/JEV model in
|
|
297
309
|
// UNIC Provider. Its OpenAI-compatible API is transport only, not proof of
|
|
298
|
-
// remote hosting or generative-LLM billing.
|
|
299
|
-
//
|
|
300
|
-
// deterministic
|
|
301
|
-
//
|
|
310
|
+
// remote hosting or generative-LLM billing. Preferred for every bounded
|
|
311
|
+
// decision since 3.1.9 — stage ships 'default'; `enabled:false` is the
|
|
312
|
+
// global emergency disable and deterministic policy still fills any gap
|
|
313
|
+
// (unavailable/timeout/invalid outcomes).
|
|
302
314
|
decisionPlane: {
|
|
303
315
|
enabled: true,
|
|
304
|
-
stage: '
|
|
316
|
+
stage: 'default',
|
|
305
317
|
logicalModel: 'unic-decision',
|
|
306
318
|
checkpoints: {
|
|
307
|
-
default: 'unic-decision
|
|
319
|
+
default: 'unic-decision',
|
|
308
320
|
english: 'unic-decision',
|
|
309
321
|
unknownLanguage: 'unic-decision',
|
|
310
322
|
},
|
package/src/decision/client.js
CHANGED
|
@@ -42,17 +42,16 @@ import {
|
|
|
42
42
|
} from './statePacket.js';
|
|
43
43
|
|
|
44
44
|
const DEFAULT_CHECKPOINTS = {
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
default: 'unic-decision
|
|
45
|
+
// Locked 2026-09-26: every language class routes to the single
|
|
46
|
+
// credential-safe 'unic-decision' model — the provider swaps the backend
|
|
47
|
+
// (Lava/JEV) behind that name, so UKit never needs per-class variants.
|
|
48
|
+
default: 'unic-decision',
|
|
49
49
|
english: 'unic-decision',
|
|
50
50
|
unknownLanguage: 'unic-decision',
|
|
51
51
|
};
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// the client falls back here once per batch.
|
|
52
|
+
// Credential-safe alias: every UNIC decision credential binds the bare name.
|
|
53
|
+
// On model_not_found the client falls back here once per batch — the bare
|
|
54
|
+
// name is also the only checkpoint name UKit ever sends.
|
|
56
55
|
const FALLBACK_CHECKPOINT = 'unic-decision';
|
|
57
56
|
|
|
58
57
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
@@ -361,8 +360,7 @@ export function createDecisionClient({
|
|
|
361
360
|
|
|
362
361
|
const status = res?.status ?? (res?.ok === false ? 500 : 200);
|
|
363
362
|
if (res?.ok === false || status < 200 || status >= 300) {
|
|
364
|
-
// A
|
|
365
|
-
// unic-decision-multilingual before provider binding) returns a 4xx
|
|
363
|
+
// A checkpoint without bound credentials returns a 4xx
|
|
366
364
|
// model_not_found body — retry once on the bare `unic-decision`
|
|
367
365
|
// model, the credential-safe alias the owner provisions first.
|
|
368
366
|
if (checkpoint !== FALLBACK_CHECKPOINT) {
|
|
@@ -83,6 +83,46 @@ export const RUNTIME_DECISIONS = Object.freeze([
|
|
|
83
83
|
'A runtime event may warrant a retry but no deterministic policy covers it. Decide conservatively.',
|
|
84
84
|
candidates: ['retry', 'no_retry', 'escalate'],
|
|
85
85
|
},
|
|
86
|
+
// V-05: VM decision nodes — the bounded route/classify questions a
|
|
87
|
+
// vmEngine classifyFn may ask when the plan IR cannot route an event.
|
|
88
|
+
// Owner is the deterministic escalation lane: every failure mode resolves
|
|
89
|
+
// to escalateUnclassified, never a hard failure.
|
|
90
|
+
{
|
|
91
|
+
decisionKey: 'runtime.node_route.v1',
|
|
92
|
+
schemaVersion: 1,
|
|
93
|
+
family: 'runtime',
|
|
94
|
+
owner: 'vmEngine',
|
|
95
|
+
kind: 'choice',
|
|
96
|
+
description: 'Outcome for a VM node event the plan IR could not route — bounded route/escalate answer only.',
|
|
97
|
+
candidatePolicy: 'node-outcomes-plus-escalate',
|
|
98
|
+
hardConstraints: ['data-only-no-tool-dispatch'],
|
|
99
|
+
probabilityPolicy: 'raw-label',
|
|
100
|
+
fallbackPolicy: 'frozen-conservative-fallback',
|
|
101
|
+
telemetryClass: 'decision',
|
|
102
|
+
rolloutStage: 'off',
|
|
103
|
+
cacheSensitivity: 'none',
|
|
104
|
+
instruction:
|
|
105
|
+
'A VM node event did not match any plan selector. Choose the node outcome, or escalate to the deterministic lane.',
|
|
106
|
+
candidates: ['completed', 'failed', 'cancelled', 'escalate'],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
decisionKey: 'runtime.node_classify.v1',
|
|
110
|
+
schemaVersion: 1,
|
|
111
|
+
family: 'runtime',
|
|
112
|
+
owner: 'vmEngine',
|
|
113
|
+
kind: 'choice',
|
|
114
|
+
description: 'Bounded node-declared classification for an unclassifiable VM event; candidates come from the node question block.',
|
|
115
|
+
candidatePolicy: 'node-declared-candidates',
|
|
116
|
+
hardConstraints: ['data-only-no-tool-dispatch'],
|
|
117
|
+
probabilityPolicy: 'raw-label',
|
|
118
|
+
fallbackPolicy: 'frozen-conservative-fallback',
|
|
119
|
+
telemetryClass: 'decision',
|
|
120
|
+
rolloutStage: 'off',
|
|
121
|
+
cacheSensitivity: 'none',
|
|
122
|
+
instruction:
|
|
123
|
+
'Classify this unclassifiable VM node event into exactly one of the node-declared candidates.',
|
|
124
|
+
candidates: ['completed', 'failed', 'escalate'],
|
|
125
|
+
},
|
|
86
126
|
]);
|
|
87
127
|
|
|
88
128
|
// Decision→fallback action map (frozen, SPEC §4). Fallbacks are conservative:
|
|
@@ -92,6 +132,9 @@ const FALLBACK_ACTIONS = Object.freeze({
|
|
|
92
132
|
'runtime.stall_action.v1': 'continue_observe',
|
|
93
133
|
'runtime.wake_needed.v1': 'escalate',
|
|
94
134
|
'runtime.safe_retry.v1': 'no_retry',
|
|
135
|
+
// VM decision nodes fail closed onto the deterministic escalation lane.
|
|
136
|
+
'runtime.node_route.v1': 'escalate',
|
|
137
|
+
'runtime.node_classify.v1': 'escalate',
|
|
95
138
|
});
|
|
96
139
|
|
|
97
140
|
// Batch-level statuses → fallback codes. 'abstained' stays distinct so
|
|
@@ -135,22 +178,37 @@ function buildStatePacket(item) {
|
|
|
135
178
|
decisionKey: item.decisionKey ?? null,
|
|
136
179
|
reason: item.reason ?? null,
|
|
137
180
|
eventType: item.event?.eventType ?? item.eventType ?? null,
|
|
181
|
+
nodeId: typeof item.nodeId === 'string' ? item.nodeId : null,
|
|
182
|
+
planInstanceId:
|
|
183
|
+
typeof item.planInstanceId === 'string' ? item.planInstanceId : null,
|
|
138
184
|
};
|
|
139
185
|
}
|
|
140
186
|
|
|
141
187
|
function buildQuestion(item, entry) {
|
|
188
|
+
// A node-declared question block may override the registered instruction/
|
|
189
|
+
// candidates — it is still a bounded choice against one registered key.
|
|
190
|
+
const instruction =
|
|
191
|
+
typeof item.instruction === 'string' && item.instruction.length > 0
|
|
192
|
+
? item.instruction
|
|
193
|
+
: typeof entry.instruction === 'string' && entry.instruction.length > 0
|
|
194
|
+
? entry.instruction
|
|
195
|
+
: `Decide ${entry.decisionKey} for the described runtime event.`;
|
|
196
|
+
const candidates =
|
|
197
|
+
Array.isArray(item.candidates) && item.candidates.length > 0
|
|
198
|
+
? item.candidates
|
|
199
|
+
: Array.isArray(entry.candidates)
|
|
200
|
+
? entry.candidates
|
|
201
|
+
: [];
|
|
142
202
|
return {
|
|
143
203
|
decisionKey: entry.decisionKey,
|
|
144
204
|
schemaVersion: entry.schemaVersion ?? 1,
|
|
145
205
|
kind: entry.kind,
|
|
146
|
-
instruction
|
|
147
|
-
|
|
148
|
-
? entry.instruction
|
|
149
|
-
: `Decide ${entry.decisionKey} for the described runtime event.`,
|
|
150
|
-
candidates: Array.isArray(entry.candidates) ? entry.candidates : [],
|
|
206
|
+
instruction,
|
|
207
|
+
candidates,
|
|
151
208
|
};
|
|
152
209
|
}
|
|
153
210
|
|
|
211
|
+
|
|
154
212
|
/**
|
|
155
213
|
* @param {{client: {requestBatch: Function}, registry?: object,
|
|
156
214
|
* confidenceFloor?: number|object, now?: Function}} options
|
|
@@ -240,3 +298,123 @@ export function createRuntimeDecider({
|
|
|
240
298
|
|
|
241
299
|
return { decide };
|
|
242
300
|
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// V-05 — VM decision nodes (agent-vm-runtime V5): a classifyFn for vmEngine
|
|
304
|
+
// that asks one bounded runtime.node_route.v1 / runtime.node_classify.v1
|
|
305
|
+
// question per unclassifiable event. The model answer is data only — the
|
|
306
|
+
// client's tool_calls envelope is consumed as an answer list, NEVER
|
|
307
|
+
// dispatched. Every failure mode maps to the deterministic escalation lane
|
|
308
|
+
// with a fallbackCode; this adapter never throws.
|
|
309
|
+
|
|
310
|
+
// Registry keys a VM decision node may ask — anything else resolves to the
|
|
311
|
+
// deterministic escalation lane without touching the gateway.
|
|
312
|
+
export const VM_NODE_DECISION_KEYS = Object.freeze([
|
|
313
|
+
'runtime.node_route.v1',
|
|
314
|
+
'runtime.node_classify.v1',
|
|
315
|
+
]);
|
|
316
|
+
const DEFAULT_NODE_DECISION_KEY = VM_NODE_DECISION_KEYS[0];
|
|
317
|
+
|
|
318
|
+
// Node question-block bounds — keeps a bounded choice small enough to stay
|
|
319
|
+
// inside the state/question budget.
|
|
320
|
+
const MAX_NODE_CANDIDATES = 8;
|
|
321
|
+
const MAX_NODE_QUESTION_TEXT = 240;
|
|
322
|
+
|
|
323
|
+
function isShortString(value) {
|
|
324
|
+
return typeof value === 'string'
|
|
325
|
+
&& value.length > 0
|
|
326
|
+
&& value.length <= MAX_NODE_QUESTION_TEXT;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// A node question block is valid only when every declared field is
|
|
330
|
+
// well-formed; a malformed block is rejected wholesale (never partially
|
|
331
|
+
// honored) so a bad plan cannot smuggle an unbounded question.
|
|
332
|
+
function validNodeQuestion(question) {
|
|
333
|
+
return isPlainObject(question)
|
|
334
|
+
&& (question.decisionKey === undefined || isShortString(question.decisionKey))
|
|
335
|
+
&& (question.instruction === undefined || isShortString(question.instruction))
|
|
336
|
+
&& (question.candidates === undefined
|
|
337
|
+
|| (Array.isArray(question.candidates)
|
|
338
|
+
&& question.candidates.length > 0
|
|
339
|
+
&& question.candidates.length <= MAX_NODE_CANDIDATES
|
|
340
|
+
&& question.candidates.every(isShortString)));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Build a vmEngine-compatible `classifyFn(event, ctx)` backed by the decision
|
|
345
|
+
* plane. Returns only `{action:'route', outcome}` / `{action:'escalate',
|
|
346
|
+
* fallbackCode?}` verdicts — model unavailability, timeouts, malformed
|
|
347
|
+
* answers, and adapter faults all resolve to the deterministic escalation
|
|
348
|
+
* lane with a `fallbackCode`, and the function NEVER throws.
|
|
349
|
+
*
|
|
350
|
+
* @param {{client?: {requestBatch: Function}, decider?: {decide: Function},
|
|
351
|
+
* registry?: object, confidenceFloor?: number|object}} options
|
|
352
|
+
* `decider` wins over `client` (a custom decider composes its own client).
|
|
353
|
+
* Neither present → every call escalates 'unavailable' with zero I/O.
|
|
354
|
+
*/
|
|
355
|
+
export function createVmClassifyFn({
|
|
356
|
+
client,
|
|
357
|
+
decider,
|
|
358
|
+
registry,
|
|
359
|
+
confidenceFloor,
|
|
360
|
+
} = {}) {
|
|
361
|
+
const resolvedDecider =
|
|
362
|
+
decider
|
|
363
|
+
?? (client != null
|
|
364
|
+
? createRuntimeDecider({ client, registry, confidenceFloor })
|
|
365
|
+
: null);
|
|
366
|
+
|
|
367
|
+
return async function vmClassify(event, ctx = {}) {
|
|
368
|
+
try {
|
|
369
|
+
const node = isPlainObject(ctx?.node) ? ctx.node : null;
|
|
370
|
+
const question = node?.question;
|
|
371
|
+
if (question !== undefined && !validNodeQuestion(question)) {
|
|
372
|
+
return { action: 'escalate', fallbackCode: 'malformed-question' };
|
|
373
|
+
}
|
|
374
|
+
const decisionKey = question?.decisionKey ?? DEFAULT_NODE_DECISION_KEY;
|
|
375
|
+
if (!VM_NODE_DECISION_KEYS.includes(decisionKey)) {
|
|
376
|
+
return { action: 'escalate', fallbackCode: 'unregistered-key' };
|
|
377
|
+
}
|
|
378
|
+
if (!resolvedDecider || typeof resolvedDecider.decide !== 'function') {
|
|
379
|
+
return { action: 'escalate', fallbackCode: 'unavailable' };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const decision = await resolvedDecider.decide({
|
|
383
|
+
action: 'model_decision',
|
|
384
|
+
decisionKey,
|
|
385
|
+
reason: `vm_unrouted:${String(event?.eventType ?? 'unknown')}`,
|
|
386
|
+
event: isPlainObject(event) ? event : null,
|
|
387
|
+
nodeId: typeof ctx?.nodeId === 'string' ? ctx.nodeId : null,
|
|
388
|
+
planInstanceId:
|
|
389
|
+
typeof ctx?.planInstanceId === 'string' ? ctx.planInstanceId : null,
|
|
390
|
+
...(isShortString(question?.instruction)
|
|
391
|
+
? { instruction: question.instruction }
|
|
392
|
+
: {}),
|
|
393
|
+
...(Array.isArray(question?.candidates)
|
|
394
|
+
? { candidates: question.candidates }
|
|
395
|
+
: {}),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
if (!isPlainObject(decision)) {
|
|
399
|
+
return { action: 'escalate', fallbackCode: 'invalid' };
|
|
400
|
+
}
|
|
401
|
+
if (decision.decidedBy === 'fallback') {
|
|
402
|
+
return { action: 'escalate', fallbackCode: decision.code ?? 'invalid' };
|
|
403
|
+
}
|
|
404
|
+
const outcome = decision.action ?? decision.value;
|
|
405
|
+
if (outcome === 'escalate') {
|
|
406
|
+
return { action: 'escalate', decisionKey };
|
|
407
|
+
}
|
|
408
|
+
if (typeof outcome !== 'string' || outcome.length === 0) {
|
|
409
|
+
return { action: 'escalate', fallbackCode: 'invalid' };
|
|
410
|
+
}
|
|
411
|
+
// The engine's transition table still gates the outcome — an undeclared
|
|
412
|
+
// value fails closed to recovery_required downstream, never silently.
|
|
413
|
+
return { action: 'route', outcome, decisionKey };
|
|
414
|
+
} catch {
|
|
415
|
+
// Any adapter fault (bad ctx shape, throwing decider impl) resolves to
|
|
416
|
+
// the same deterministic lane — decision nodes never hard-fail the VM.
|
|
417
|
+
return { action: 'escalate', fallbackCode: 'adapter-error' };
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
}
|
|
@@ -476,10 +476,10 @@ function readMergedConfig(rootDir, homeDir) {
|
|
|
476
476
|
// ---------------------------------------------------------------------------
|
|
477
477
|
// Client core — mirror of src/decision/client.js semantics minus the circuit
|
|
478
478
|
const DEFAULT_CHECKPOINTS = {
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
default: 'unic-decision
|
|
479
|
+
// Locked 2026-09-26: every language class routes to the single
|
|
480
|
+
// credential-safe 'unic-decision' model — the provider swaps the backend
|
|
481
|
+
// (Lava/JEV) behind that name, so UKit never needs per-class variants.
|
|
482
|
+
default: 'unic-decision',
|
|
483
483
|
english: 'unic-decision',
|
|
484
484
|
unknownLanguage: 'unic-decision',
|
|
485
485
|
};
|
|
@@ -718,8 +718,7 @@ export async function requestBatch(batch, {
|
|
|
718
718
|
|
|
719
719
|
const status = res?.status ?? (res?.ok === false ? 500 : 200);
|
|
720
720
|
if (res?.ok === false || status < 200 || status >= 300) {
|
|
721
|
-
// A
|
|
722
|
-
// unic-decision-multilingual before provider binding) returns a 4xx
|
|
721
|
+
// A checkpoint without bound credentials returns a 4xx
|
|
723
722
|
// model_not_found body — retry once on the bare `unic-decision` model.
|
|
724
723
|
if (checkpoint !== 'unic-decision') {
|
|
725
724
|
let notFound = status === 404;
|
|
@@ -99,10 +99,10 @@
|
|
|
99
99
|
},
|
|
100
100
|
"decisionPlane": {
|
|
101
101
|
"enabled": true,
|
|
102
|
-
"stage": "
|
|
102
|
+
"stage": "default",
|
|
103
103
|
"logicalModel": "unic-decision",
|
|
104
104
|
"checkpoints": {
|
|
105
|
-
"default": "unic-decision
|
|
105
|
+
"default": "unic-decision",
|
|
106
106
|
"english": "unic-decision",
|
|
107
107
|
"unknownLanguage": "unic-decision"
|
|
108
108
|
},
|