dsh-decision-layer 0.4.0 → 0.5.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/README.en.md +6 -4
- package/README.md +7 -5
- package/lib/client.js +248 -33
- package/package.json +1 -1
- package/service/config-store.mjs +47 -4
- package/service/danger-gate.mjs +33 -16
- package/service/dsh.mjs +72 -1
- package/service/log-analyze.mjs +155 -0
- package/service/log-read.mjs +27 -0
- package/service/log-store.mjs +12 -0
- package/service/loop-guard.mjs +3 -4
- package/service/manager-api.mjs +26 -1
- package/service/quality-trend.mjs +29 -0
- package/service/self-check.mjs +12 -5
- package/service/session-state.mjs +52 -15
- package/service/task-completion.mjs +155 -0
package/service/config-store.mjs
CHANGED
|
@@ -55,7 +55,7 @@ function validRules(value) {
|
|
|
55
55
|
|
|
56
56
|
function validCheck(value) {
|
|
57
57
|
if (!safeObject(value)) return undefined;
|
|
58
|
-
if (Object.keys(value).some(key => !['mode', 'rubric', 'lowScoreThreshold'].includes(key))) throw new Error('Invalid self-check settings');
|
|
58
|
+
if (Object.keys(value).some(key => !['mode', 'rubric', 'lowScoreThreshold', 'trendMinConfidence', 'trendRun', 'trendSevereRun'].includes(key))) throw new Error('Invalid self-check settings');
|
|
59
59
|
const check = {};
|
|
60
60
|
if (value.mode !== undefined) {
|
|
61
61
|
if (value.mode !== 'observe' && value.mode !== 'steer') throw new Error('Invalid self-check settings');
|
|
@@ -71,6 +71,27 @@ function validCheck(value) {
|
|
|
71
71
|
if (!Number.isSafeInteger(value.lowScoreThreshold) || value.lowScoreThreshold < 0 || value.lowScoreThreshold > 9) throw new Error('Invalid self-check settings');
|
|
72
72
|
check.lowScoreThreshold = value.lowScoreThreshold;
|
|
73
73
|
}
|
|
74
|
+
// Quality-trend alarm thresholds (all unvalidated heuristics, tuned on real logs):
|
|
75
|
+
// a high-confidence low score requires confidence ≥ trendMinConfidence; a run of
|
|
76
|
+
// trendRun such scores raises "warn", trendSevereRun raises "severe".
|
|
77
|
+
if (value.trendMinConfidence !== undefined) {
|
|
78
|
+
if (typeof value.trendMinConfidence !== 'number' || !Number.isFinite(value.trendMinConfidence) || value.trendMinConfidence < 0 || value.trendMinConfidence > 1) throw new Error('Invalid self-check settings');
|
|
79
|
+
check.trendMinConfidence = value.trendMinConfidence;
|
|
80
|
+
}
|
|
81
|
+
if (value.trendRun !== undefined) {
|
|
82
|
+
if (!Number.isSafeInteger(value.trendRun) || value.trendRun < 2 || value.trendRun > 10) throw new Error('Invalid self-check settings');
|
|
83
|
+
check.trendRun = value.trendRun;
|
|
84
|
+
}
|
|
85
|
+
if (value.trendSevereRun !== undefined) {
|
|
86
|
+
if (!Number.isSafeInteger(value.trendSevereRun) || value.trendSevereRun < 2 || value.trendSevereRun > 20) throw new Error('Invalid self-check settings');
|
|
87
|
+
check.trendSevereRun = value.trendSevereRun;
|
|
88
|
+
}
|
|
89
|
+
// The severe threshold must sit strictly above the warn threshold; check against
|
|
90
|
+
// the effective warn value (explicit or default 3) so the pair is always ordered.
|
|
91
|
+
if (check.trendSevereRun !== undefined) {
|
|
92
|
+
const warn = check.trendRun ?? 3;
|
|
93
|
+
if (check.trendSevereRun <= warn) throw new Error('Invalid self-check settings');
|
|
94
|
+
}
|
|
74
95
|
return check;
|
|
75
96
|
}
|
|
76
97
|
|
|
@@ -106,9 +127,27 @@ function validNarrow(value) {
|
|
|
106
127
|
return narrow;
|
|
107
128
|
}
|
|
108
129
|
|
|
109
|
-
// Per-feature enable flags for the
|
|
130
|
+
// Per-feature enable flags for the decision points. Every flag defaults to
|
|
110
131
|
// enabled; only an explicit `false` disables its decision point.
|
|
111
|
-
export const FEATURE_KEYS = Object.freeze(['gate', 'check', 'narrow']);
|
|
132
|
+
export const FEATURE_KEYS = Object.freeze(['gate', 'check', 'narrow', 'complete']);
|
|
133
|
+
|
|
134
|
+
// Task-completion checking settings: observe-only by default; steer opts into a
|
|
135
|
+
// single supplemental prompt on low completion. minConditions is the minimum
|
|
136
|
+
// number of explicit conditions a turn must have for the check to run at all.
|
|
137
|
+
function validComplete(value) {
|
|
138
|
+
if (!safeObject(value)) return undefined;
|
|
139
|
+
if (Object.keys(value).some(key => !['mode', 'minConditions'].includes(key))) throw new Error('Invalid task-completion settings');
|
|
140
|
+
const complete = {};
|
|
141
|
+
if (value.mode !== undefined) {
|
|
142
|
+
if (value.mode !== 'observe' && value.mode !== 'steer') throw new Error('Invalid task-completion settings');
|
|
143
|
+
complete.mode = value.mode;
|
|
144
|
+
}
|
|
145
|
+
if (value.minConditions !== undefined) {
|
|
146
|
+
if (!Number.isSafeInteger(value.minConditions) || value.minConditions < 2 || value.minConditions > 20) throw new Error('Invalid task-completion settings');
|
|
147
|
+
complete.minConditions = value.minConditions;
|
|
148
|
+
}
|
|
149
|
+
return complete;
|
|
150
|
+
}
|
|
112
151
|
|
|
113
152
|
function validFeatures(value) {
|
|
114
153
|
if (!safeObject(value)) return undefined;
|
|
@@ -138,6 +177,7 @@ function view(stored) {
|
|
|
138
177
|
if (stored.dangerRules !== undefined) result.dangerRules = validRules(stored.dangerRules);
|
|
139
178
|
if (stored.checkSettings !== undefined) result.checkSettings = validCheck(stored.checkSettings);
|
|
140
179
|
if (stored.narrowSettings !== undefined) result.narrowSettings = validNarrow(stored.narrowSettings);
|
|
180
|
+
if (stored.completeSettings !== undefined) result.completeSettings = validComplete(stored.completeSettings);
|
|
141
181
|
if (stored.features !== undefined) result.features = validFeatures(stored.features);
|
|
142
182
|
return result;
|
|
143
183
|
}
|
|
@@ -158,12 +198,13 @@ export async function resolveConfig({ path = configPath(), env = process.env } =
|
|
|
158
198
|
if (stored.dangerRules !== undefined) resolved.dangerRules = validRules(stored.dangerRules);
|
|
159
199
|
if (stored.checkSettings !== undefined) resolved.checkSettings = validCheck(stored.checkSettings);
|
|
160
200
|
if (stored.narrowSettings !== undefined) resolved.narrowSettings = validNarrow(stored.narrowSettings);
|
|
201
|
+
if (stored.completeSettings !== undefined) resolved.completeSettings = validComplete(stored.completeSettings);
|
|
161
202
|
if (stored.features !== undefined) resolved.features = validFeatures(stored.features);
|
|
162
203
|
return resolved;
|
|
163
204
|
}
|
|
164
205
|
|
|
165
206
|
export async function saveConfig(input, path = configPath()) {
|
|
166
|
-
if (!safeObject(input) || Object.keys(input).some(key => !['url', 'apiKey', 'model', 'confirmHttpUrl', 'dangerRules', 'checkSettings', 'narrowSettings', 'features'].includes(key))) {
|
|
207
|
+
if (!safeObject(input) || Object.keys(input).some(key => !['url', 'apiKey', 'model', 'confirmHttpUrl', 'dangerRules', 'checkSettings', 'narrowSettings', 'completeSettings', 'features'].includes(key))) {
|
|
167
208
|
throw new Error('Invalid configuration fields');
|
|
168
209
|
}
|
|
169
210
|
for (const key of ['url', 'apiKey', 'model', 'confirmHttpUrl']) {
|
|
@@ -179,6 +220,7 @@ export async function saveConfig(input, path = configPath()) {
|
|
|
179
220
|
const dangerRules = input.dangerRules === undefined ? previous.dangerRules : validRules(input.dangerRules);
|
|
180
221
|
const checkSettings = input.checkSettings === undefined ? previous.checkSettings : validCheck(input.checkSettings);
|
|
181
222
|
const narrowSettings = input.narrowSettings === undefined ? previous.narrowSettings : validNarrow(input.narrowSettings);
|
|
223
|
+
const completeSettings = input.completeSettings === undefined ? previous.completeSettings : validComplete(input.completeSettings);
|
|
182
224
|
const features = input.features === undefined ? previous.features : validFeatures(input.features);
|
|
183
225
|
const confirmed = url.startsWith('http://') && input.confirmHttpUrl !== undefined &&
|
|
184
226
|
checkedUrl(safeText(input.confirmHttpUrl)) === url;
|
|
@@ -189,6 +231,7 @@ export async function saveConfig(input, path = configPath()) {
|
|
|
189
231
|
if (dangerRules !== undefined) next.dangerRules = dangerRules;
|
|
190
232
|
if (checkSettings !== undefined) next.checkSettings = checkSettings;
|
|
191
233
|
if (narrowSettings !== undefined) next.narrowSettings = narrowSettings;
|
|
234
|
+
if (completeSettings !== undefined) next.completeSettings = completeSettings;
|
|
192
235
|
if (features !== undefined) next.features = features;
|
|
193
236
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
194
237
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
package/service/danger-gate.mjs
CHANGED
|
@@ -92,10 +92,19 @@ export function createDangerGate({ evaluate, sessions, rules = DEFAULT_DANGEROUS
|
|
|
92
92
|
try { sessions.log(id, { kind: 'gate', outcome, tool: exec?.name, ...detail }); }
|
|
93
93
|
catch { /* logging is best effort */ }
|
|
94
94
|
};
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
// Two-tier model: the gate intervenes ONLY on a high-confidence `deny`
|
|
96
|
+
// (returned as kind:'deny', enforced monotonically through tools.guard,
|
|
97
|
+
// independent of the host approval policy). Every other outcome — a low-
|
|
98
|
+
// confidence verdict, a high-confidence allow/ask, an invalid response, or a
|
|
99
|
+
// backend failure — is NOT an intervention: the call proceeds on its original
|
|
100
|
+
// path (next()). The gate no longer emits `ask`, so it never routes through
|
|
101
|
+
// host approval and can no longer be mis-rendered as "the user rejected".
|
|
102
|
+
// `passThrough` records the non-intervention for observability and returns
|
|
103
|
+
// undefined so preExecute falls through to next().
|
|
104
|
+
const passThrough = (exec, outcome, detail = {}) => {
|
|
105
|
+
record(exec, outcome, { action: 'pass', ...detail });
|
|
97
106
|
if (exec && typeof exec === 'object') evaluatedCalls.add(exec);
|
|
98
|
-
return
|
|
107
|
+
return undefined;
|
|
99
108
|
};
|
|
100
109
|
return {
|
|
101
110
|
classify: exec => classifyDangerous(exec, typeof rules === 'function' ? DEFAULT_DANGEROUS_RULES : rules),
|
|
@@ -112,18 +121,27 @@ export function createDangerGate({ evaluate, sessions, rules = DEFAULT_DANGEROUS
|
|
|
112
121
|
evaluatedCalls.delete(exec);
|
|
113
122
|
},
|
|
114
123
|
async evaluate(exec) {
|
|
115
|
-
if (exec && typeof exec === 'object') evaluatedCalls.add(exec);
|
|
116
124
|
let result;
|
|
117
125
|
try {
|
|
118
126
|
result = await evaluate(suggestionQuestion(exec), { signal: exec.signal });
|
|
119
|
-
} catch (error) {
|
|
127
|
+
} catch (error) {
|
|
128
|
+
// Backend failure: no verdict, so do not intervene (pass through).
|
|
129
|
+
return passThrough(exec, 'error', { reason: typeof error?.reason === 'string' ? error.reason : 'unreachable' });
|
|
130
|
+
}
|
|
120
131
|
const answer = result?.answers?.verdict;
|
|
121
132
|
const choice = answer?.choice;
|
|
122
|
-
if (!['allow', 'ask', 'deny'].includes(choice)) return
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
133
|
+
if (!['allow', 'ask', 'deny'].includes(choice)) return passThrough(exec, 'error', { reason: 'invalid-response' });
|
|
134
|
+
const confident = typeof answer.confidence === 'number' && Number.isFinite(answer.confidence) && answer.confidence >= minConfidence;
|
|
135
|
+
// Only a high-confidence deny blocks the call.
|
|
136
|
+
if (choice === 'deny' && confident) {
|
|
137
|
+
record(exec, 'deny', { suggestion: 'deny', action: 'deny' });
|
|
138
|
+
if (exec && typeof exec === 'object') deniedCalls.add(exec);
|
|
139
|
+
return { kind: 'deny', reason: 'Dangerous call rejected by decision policy', modelSuggestion: choice };
|
|
140
|
+
}
|
|
141
|
+
// Uncertain (low confidence) or a non-deny verdict → do not intervene.
|
|
142
|
+
const detail = { suggestion: choice };
|
|
143
|
+
if (!confident) { detail.reason = 'low-confidence'; if (typeof answer.confidence === 'number' && Number.isFinite(answer.confidence)) detail.confidence = answer.confidence; }
|
|
144
|
+
return passThrough(exec, 'allow', detail);
|
|
127
145
|
},
|
|
128
146
|
async preExecute(exec, next) {
|
|
129
147
|
const classification = classifyDangerous(exec, await resolveRules());
|
|
@@ -131,13 +149,12 @@ export function createDangerGate({ evaluate, sessions, rules = DEFAULT_DANGEROUS
|
|
|
131
149
|
const id = exec?.agent?.session?.id;
|
|
132
150
|
if (sessions && typeof id === 'string') {
|
|
133
151
|
try {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (snapshot.capacityExceeded) return fallback(exec, 'capacity');
|
|
138
|
-
} catch { return fallback(exec, 'unreachable'); }
|
|
152
|
+
// A deliberate user toggle bypasses the gate entirely (no record).
|
|
153
|
+
if (sessions.snapshot(id).enabled === false) return next();
|
|
154
|
+
} catch { passThrough(exec, 'error', { reason: 'unreachable' }); return next(); }
|
|
139
155
|
}
|
|
140
|
-
|
|
156
|
+
const decision = await this.evaluate(exec);
|
|
157
|
+
return decision ?? next();
|
|
141
158
|
},
|
|
142
159
|
};
|
|
143
160
|
}
|
package/service/dsh.mjs
CHANGED
|
@@ -7,8 +7,10 @@ import { createSessionState } from './session-state.mjs';
|
|
|
7
7
|
import { createDangerGate, normalizeDangerRules } from './danger-gate.mjs';
|
|
8
8
|
import { createSelfCheck } from './self-check.mjs';
|
|
9
9
|
import { createNarrowing } from './narrowing.mjs';
|
|
10
|
+
import { createTaskCompletion } from './task-completion.mjs';
|
|
10
11
|
import { createLoopGuard } from './loop-guard.mjs';
|
|
11
12
|
import { createLogStore } from './log-store.mjs';
|
|
13
|
+
import { analyzeSessionLogs } from './log-analyze.mjs';
|
|
12
14
|
|
|
13
15
|
export const name = 'dsh-decision-layer';
|
|
14
16
|
export const inject = ['tools', 'skills'];
|
|
@@ -40,6 +42,47 @@ function stepTaskText(messages) {
|
|
|
40
42
|
} catch { return ''; }
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
// The user's request text for this turn, used by task-completion to extract the
|
|
46
|
+
// explicit conditions. Best-effort: an unreachable shape yields '', which skips
|
|
47
|
+
// the check. Takes the trailing run of user messages (the prompt entering the turn).
|
|
48
|
+
function turnUserText(agent) {
|
|
49
|
+
try {
|
|
50
|
+
const messages = agent?.session?.deriveMessages?.();
|
|
51
|
+
if (!Array.isArray(messages)) return '';
|
|
52
|
+
const texts = [];
|
|
53
|
+
let collecting = false;
|
|
54
|
+
// Walk backwards: skip the turn's trailing assistant/tool messages, collect the
|
|
55
|
+
// contiguous user run that started this turn, then stop at the prior assistant.
|
|
56
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
57
|
+
const message = messages[i];
|
|
58
|
+
if (message?.role === 'user' && Array.isArray(message.content)) {
|
|
59
|
+
collecting = true;
|
|
60
|
+
texts.unshift(message.content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join('\n'));
|
|
61
|
+
} else if (collecting) break;
|
|
62
|
+
}
|
|
63
|
+
return texts.join('\n').slice(0, 8 * 1024);
|
|
64
|
+
} catch { return ''; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Tool evidence for the turn's task-completion check: the tool calls and their
|
|
68
|
+
// ok/error outcome from the derived history. Names only (no arguments), bounded.
|
|
69
|
+
// An unreachable shape yields [], which skips the check.
|
|
70
|
+
function turnToolEvidence(agent) {
|
|
71
|
+
try {
|
|
72
|
+
const messages = agent?.session?.deriveMessages?.();
|
|
73
|
+
if (!Array.isArray(messages)) return [];
|
|
74
|
+
const evidence = [];
|
|
75
|
+
for (const message of messages) {
|
|
76
|
+
if (message?.role !== 'tool' || !Array.isArray(message.content)) continue;
|
|
77
|
+
const tool = typeof message.name === 'string' ? message.name : (typeof message.toolName === 'string' ? message.toolName : '');
|
|
78
|
+
if (!tool) continue;
|
|
79
|
+
evidence.push({ tool: tool.slice(0, 128), outcome: message.isError === true ? 'error' : 'ok' });
|
|
80
|
+
if (evidence.length >= 40) break;
|
|
81
|
+
}
|
|
82
|
+
return evidence;
|
|
83
|
+
} catch { return []; }
|
|
84
|
+
}
|
|
85
|
+
|
|
43
86
|
// Enumerate the tool names/descriptions visible to this agent scope, best-effort.
|
|
44
87
|
function agentTools(ctx, agent) {
|
|
45
88
|
try {
|
|
@@ -106,6 +149,29 @@ export async function apply(ctx, options = {}) {
|
|
|
106
149
|
if (typeof ctx.effect === 'function') ctx.effect(installCheck);
|
|
107
150
|
else installCheck();
|
|
108
151
|
|
|
152
|
+
const taskCompletion = createTaskCompletion({
|
|
153
|
+
evaluate: (input, request) => backend.evaluate(input, request),
|
|
154
|
+
sessions,
|
|
155
|
+
settings: async () => (await resolveConfig({ path })).completeSettings,
|
|
156
|
+
steer: (agent, text) => agent.steer(createUserMessage({
|
|
157
|
+
content: [{ type: 'text', text }],
|
|
158
|
+
source: { kind: 'user' },
|
|
159
|
+
})),
|
|
160
|
+
});
|
|
161
|
+
// Independent turn-stopping listener, separate from the self-check: it checks
|
|
162
|
+
// whether the user's explicit conditions have supporting tool evidence.
|
|
163
|
+
const installCompletion = () => ctx.on('agent/turn-stopping', async payload => {
|
|
164
|
+
if (!(await featureOn('complete'))) return;
|
|
165
|
+
return taskCompletion.review({
|
|
166
|
+
agent: payload.agent, turn: payload.turn, signal: payload.signal,
|
|
167
|
+
output: lastAssistantText(payload.agent),
|
|
168
|
+
userText: turnUserText(payload.agent),
|
|
169
|
+
evidence: turnToolEvidence(payload.agent),
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
if (typeof ctx.effect === 'function') ctx.effect(installCompletion);
|
|
173
|
+
else installCompletion();
|
|
174
|
+
|
|
109
175
|
const narrowing = createNarrowing({
|
|
110
176
|
evaluate: (input, request) => backend.evaluate(input, request),
|
|
111
177
|
sessions,
|
|
@@ -169,7 +235,12 @@ export async function apply(ctx, options = {}) {
|
|
|
169
235
|
|
|
170
236
|
ctx.inject(['webServer'], web => {
|
|
171
237
|
logStore = createLogStore({ port: web.webServer.port });
|
|
172
|
-
const
|
|
238
|
+
const port = web.webServer.port;
|
|
239
|
+
const analyze = async sessionId => analyzeSessionLogs(sessionId, {
|
|
240
|
+
port,
|
|
241
|
+
trendConfig: (await resolveConfig({ path })).checkSettings,
|
|
242
|
+
});
|
|
243
|
+
const unregister = createManagerRoutes({ path, sessions, backend, logStore, analyze, bindHost: web.webServer.host }).map(route => web.webServer.register(route));
|
|
173
244
|
return () => { unregister.forEach(dispose => dispose()); const store = logStore; logStore = undefined; void store?.dispose(); };
|
|
174
245
|
});
|
|
175
246
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createReadableStream } from './log-read.mjs';
|
|
2
|
+
|
|
3
|
+
// Offline analysis over the persisted decision JSONL logs. Pure aggregation:
|
|
4
|
+
// given a stream of records (decision events + annotation events) for one
|
|
5
|
+
// session, fold annotations onto decisions by id (latest wins) and build a
|
|
6
|
+
// process-metrics profile plus a quality-trend threshold backtest.
|
|
7
|
+
//
|
|
8
|
+
// Reading is separated (log-read.mjs) so this module stays pure and testable
|
|
9
|
+
// without touching the filesystem.
|
|
10
|
+
|
|
11
|
+
const DECISION_KINDS = new Set(['gate', 'check', 'narrow', 'complete']);
|
|
12
|
+
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
13
|
+
|
|
14
|
+
// Reduce one session's records into decisions (with the latest annotation
|
|
15
|
+
// attached) and per-kind aggregates. `records` is any iterable of parsed rows.
|
|
16
|
+
export function foldSession(records) {
|
|
17
|
+
const decisions = new Map(); // id → decision record
|
|
18
|
+
const annotations = new Map(); // target id → latest annotation
|
|
19
|
+
for (const record of records) {
|
|
20
|
+
if (!isObject(record)) continue;
|
|
21
|
+
if (record.kind === 'annotation') {
|
|
22
|
+
const target = typeof record.target === 'string' ? record.target : undefined;
|
|
23
|
+
if (!target) continue;
|
|
24
|
+
const prev = annotations.get(target);
|
|
25
|
+
if (!prev || (Number.isFinite(record.at) && record.at >= (prev.at ?? 0))) {
|
|
26
|
+
annotations.set(target, { rating: record.rating, at: Number.isFinite(record.at) ? record.at : 0 });
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (DECISION_KINDS.has(record.kind) && typeof record.id === 'string') {
|
|
31
|
+
decisions.set(record.id, record);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { decisions, annotations };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A confidence bucket label for the [0,1] range, coarse enough to be useful.
|
|
38
|
+
function confidenceBucket(value) {
|
|
39
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
|
|
40
|
+
if (value < 0.4) return 'lt40';
|
|
41
|
+
if (value < 0.6) return 'lt60';
|
|
42
|
+
if (value < 0.8) return 'lt80';
|
|
43
|
+
return 'gte80';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function emptyProfile() {
|
|
47
|
+
return {
|
|
48
|
+
gate: { attempts: 0, allow: 0, ask: 0, deny: 0, error: 0 },
|
|
49
|
+
check: { attempts: 0, ok: 0, low: 0, error: 0, scoreSum: 0, scoreCount: 0, confidence: { lt40: 0, lt60: 0, lt80: 0, gte80: 0 } },
|
|
50
|
+
narrow: { attempts: 0, applied: 0, error: 0, droppedSum: 0, tooManyCandidates: 0 },
|
|
51
|
+
complete: { attempts: 0, satisfied: 0, unsatisfied: 0, insufficient: 0, error: 0, steered: 0 },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Build the process-metrics profile from the folded decisions. Clean denominators:
|
|
56
|
+
// each `attempts` counts real evaluations of that kind; averages (e.g. mean
|
|
57
|
+
// dropped) are reported as-is, never mislabeled as percentages.
|
|
58
|
+
export function buildProfile(decisions) {
|
|
59
|
+
const profile = emptyProfile();
|
|
60
|
+
for (const record of decisions.values()) {
|
|
61
|
+
const kind = record.kind;
|
|
62
|
+
const bucket = profile[kind];
|
|
63
|
+
if (!bucket) continue;
|
|
64
|
+
bucket.attempts++;
|
|
65
|
+
if (kind === 'gate') {
|
|
66
|
+
if (record.outcome === 'error') bucket.error++;
|
|
67
|
+
else if (['allow', 'ask', 'deny'].includes(record.outcome)) bucket[record.outcome]++;
|
|
68
|
+
} else if (kind === 'check') {
|
|
69
|
+
if (record.outcome === 'error') bucket.error++;
|
|
70
|
+
else if (record.outcome === 'low') bucket.low++;
|
|
71
|
+
else bucket.ok++;
|
|
72
|
+
if (typeof record.score === 'number' && Number.isFinite(record.score)) { bucket.scoreSum += record.score; bucket.scoreCount++; }
|
|
73
|
+
const cb = confidenceBucket(record.confidence);
|
|
74
|
+
if (cb) bucket.confidence[cb]++;
|
|
75
|
+
} else if (kind === 'narrow') {
|
|
76
|
+
if (record.outcome === 'error') bucket.error++;
|
|
77
|
+
else if (record.outcome === 'applied') bucket.applied++;
|
|
78
|
+
if (Number.isSafeInteger(record.dropped) && record.dropped > 0) bucket.droppedSum += record.dropped;
|
|
79
|
+
if (record.reason === 'too-many-candidates') bucket.tooManyCandidates++;
|
|
80
|
+
} else if (kind === 'complete') {
|
|
81
|
+
if (record.outcome === 'error') bucket.error++;
|
|
82
|
+
if (Number.isSafeInteger(record.satisfied)) bucket.satisfied += record.satisfied;
|
|
83
|
+
if (Number.isSafeInteger(record.unsatisfied)) bucket.unsatisfied += record.unsatisfied;
|
|
84
|
+
if (Number.isSafeInteger(record.insufficient)) bucket.insufficient += record.insufficient;
|
|
85
|
+
if (record.steered === true) bucket.steered++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return profile;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Replay the quality-trend thresholds over this session's check records in time
|
|
92
|
+
// order: how many times would the alarm have fired at warn / severe? Uses the
|
|
93
|
+
// same run semantics as quality-trend.mjs (good resets, high-confidence low
|
|
94
|
+
// grows, low-confidence low and non-low are skipped for the run — but a good
|
|
95
|
+
// score still resets). This is a calibration aid, not a live signal.
|
|
96
|
+
export function backtestTrend(checkRecords, { trendMinConfidence = 0.6, trendRun = 3, trendSevereRun = 5, lowScoreThreshold = 1 } = {}) {
|
|
97
|
+
const ordered = [...checkRecords].filter(r => isObject(r) && r.kind === 'check').sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
|
|
98
|
+
let run = 0, warnHits = 0, severeHits = 0, maxRun = 0;
|
|
99
|
+
for (const record of ordered) {
|
|
100
|
+
if (record.outcome === 'error') continue; // unevaluated: skip
|
|
101
|
+
const isLow = record.outcome === 'low' || (typeof record.score === 'number' && record.score <= lowScoreThreshold);
|
|
102
|
+
if (!isLow) { run = 0; continue; }
|
|
103
|
+
const confident = typeof record.confidence === 'number' && Number.isFinite(record.confidence) && record.confidence >= trendMinConfidence;
|
|
104
|
+
if (!confident) continue; // low-confidence low: skip, run unchanged
|
|
105
|
+
run++;
|
|
106
|
+
maxRun = Math.max(maxRun, run);
|
|
107
|
+
if (run === trendSevereRun) severeHits++;
|
|
108
|
+
else if (run === trendRun) warnHits++;
|
|
109
|
+
}
|
|
110
|
+
return { warnHits, severeHits, maxRun };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Attach annotation summary: how many decisions carry a good/bad/unsure rating.
|
|
114
|
+
// Also returns a per-decision ratings map (id → rating) so a UI can show the
|
|
115
|
+
// persisted annotation state on each decision, whichever surface renders it.
|
|
116
|
+
export function annotationSummary(decisions, annotations) {
|
|
117
|
+
const summary = { rated: 0, good: 0, bad: 0, unsure: 0 };
|
|
118
|
+
const ratings = Object.create(null);
|
|
119
|
+
for (const [id] of decisions) {
|
|
120
|
+
const annotation = annotations.get(id);
|
|
121
|
+
if (!annotation || typeof annotation.rating !== 'string') continue;
|
|
122
|
+
summary.rated++;
|
|
123
|
+
if (annotation.rating === 'good') summary.good++;
|
|
124
|
+
else if (annotation.rating === 'bad') summary.bad++;
|
|
125
|
+
else if (annotation.rating === 'unsure') summary.unsure++;
|
|
126
|
+
ratings[id] = annotation.rating;
|
|
127
|
+
}
|
|
128
|
+
return { summary, ratings };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// One-call analysis over an iterable of records for a single session.
|
|
132
|
+
export function analyzeSession(records, trendConfig) {
|
|
133
|
+
const { decisions, annotations } = foldSession(records);
|
|
134
|
+
const annotation = annotationSummary(decisions, annotations);
|
|
135
|
+
return {
|
|
136
|
+
totalDecisions: decisions.size,
|
|
137
|
+
profile: buildProfile(decisions),
|
|
138
|
+
trendBacktest: backtestTrend([...decisions.values()], trendConfig),
|
|
139
|
+
annotations: annotation.summary,
|
|
140
|
+
ratings: annotation.ratings,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Read the persisted logs for one session id and analyze them. Streams files so
|
|
145
|
+
// a large corpus is never fully buffered; only rows whose sessionId matches are
|
|
146
|
+
// kept. `readStream` is injected for tests; production reads the logs dir.
|
|
147
|
+
export async function analyzeSessionLogs(sessionId, { dir, port, trendConfig, readStream = createReadableStream } = {}) {
|
|
148
|
+
if (typeof sessionId !== 'string' || !sessionId.trim()) throw new Error('Invalid session id');
|
|
149
|
+
const records = [];
|
|
150
|
+
for await (const record of readStream({ dir, port })) {
|
|
151
|
+
if (!isObject(record)) continue;
|
|
152
|
+
if (record.sessionId === sessionId) records.push(record);
|
|
153
|
+
}
|
|
154
|
+
return analyzeSession(records, trendConfig);
|
|
155
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { logsDir } from './log-store.mjs';
|
|
6
|
+
|
|
7
|
+
// Stream the persisted decision JSONL logs line by line, yielding one parsed
|
|
8
|
+
// record per line. Files are read sequentially so a large corpus is never fully
|
|
9
|
+
// buffered. A malformed line is skipped, not fatal. Only this port's files are
|
|
10
|
+
// read when `port` is given (matching the log-store naming); otherwise all
|
|
11
|
+
// `decisions-*.jsonl` files are read.
|
|
12
|
+
export async function* createReadableStream({ dir = logsDir(), port } = {}) {
|
|
13
|
+
const prefix = Number.isInteger(port) && port > 0 ? `decisions-${port}-` : 'decisions-';
|
|
14
|
+
let names;
|
|
15
|
+
try { names = await readdir(dir); } catch { return; }
|
|
16
|
+
const files = names.filter(name => name.startsWith(prefix) && name.endsWith('.jsonl')).sort();
|
|
17
|
+
for (const name of files) {
|
|
18
|
+
const stream = createReadStream(join(dir, name), { encoding: 'utf8' });
|
|
19
|
+
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
20
|
+
try {
|
|
21
|
+
for await (const line of lines) {
|
|
22
|
+
if (!line.trim()) continue;
|
|
23
|
+
try { yield JSON.parse(line); } catch { /* skip malformed line */ }
|
|
24
|
+
}
|
|
25
|
+
} finally { lines.close(); stream.close(); }
|
|
26
|
+
}
|
|
27
|
+
}
|
package/service/log-store.mjs
CHANGED
|
@@ -61,6 +61,18 @@ export function createLogStore({ port, dir = logsDir(), flushIntervalMs = 5000,
|
|
|
61
61
|
if (buffer.length >= maxBuffer) { void flush(); return; }
|
|
62
62
|
if (!timer) timer = setTimeout(() => { timer = undefined; void flush(); }, flushIntervalMs);
|
|
63
63
|
},
|
|
64
|
+
// An annotation is an explicit user action, not telemetry: write it
|
|
65
|
+
// immediately (not batched) and surface failure to the caller instead of
|
|
66
|
+
// dropping it. It lands in the same JSONL directory as a `kind:"annotation"`
|
|
67
|
+
// event and is pruned with the decision logs on the next flush.
|
|
68
|
+
async appendAnnotation(entry) {
|
|
69
|
+
if (entry === null || typeof entry !== 'object') throw new Error('Invalid annotation');
|
|
70
|
+
const at = Number.isFinite(entry.at) ? entry.at : now().getTime();
|
|
71
|
+
const record = { ...entry, at };
|
|
72
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
73
|
+
await appendFile(fileFor(new Date(at)), `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
74
|
+
return record;
|
|
75
|
+
},
|
|
64
76
|
flush,
|
|
65
77
|
dispose() {
|
|
66
78
|
if (timer) { clearTimeout(timer); timer = undefined; }
|
package/service/loop-guard.mjs
CHANGED
|
@@ -28,12 +28,11 @@ export function createLoopGuard({ threshold = 3, maxSessions = 1024, sessions }
|
|
|
28
28
|
check(exec) {
|
|
29
29
|
const id = exec?.agent?.session?.id;
|
|
30
30
|
if (typeof id !== 'string') return undefined;
|
|
31
|
-
// The master switch pauses automatic intervention (mirrors the danger gate)
|
|
32
|
-
//
|
|
31
|
+
// The master switch pauses automatic intervention (mirrors the danger gate):
|
|
32
|
+
// a deliberate user toggle skips the loop guard too.
|
|
33
33
|
if (sessions) {
|
|
34
34
|
try {
|
|
35
|
-
|
|
36
|
-
if (snapshot.enabled === false && !snapshot.capacityExceeded) return undefined;
|
|
35
|
+
if (sessions.snapshot(id).enabled === false) return undefined;
|
|
37
36
|
} catch { /* fall through and guard */ }
|
|
38
37
|
}
|
|
39
38
|
const run = runFor(id);
|
package/service/manager-api.mjs
CHANGED
|
@@ -37,7 +37,7 @@ async function readBody(req) {
|
|
|
37
37
|
return value;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export function createManagerRoutes({ path, sessions, backend, env = process.env, bindHost = '127.0.0.1' }) {
|
|
40
|
+
export function createManagerRoutes({ path, sessions, backend, logStore, analyze, env = process.env, bindHost = '127.0.0.1' }) {
|
|
41
41
|
const route = (suffix, methods) => ({ kind: 'exact', path: `${API_PREFIX}/${suffix}`, handler: async (req, res) => {
|
|
42
42
|
if (bindHost !== '127.0.0.1' || !authorizedBrowser(req)) return respond(res, 403, { ok: false, error: 'Local same-origin browser request required' });
|
|
43
43
|
if (!Object.hasOwn(methods, req.method)) return respond(res, 405, { ok: false, error: 'Method not allowed' });
|
|
@@ -75,6 +75,31 @@ export function createManagerRoutes({ path, sessions, backend, env = process.env
|
|
|
75
75
|
}),
|
|
76
76
|
route('metrics', { GET: req => sessions.snapshot(sessionId(req)) }),
|
|
77
77
|
route('log', { GET: req => ({ entries: sessions.snapshot(sessionId(req)).log ?? [] }) }),
|
|
78
|
+
// Read-only analysis over the persisted decision logs for one session. Returns
|
|
79
|
+
// only aggregates and annotation counts — never raw log rows — so gate command
|
|
80
|
+
// / path fragments are not echoed back to the browser.
|
|
81
|
+
route('logs', {
|
|
82
|
+
GET: async req => {
|
|
83
|
+
if (typeof analyze !== 'function') { const error = new Error('analysis unavailable'); error.code = 'BAD_REQUEST'; throw error; }
|
|
84
|
+
return analyze(sessionId(req));
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
// Append one annotation event for a decision. Explicit user intent: validated
|
|
88
|
+
// and written immediately; a write failure surfaces as an error, not silence.
|
|
89
|
+
route('annotate', {
|
|
90
|
+
POST: async req => {
|
|
91
|
+
if (!logStore || typeof logStore.appendAnnotation !== 'function') { const error = new Error('annotation unavailable'); error.code = 'BAD_REQUEST'; throw error; }
|
|
92
|
+
const body = await readBody(req);
|
|
93
|
+
const target = typeof body.target === 'string' ? body.target.trim() : '';
|
|
94
|
+
const rating = body.rating;
|
|
95
|
+
const session = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
96
|
+
if (!target || target.length > 128 || !['good', 'bad', 'unsure'].includes(rating) || !session || session.length > 512) {
|
|
97
|
+
const error = new Error('Invalid annotation'); error.code = 'BAD_REQUEST'; throw error;
|
|
98
|
+
}
|
|
99
|
+
await logStore.appendAnnotation({ kind: 'annotation', target, rating, sessionId: session });
|
|
100
|
+
return { ok: true, target, rating };
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
78
103
|
route('session', {
|
|
79
104
|
GET: req => sessions.snapshot(sessionId(req)),
|
|
80
105
|
PUT: async req => {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Pure helpers for the output-quality trend alarm. A run counts consecutive
|
|
2
|
+
// high-confidence low self-check scores; the run length maps to a severity that
|
|
3
|
+
// the trigger icon renders (normal → warn → severe). All thresholds are
|
|
4
|
+
// unvalidated heuristics, tuned later on real decision logs.
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_TREND_MIN_CONFIDENCE = 0.6;
|
|
7
|
+
export const DEFAULT_TREND_RUN = 3;
|
|
8
|
+
export const DEFAULT_TREND_SEVERE_RUN = 5;
|
|
9
|
+
|
|
10
|
+
// Advance the run over ONE evaluated self-check:
|
|
11
|
+
// - a good score (not low) resets the run to 0;
|
|
12
|
+
// - a high-confidence low score increments it;
|
|
13
|
+
// - a low-confidence low score is skipped (neither resets nor increments);
|
|
14
|
+
// - an unevaluated/absent verdict must not call this at all.
|
|
15
|
+
// `sample` is { lowScore: boolean, confident: boolean }.
|
|
16
|
+
export function advanceRun(run, sample) {
|
|
17
|
+
const current = Number.isSafeInteger(run) && run >= 0 ? run : 0;
|
|
18
|
+
if (!sample || sample.lowScore !== true) return 0;
|
|
19
|
+
if (sample.confident === true) return current + 1;
|
|
20
|
+
return current;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Map a run length to a severity, given the warn/severe thresholds.
|
|
24
|
+
export function severityOf(run, { trendRun = DEFAULT_TREND_RUN, trendSevereRun = DEFAULT_TREND_SEVERE_RUN } = {}) {
|
|
25
|
+
const value = Number.isSafeInteger(run) && run >= 0 ? run : 0;
|
|
26
|
+
if (value >= trendSevereRun) return 'severe';
|
|
27
|
+
if (value >= trendRun) return 'warn';
|
|
28
|
+
return 'normal';
|
|
29
|
+
}
|
package/service/self-check.mjs
CHANGED
|
@@ -40,12 +40,16 @@ export function createSelfCheck({ evaluate, sessions, steer, settings, minConfid
|
|
|
40
40
|
try { raw = (typeof settings === 'function' ? await settings() : settings) ?? {}; } catch { raw = {}; }
|
|
41
41
|
const mode = raw?.mode === 'steer' ? 'steer' : 'observe';
|
|
42
42
|
const lowScoreThreshold = Number.isSafeInteger(raw?.lowScoreThreshold) && raw.lowScoreThreshold >= 0 ? raw.lowScoreThreshold : 1;
|
|
43
|
-
|
|
43
|
+
// Quality-trend thresholds (unvalidated heuristics; see quality-trend.mjs).
|
|
44
|
+
const trendMinConfidence = typeof raw?.trendMinConfidence === 'number' && Number.isFinite(raw.trendMinConfidence) && raw.trendMinConfidence >= 0 && raw.trendMinConfidence <= 1 ? raw.trendMinConfidence : 0.6;
|
|
45
|
+
const trendRun = Number.isSafeInteger(raw?.trendRun) && raw.trendRun >= 2 ? raw.trendRun : 3;
|
|
46
|
+
const trendSevereRun = Number.isSafeInteger(raw?.trendSevereRun) && raw.trendSevereRun > trendRun ? raw.trendSevereRun : Math.max(trendRun + 1, 5);
|
|
47
|
+
return { mode, rubric: boundedRubric(raw?.rubric), lowScoreThreshold, trendMinConfidence, trendRun, trendSevereRun };
|
|
44
48
|
};
|
|
45
|
-
const record = (turn, outcome, detail = {}) => {
|
|
49
|
+
const record = (turn, outcome, detail = {}, trend) => {
|
|
46
50
|
const id = turn?.agent?.session?.id;
|
|
47
51
|
if (!sessions || typeof id !== 'string' || typeof sessions.recordCheck !== 'function') return;
|
|
48
|
-
try { sessions.recordCheck(id, outcome); } catch { /* telemetry is best effort */ }
|
|
52
|
+
try { sessions.recordCheck(id, outcome, trend); } catch { /* telemetry is best effort */ }
|
|
49
53
|
if (typeof sessions.log !== 'function') return;
|
|
50
54
|
try { sessions.log(id, { kind: 'check', outcome, ...detail }); }
|
|
51
55
|
catch { /* logging is best effort */ }
|
|
@@ -59,7 +63,7 @@ export function createSelfCheck({ evaluate, sessions, steer, settings, minConfid
|
|
|
59
63
|
try { if (sessions.snapshot(id).enabled === false) return { evaluated: false, lowScore: false, steered: false }; }
|
|
60
64
|
catch { return { evaluated: false, lowScore: false, steered: false }; }
|
|
61
65
|
}
|
|
62
|
-
const { mode, rubric, lowScoreThreshold } = await resolveSettings();
|
|
66
|
+
const { mode, rubric, lowScoreThreshold, trendMinConfidence, trendRun, trendSevereRun } = await resolveSettings();
|
|
63
67
|
let answer;
|
|
64
68
|
try {
|
|
65
69
|
const result = await evaluate(scoreQuestion(output, rubric), { signal: turn.signal });
|
|
@@ -75,7 +79,10 @@ export function createSelfCheck({ evaluate, sessions, steer, settings, minConfid
|
|
|
75
79
|
const confidence = typeof answer.confidence === 'number' && Number.isFinite(answer.confidence) ? answer.confidence : undefined;
|
|
76
80
|
const detail = confidence === undefined ? {} : { confidence };
|
|
77
81
|
const lowScore = answer.score <= lowScoreThreshold;
|
|
78
|
-
|
|
82
|
+
// The trend run advances over every evaluated score; a high-confidence low
|
|
83
|
+
// score is the only thing that grows it. Thresholds flow to session-state.
|
|
84
|
+
const trend = { confident: lowScore && confidence !== undefined && confidence >= trendMinConfidence, trendRun, trendSevereRun };
|
|
85
|
+
record(turn, lowScore ? 'low' : 'ok', { score: answer.score, ...detail }, trend);
|
|
79
86
|
if (!lowScore) return { evaluated: true, lowScore: false, steered: false, score: answer.score };
|
|
80
87
|
const confident = confidence !== undefined && confidence >= minConfidence;
|
|
81
88
|
if (mode === 'steer' && confident && typeof steer === 'function' && turn?.agent) {
|