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
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { advanceRun, severityOf } from './quality-trend.mjs';
|
|
3
|
+
|
|
1
4
|
function sessionKey(value) {
|
|
2
5
|
if (typeof value !== 'string' || !value.trim() || value.length > 512) throw new Error('Invalid session ID');
|
|
3
6
|
return value.trim();
|
|
@@ -6,8 +9,9 @@ function sessionKey(value) {
|
|
|
6
9
|
function emptyState() {
|
|
7
10
|
return { enabled: true, attempts: 0, failures: 0,
|
|
8
11
|
gate: { attempts: 0, failures: 0, ask: 0, deny: 0, allow: 0, actual: { allow: 0, deny: 0, error: 0 } },
|
|
9
|
-
check: { attempts: 0, failures: 0, low: 0 },
|
|
10
|
-
narrow: { attempts: 0, failures: 0, applied: 0, dropped: 0 },
|
|
12
|
+
check: { attempts: 0, failures: 0, low: 0, run: 0, severity: 'normal' },
|
|
13
|
+
narrow: { attempts: 0, failures: 0, applied: 0, dropped: 0 },
|
|
14
|
+
complete: { attempts: 0, failures: 0, unsatisfied: 0, satisfied: 0, insufficient: 0, steered: 0 }, log: [] };
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
function safeTool(value) {
|
|
@@ -19,11 +23,17 @@ export function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onL
|
|
|
19
23
|
if (!Number.isSafeInteger(maxLogEntries) || maxLogEntries < 1) throw new Error('Invalid log limit');
|
|
20
24
|
const sessions = new Map();
|
|
21
25
|
const sink = typeof onLog === 'function' ? onLog : undefined;
|
|
26
|
+
// FIFO eviction: when the map is full, drop the oldest-inserted session before
|
|
27
|
+
// admitting a new one (Map preserves insertion order, so the first key is the
|
|
28
|
+
// oldest). This mirrors loop-guard / narrowing and means the store never
|
|
29
|
+
// "fills up" — there is no capacity-exceeded state. An evicted session's
|
|
30
|
+
// in-memory counters/log are gone, but the persisted JSONL log is unaffected;
|
|
31
|
+
// a later call rebuilds an empty state for it.
|
|
22
32
|
const writable = id => {
|
|
23
33
|
const key = sessionKey(id);
|
|
24
34
|
let state = sessions.get(key);
|
|
25
35
|
if (!state) {
|
|
26
|
-
if (sessions.size >= maxSessions)
|
|
36
|
+
if (sessions.size >= maxSessions) sessions.delete(sessions.keys().next().value);
|
|
27
37
|
state = emptyState();
|
|
28
38
|
sessions.set(key, state);
|
|
29
39
|
}
|
|
@@ -32,19 +42,16 @@ export function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onL
|
|
|
32
42
|
return {
|
|
33
43
|
setEnabled(id, enabled) {
|
|
34
44
|
if (typeof enabled !== 'boolean') throw new Error('Invalid enabled value');
|
|
35
|
-
|
|
36
|
-
if (!sessions.has(key) && sessions.size >= maxSessions && !enabled) return;
|
|
37
|
-
writable(key).enabled = enabled;
|
|
45
|
+
writable(id).enabled = enabled;
|
|
38
46
|
},
|
|
39
47
|
snapshot(id) {
|
|
40
48
|
const stored = sessions.get(sessionKey(id));
|
|
41
|
-
const overflow = !stored && sessions.size >= maxSessions;
|
|
42
49
|
const state = stored ?? emptyState();
|
|
43
|
-
const value = { enabled:
|
|
44
|
-
if (overflow) value.capacityExceeded = true;
|
|
50
|
+
const value = { enabled: state.enabled, hasAutomaticDecisions: state.attempts > 0, attempts: state.attempts, failures: state.failures };
|
|
45
51
|
if (state.gate.attempts > 0) value.gate = { ...state.gate, actual: { ...state.gate.actual } };
|
|
46
|
-
if (state.check.attempts > 0) value.check = {
|
|
52
|
+
if (state.check.attempts > 0) value.check = { attempts: state.check.attempts, failures: state.check.failures, low: state.check.low, trend: { run: state.check.run, severity: state.check.severity } };
|
|
47
53
|
if (state.narrow.attempts > 0) value.narrow = { ...state.narrow };
|
|
54
|
+
if (state.complete.attempts > 0) value.complete = { ...state.complete };
|
|
48
55
|
if (state.log.length > 0) value.log = state.log.map(entry => ({ ...entry }));
|
|
49
56
|
return value;
|
|
50
57
|
},
|
|
@@ -60,13 +67,21 @@ export function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onL
|
|
|
60
67
|
if (!['allow', 'deny', 'error'].includes(outcome)) throw new Error('Invalid actual decision');
|
|
61
68
|
writable(id).gate.actual[outcome]++;
|
|
62
69
|
},
|
|
63
|
-
recordCheck(id, outcome) {
|
|
70
|
+
recordCheck(id, outcome, trend) {
|
|
64
71
|
if (!['ok', 'low', 'error'].includes(outcome)) throw new Error('Invalid self-check outcome');
|
|
65
72
|
const state = writable(id);
|
|
66
73
|
state.attempts++;
|
|
67
74
|
state.check.attempts++;
|
|
68
|
-
if (outcome === 'error') { state.failures++; state.check.failures++; }
|
|
69
|
-
|
|
75
|
+
if (outcome === 'error') { state.failures++; state.check.failures++; return; }
|
|
76
|
+
if (outcome === 'low') state.check.low++;
|
|
77
|
+
// Advance the quality-trend run over this evaluated score. A good score
|
|
78
|
+
// resets the run; a high-confidence low score grows it; a low-confidence
|
|
79
|
+
// low score neither resets nor grows. `trend` carries the per-sample flags
|
|
80
|
+
// and the configured thresholds; absent trend leaves the run unchanged.
|
|
81
|
+
if (trend && typeof trend === 'object') {
|
|
82
|
+
state.check.run = advanceRun(state.check.run, { lowScore: outcome === 'low', confident: trend.confident === true });
|
|
83
|
+
state.check.severity = severityOf(state.check.run, { trendRun: trend.trendRun, trendSevereRun: trend.trendSevereRun });
|
|
84
|
+
}
|
|
70
85
|
},
|
|
71
86
|
recordNarrow(id, outcome, dropped = 0) {
|
|
72
87
|
if (!['ok', 'applied', 'error'].includes(outcome)) throw new Error('Invalid narrowing outcome');
|
|
@@ -79,10 +94,27 @@ export function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onL
|
|
|
79
94
|
if (Number.isSafeInteger(dropped) && dropped > 0) state.narrow.dropped += dropped;
|
|
80
95
|
}
|
|
81
96
|
},
|
|
97
|
+
// Task-completion checking. `ok`/`unsatisfied` are real evaluations; `error`
|
|
98
|
+
// is an unevaluated failure counted as a failure, never as "unsatisfied".
|
|
99
|
+
// The per-condition three-state tallies come from `detail`.
|
|
100
|
+
recordComplete(id, outcome, detail = {}) {
|
|
101
|
+
if (!['ok', 'unsatisfied', 'error'].includes(outcome)) throw new Error('Invalid task-completion outcome');
|
|
102
|
+
const state = writable(id);
|
|
103
|
+
state.attempts++;
|
|
104
|
+
state.complete.attempts++;
|
|
105
|
+
if (outcome === 'error') { state.failures++; state.complete.failures++; return; }
|
|
106
|
+
if (Number.isSafeInteger(detail.satisfied) && detail.satisfied > 0) state.complete.satisfied += detail.satisfied;
|
|
107
|
+
if (Number.isSafeInteger(detail.unsatisfied) && detail.unsatisfied > 0) state.complete.unsatisfied += detail.unsatisfied;
|
|
108
|
+
if (Number.isSafeInteger(detail.insufficient) && detail.insufficient > 0) state.complete.insufficient += detail.insufficient;
|
|
109
|
+
if (detail.steered === true) state.complete.steered++;
|
|
110
|
+
},
|
|
82
111
|
log(id, entry) {
|
|
83
112
|
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) throw new Error('Invalid log entry');
|
|
84
|
-
if (!['gate', 'check', 'narrow'].includes(entry.kind)) throw new Error('Invalid log entry');
|
|
85
|
-
|
|
113
|
+
if (!['gate', 'check', 'narrow', 'complete'].includes(entry.kind)) throw new Error('Invalid log entry');
|
|
114
|
+
// Every decision record carries a stable unique id, generated here so the
|
|
115
|
+
// in-memory panel copy and the persisted sink record share the same id.
|
|
116
|
+
// Later annotation events reference this id to attribute a rating to one decision.
|
|
117
|
+
const record = { id: randomUUID(), at: Date.now(), kind: entry.kind, outcome: typeof entry.outcome === 'string' ? entry.outcome.slice(0, 32) : '' };
|
|
86
118
|
const tool = safeTool(entry.tool);
|
|
87
119
|
if (tool) record.tool = tool;
|
|
88
120
|
if (typeof entry.suggestion === 'string' && entry.suggestion) record.suggestion = entry.suggestion.slice(0, 16);
|
|
@@ -94,6 +126,11 @@ export function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onL
|
|
|
94
126
|
if (Number.isSafeInteger(entry.dropped) && entry.dropped >= 0) record.dropped = entry.dropped;
|
|
95
127
|
if (Number.isSafeInteger(entry.kept) && entry.kept >= 0) record.kept = entry.kept;
|
|
96
128
|
if (Number.isSafeInteger(entry.candidates) && entry.candidates >= 0) record.candidates = entry.candidates;
|
|
129
|
+
if (Number.isSafeInteger(entry.conditions) && entry.conditions >= 0) record.conditions = entry.conditions;
|
|
130
|
+
if (Number.isSafeInteger(entry.satisfied) && entry.satisfied >= 0) record.satisfied = entry.satisfied;
|
|
131
|
+
if (Number.isSafeInteger(entry.unsatisfied) && entry.unsatisfied >= 0) record.unsatisfied = entry.unsatisfied;
|
|
132
|
+
if (Number.isSafeInteger(entry.insufficient) && entry.insufficient >= 0) record.insufficient = entry.insufficient;
|
|
133
|
+
if (entry.steered === true) record.steered = true;
|
|
97
134
|
if (Array.isArray(entry.tools)) {
|
|
98
135
|
const names = entry.tools.filter(name => typeof name === 'string' && name.trim()).slice(0, 12).map(name => name.trim().slice(0, 64));
|
|
99
136
|
if (names.length > 0) record.tools = names;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const MAX_STATE = 8 * 1024;
|
|
2
|
+
const MAX_CONDITION = 512;
|
|
3
|
+
const MAX_CONDITIONS = 20;
|
|
4
|
+
const MAX_EVIDENCE = 40;
|
|
5
|
+
|
|
6
|
+
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
const probability = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
8
|
+
|
|
9
|
+
const STEER_TEXT = '本回合任务完成核对发现有明确交付条件尚未满足:请对照用户列出的条件逐项复核,补完未完成的部分。';
|
|
10
|
+
|
|
11
|
+
// Deterministic extraction of the explicit deliverable conditions a user listed.
|
|
12
|
+
// Only structured forms are recognized — numbered lists, bullet lists, and a
|
|
13
|
+
// single line of comma/、-separated multiple commands. Implicit or inferred
|
|
14
|
+
// expectations are deliberately out of scope for the first version.
|
|
15
|
+
export function extractConditions(userText) {
|
|
16
|
+
const text = typeof userText === 'string' ? userText : '';
|
|
17
|
+
if (!text.trim()) return [];
|
|
18
|
+
const conditions = [];
|
|
19
|
+
const push = value => {
|
|
20
|
+
const item = value.trim().replace(/\s+/g, ' ').slice(0, MAX_CONDITION);
|
|
21
|
+
if (item && !conditions.includes(item)) conditions.push(item);
|
|
22
|
+
};
|
|
23
|
+
const lines = text.split(/\r?\n/);
|
|
24
|
+
let matchedStructured = false;
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
// Numbered: `1. `, `1) `, `1、`, `(1)`, `(1)`; or bullets: `- `, `* `, `• `.
|
|
27
|
+
const numbered = line.match(/^\s*(?:[((]?\d+[)).、]|\d+\s*[.)、])\s+(.*)$/);
|
|
28
|
+
const bullet = line.match(/^\s*[-*•]\s+(.*)$/);
|
|
29
|
+
if (numbered && numbered[1].trim()) { push(numbered[1]); matchedStructured = true; }
|
|
30
|
+
else if (bullet && bullet[1].trim()) { push(bullet[1]); matchedStructured = true; }
|
|
31
|
+
}
|
|
32
|
+
// Fallback: a single instruction line joining multiple commands with 、/,/,
|
|
33
|
+
// and enough parts to look like an explicit multi-step ask (e.g. "改配置、跑测试、说明失败原因").
|
|
34
|
+
if (!matchedStructured) {
|
|
35
|
+
const compact = text.trim();
|
|
36
|
+
if (!/[\r\n]/.test(compact)) {
|
|
37
|
+
const parts = compact.split(/[、,,]/).map(part => part.trim()).filter(Boolean);
|
|
38
|
+
if (parts.length >= 2) for (const part of parts) push(part);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return conditions.slice(0, MAX_CONDITIONS);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Build one choice question per explicit condition. The state carries the
|
|
45
|
+
// conditions, the turn's tool evidence, and the final reply. Tool evidence is
|
|
46
|
+
// primary; the reply's own claims are explicitly NOT treated as proof, so the
|
|
47
|
+
// model can answer insufficient_evidence when only the reply asserts completion.
|
|
48
|
+
export function completionQuestions(conditions, evidence, answer) {
|
|
49
|
+
const items = Array.isArray(conditions) ? conditions.slice(0, MAX_CONDITIONS) : [];
|
|
50
|
+
const tools = Array.isArray(evidence)
|
|
51
|
+
? evidence.filter(item => isObject(item) && typeof item.tool === 'string').slice(0, MAX_EVIDENCE)
|
|
52
|
+
.map(item => ({ tool: item.tool.slice(0, 128), outcome: item.outcome === 'error' ? 'error' : 'ok' }))
|
|
53
|
+
: [];
|
|
54
|
+
const state = {
|
|
55
|
+
conditions: items.map((text, index) => ({ id: `c${index}`, text })),
|
|
56
|
+
toolEvidence: tools,
|
|
57
|
+
finalReply: String(answer ?? '').slice(0, MAX_STATE),
|
|
58
|
+
note: '工具证据(toolEvidence)是判定依据;finalReply 自称完成不作为证据。缺少证据时选 insufficient_evidence。',
|
|
59
|
+
};
|
|
60
|
+
const questions = Object.fromEntries(items.map((text, index) => [`c${index}`, {
|
|
61
|
+
type: 'choice',
|
|
62
|
+
instructions: {
|
|
63
|
+
condition: text,
|
|
64
|
+
question: '根据当前工具证据与最终回复,这个交付条件的完成情况属于哪一种?',
|
|
65
|
+
},
|
|
66
|
+
criteria: {
|
|
67
|
+
satisfied: '有工具证据或用户明确确认表明该条件已完成',
|
|
68
|
+
unsatisfied: '有证据表明该条件未完成或做错',
|
|
69
|
+
insufficient_evidence: '现有证据不足以判断该条件是否完成',
|
|
70
|
+
},
|
|
71
|
+
}]));
|
|
72
|
+
return { state, questions };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Orchestrates one task-completion pass at turn end. Read-only by default: it
|
|
76
|
+
// records a per-condition verdict and only steers when explicitly configured,
|
|
77
|
+
// the model is confident, and at least one condition is not satisfied. A steer
|
|
78
|
+
// opens a supplemental step; it never rewrites the already-committed reply.
|
|
79
|
+
export function createTaskCompletion({ evaluate, sessions, steer, settings, minConfidence = 0.4 } = {}) {
|
|
80
|
+
if (typeof evaluate !== 'function') throw new Error('Task completion requires an evaluator');
|
|
81
|
+
const steeredTurns = new Set();
|
|
82
|
+
const steerKey = turn => {
|
|
83
|
+
const id = turn?.agent?.session?.id;
|
|
84
|
+
return typeof id === 'string' && Number.isInteger(turn?.turn) ? `${id}:${turn.turn}` : undefined;
|
|
85
|
+
};
|
|
86
|
+
const resolveSettings = async () => {
|
|
87
|
+
let raw = {};
|
|
88
|
+
try { raw = (typeof settings === 'function' ? await settings() : settings) ?? {}; } catch { raw = {}; }
|
|
89
|
+
const mode = raw?.mode === 'steer' ? 'steer' : 'observe';
|
|
90
|
+
const minConditions = Number.isSafeInteger(raw?.minConditions) && raw.minConditions >= 2 ? raw.minConditions : 2;
|
|
91
|
+
return { mode, minConditions };
|
|
92
|
+
};
|
|
93
|
+
const record = (turn, outcome, detail = {}) => {
|
|
94
|
+
const id = turn?.agent?.session?.id;
|
|
95
|
+
if (!sessions || typeof id !== 'string' || typeof sessions.recordComplete !== 'function') return;
|
|
96
|
+
try { sessions.recordComplete(id, outcome, detail); } catch { /* telemetry is best effort */ }
|
|
97
|
+
if (typeof sessions.log !== 'function') return;
|
|
98
|
+
try { sessions.log(id, { kind: 'complete', outcome, ...detail }); }
|
|
99
|
+
catch { /* logging is best effort */ }
|
|
100
|
+
};
|
|
101
|
+
const skip = { evaluated: false, steered: false };
|
|
102
|
+
return {
|
|
103
|
+
async review(turn) {
|
|
104
|
+
const output = typeof turn?.output === 'string' ? turn.output.trim() : '';
|
|
105
|
+
const { mode, minConditions } = await resolveSettings();
|
|
106
|
+
const conditions = extractConditions(turn?.userText);
|
|
107
|
+
if (conditions.length < minConditions) return { ...skip };
|
|
108
|
+
const evidence = Array.isArray(turn?.evidence) ? turn.evidence : [];
|
|
109
|
+
if (evidence.length === 0) return { ...skip };
|
|
110
|
+
const id = turn?.agent?.session?.id;
|
|
111
|
+
if (sessions && typeof id === 'string') {
|
|
112
|
+
try { if (sessions.snapshot(id).enabled === false) return { ...skip }; }
|
|
113
|
+
catch { return { ...skip }; }
|
|
114
|
+
}
|
|
115
|
+
let answers;
|
|
116
|
+
try {
|
|
117
|
+
const result = await evaluate(completionQuestions(conditions, evidence, output), { signal: turn.signal });
|
|
118
|
+
answers = isObject(result?.answers) ? result.answers : undefined;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
record(turn, 'error', { reason: typeof error?.reason === 'string' ? error.reason : 'unreachable' });
|
|
121
|
+
return { ...skip };
|
|
122
|
+
}
|
|
123
|
+
if (!answers) { record(turn, 'error', { reason: 'invalid-response' }); return { ...skip }; }
|
|
124
|
+
let satisfied = 0, unsatisfied = 0, insufficient = 0;
|
|
125
|
+
let minConf = 1;
|
|
126
|
+
for (let index = 0; index < conditions.length; index++) {
|
|
127
|
+
const answer = answers[`c${index}`];
|
|
128
|
+
if (!isObject(answer) || answer.type !== 'choice' || typeof answer.choice !== 'string') {
|
|
129
|
+
insufficient++; continue;
|
|
130
|
+
}
|
|
131
|
+
if (probability(answer.confidence)) minConf = Math.min(minConf, answer.confidence);
|
|
132
|
+
if (answer.choice === 'satisfied') satisfied++;
|
|
133
|
+
else if (answer.choice === 'unsatisfied') unsatisfied++;
|
|
134
|
+
else insufficient++;
|
|
135
|
+
}
|
|
136
|
+
const outcome = unsatisfied > 0 ? 'unsatisfied' : 'ok';
|
|
137
|
+
const detail = { conditions: conditions.length, satisfied, unsatisfied, insufficient };
|
|
138
|
+
// A steer is only for a confident finding that a listed condition is not met.
|
|
139
|
+
const confident = unsatisfied > 0 && minConf >= minConfidence;
|
|
140
|
+
let steered = false;
|
|
141
|
+
if (mode === 'steer' && confident && typeof steer === 'function' && turn?.agent) {
|
|
142
|
+
const key = steerKey(turn);
|
|
143
|
+
if (key !== undefined && !steeredTurns.has(key)) {
|
|
144
|
+
steeredTurns.add(key);
|
|
145
|
+
if (steeredTurns.size > 4096) steeredTurns.delete(steeredTurns.values().next().value);
|
|
146
|
+
try { steer(turn.agent, STEER_TEXT); steered = true; }
|
|
147
|
+
catch { steered = false; }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (steered) detail.steered = true;
|
|
151
|
+
record(turn, outcome, detail);
|
|
152
|
+
return { evaluated: true, steered, satisfied, unsatisfied, insufficient };
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|