dsh-decision-layer 0.4.0 → 0.5.1

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.
@@ -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 }, log: [] };
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) throw new Error('Session state limit reached');
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
- const key = sessionKey(id);
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: overflow ? false : state.enabled, hasAutomaticDecisions: state.attempts > 0, attempts: state.attempts, failures: state.failures };
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 = { ...state.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
- else if (outcome === 'low') state.check.low++;
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
- const record = { at: Date.now(), kind: entry.kind, outcome: typeof entry.outcome === 'string' ? entry.outcome.slice(0, 32) : '' };
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
+ }