dsh-decision-layer 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.en.md +14 -14
  2. package/README.md +16 -16
  3. package/docs/en/DETAIL.md +2 -2
  4. package/docs/zh/DETAIL.md +2 -2
  5. package/lib/client.js +54 -23
  6. package/lib/host/backend.d.ts +15 -0
  7. package/lib/host/backend.js +149 -0
  8. package/lib/host/config-store.d.ts +12 -0
  9. package/lib/host/config-store.js +254 -0
  10. package/lib/host/danger-gate.d.ts +45 -0
  11. package/lib/host/danger-gate.js +196 -0
  12. package/lib/host/decision-context.d.ts +21 -0
  13. package/lib/host/decision-context.js +187 -0
  14. package/lib/host/excerpt.d.ts +26 -0
  15. package/lib/host/excerpt.js +76 -0
  16. package/lib/host/index.d.ts +6 -0
  17. package/lib/host/index.js +287 -0
  18. package/lib/host/log-analyze.d.ts +121 -0
  19. package/lib/host/log-analyze.js +254 -0
  20. package/lib/host/log-read.d.ts +4 -0
  21. package/lib/host/log-read.js +34 -0
  22. package/lib/host/log-store.d.ts +15 -0
  23. package/lib/host/log-store.js +98 -0
  24. package/lib/host/loop-guard.d.ts +10 -0
  25. package/lib/host/loop-guard.js +52 -0
  26. package/lib/host/manager-api.d.ts +20 -0
  27. package/lib/host/manager-api.js +174 -0
  28. package/lib/host/narrowing.d.ts +51 -0
  29. package/lib/host/narrowing.js +214 -0
  30. package/lib/host/quality-trend.d.ts +14 -0
  31. package/lib/host/quality-trend.js +22 -0
  32. package/lib/host/self-check.d.ts +28 -0
  33. package/lib/host/self-check.js +111 -0
  34. package/lib/host/session-state.d.ts +19 -0
  35. package/lib/host/session-state.js +289 -0
  36. package/lib/host/state-store.d.ts +14 -0
  37. package/lib/host/state-store.js +109 -0
  38. package/lib/host/steer-once.d.ts +13 -0
  39. package/lib/host/steer-once.js +25 -0
  40. package/lib/host/task-completion.d.ts +30 -0
  41. package/lib/host/task-completion.js +166 -0
  42. package/lib/host/types.d.ts +365 -0
  43. package/lib/host/types.js +11 -0
  44. package/package.json +17 -10
  45. package/service/.gitkeep +0 -0
  46. package/service/backend.mjs +0 -127
  47. package/service/config-store.mjs +0 -311
  48. package/service/danger-gate.mjs +0 -182
  49. package/service/dsh.mjs +0 -288
  50. package/service/log-analyze.mjs +0 -257
  51. package/service/log-read.mjs +0 -27
  52. package/service/log-store.mjs +0 -82
  53. package/service/loop-guard.mjs +0 -51
  54. package/service/manager-api.mjs +0 -137
  55. package/service/narrowing.mjs +0 -226
  56. package/service/quality-trend.mjs +0 -29
  57. package/service/self-check.mjs +0 -131
  58. package/service/session-state.mjs +0 -260
  59. package/service/state-store.mjs +0 -100
  60. package/service/steer-once.mjs +0 -25
  61. package/service/task-completion.mjs +0 -163
@@ -1,260 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { advanceRun, severityOf } from './quality-trend.mjs';
3
-
4
- function sessionKey(value) {
5
- if (typeof value !== 'string' || !value.trim() || value.length > 512) throw new Error('Invalid session ID');
6
- return value.trim();
7
- }
8
-
9
- function emptyState() {
10
- return { enabled: true, attempts: 0, failures: 0,
11
- gate: { attempts: 0, failures: 0, ask: 0, deny: 0, allow: 0, actual: { allow: 0, deny: 0, error: 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: [] };
15
- }
16
-
17
- function safeTool(value) {
18
- return typeof value === 'string' && value.trim() ? value.trim().slice(0, 128) : undefined;
19
- }
20
-
21
- const toCount = value => (Number.isSafeInteger(value) && value >= 0 ? value : 0);
22
-
23
- // Rebuild a trusted in-memory state from a persisted (untrusted) snapshot. Every
24
- // counter is coerced into the emptyState() shape with a non-negative-integer
25
- // guard and the trend severity into its enum, so a stale or tampered file can
26
- // never inject an out-of-shape value into the live counters. The recent log is
27
- // kept as plain records (newest-last); callers trim it to the log cap.
28
- function coerceState(raw) {
29
- const state = emptyState();
30
- if (!raw || typeof raw !== 'object') return state;
31
- if (typeof raw.enabled === 'boolean') state.enabled = raw.enabled;
32
- state.attempts = toCount(raw.attempts);
33
- state.failures = toCount(raw.failures);
34
- const g = raw.gate;
35
- if (g && typeof g === 'object') {
36
- state.gate.attempts = toCount(g.attempts); state.gate.failures = toCount(g.failures);
37
- state.gate.ask = toCount(g.ask); state.gate.deny = toCount(g.deny); state.gate.allow = toCount(g.allow);
38
- const a = g.actual;
39
- if (a && typeof a === 'object') { state.gate.actual.allow = toCount(a.allow); state.gate.actual.deny = toCount(a.deny); state.gate.actual.error = toCount(a.error); }
40
- }
41
- const c = raw.check;
42
- if (c && typeof c === 'object') {
43
- state.check.attempts = toCount(c.attempts); state.check.failures = toCount(c.failures); state.check.low = toCount(c.low);
44
- state.check.run = toCount(c.run);
45
- state.check.severity = ['normal', 'warn', 'severe'].includes(c.severity) ? c.severity : 'normal';
46
- }
47
- const n = raw.narrow;
48
- if (n && typeof n === 'object') {
49
- state.narrow.attempts = toCount(n.attempts); state.narrow.failures = toCount(n.failures);
50
- state.narrow.applied = toCount(n.applied); state.narrow.dropped = toCount(n.dropped);
51
- }
52
- const k = raw.complete;
53
- if (k && typeof k === 'object') {
54
- state.complete.attempts = toCount(k.attempts); state.complete.failures = toCount(k.failures);
55
- state.complete.unsatisfied = toCount(k.unsatisfied); state.complete.satisfied = toCount(k.satisfied);
56
- state.complete.insufficient = toCount(k.insufficient); state.complete.steered = toCount(k.steered);
57
- }
58
- if (Array.isArray(raw.log)) {
59
- state.log = raw.log
60
- .filter(entry => entry && typeof entry === 'object' && !Array.isArray(entry) && typeof entry.kind === 'string')
61
- .map(entry => ({ ...entry }));
62
- }
63
- return state;
64
- }
65
-
66
- export function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onLog, loadState, persistState } = {}) {
67
- if (!Number.isSafeInteger(maxSessions) || maxSessions < 1) throw new Error('Invalid session limit');
68
- if (!Number.isSafeInteger(maxLogEntries) || maxLogEntries < 1) throw new Error('Invalid log limit');
69
- const sessions = new Map();
70
- const sink = typeof onLog === 'function' ? onLog : undefined;
71
- const load = typeof loadState === 'function' ? loadState : undefined;
72
- const persist = typeof persistState === 'function' ? persistState : undefined;
73
- // Memoize rehydration per id so a session is seeded from disk at most once per
74
- // process. Bounded like `sessions`; dropping a memo entry only risks a
75
- // redundant disk read that no-ops (ensureLoaded never clobbers live state).
76
- const loaded = new Map();
77
- // Mirror the latest live state to the durable store after every mutation.
78
- // Best-effort: a persistence fault must never surface in the decision path.
79
- const bump = (key, state) => { if (persist) { try { persist(key, state); } catch { /* best effort */ } } };
80
- // FIFO eviction: when the map is full, drop the oldest-inserted session before
81
- // admitting a new one (Map preserves insertion order, so the first key is the
82
- // oldest). This mirrors loop-guard / narrowing and means the store never
83
- // "fills up" — there is no capacity-exceeded state. An evicted session's
84
- // in-memory counters/log are gone, but the persisted JSONL log is unaffected;
85
- // a later call rebuilds an empty state for it.
86
- const writable = id => {
87
- const key = sessionKey(id);
88
- let state = sessions.get(key);
89
- if (!state) {
90
- if (sessions.size >= maxSessions) sessions.delete(sessions.keys().next().value);
91
- state = emptyState();
92
- sessions.set(key, state);
93
- }
94
- return state;
95
- };
96
- return {
97
- // Rehydrate a session's live counters/log from the durable store the first
98
- // time it is touched this process, before any new event mutates it — so disk
99
- // history and in-process events never double-count. Memoized per id; a no-op
100
- // when no loadState was given or the session already has live state. Callers
101
- // should await this before the first snapshot/record of a resumed session
102
- // (the plugin does so at the turn's first step and in the panel routes).
103
- async ensureLoaded(id) {
104
- if (!load) return;
105
- let key;
106
- try { key = sessionKey(id); } catch { return; }
107
- const existing = loaded.get(key);
108
- if (existing) return existing;
109
- const promise = (async () => {
110
- if (sessions.has(key)) return; // live events already created state; keep it
111
- let raw;
112
- try { raw = await load(key); } catch { raw = undefined; }
113
- if (!raw) return;
114
- if (sessions.has(key)) return; // a concurrent mutation won the race; do not clobber
115
- const state = coerceState(raw);
116
- if (state.log.length > maxLogEntries) state.log.splice(0, state.log.length - maxLogEntries);
117
- if (sessions.size >= maxSessions) sessions.delete(sessions.keys().next().value);
118
- sessions.set(key, state);
119
- })();
120
- if (loaded.size >= maxSessions) loaded.delete(loaded.keys().next().value);
121
- loaded.set(key, promise);
122
- return promise;
123
- },
124
- setEnabled(id, enabled) {
125
- if (typeof enabled !== 'boolean') throw new Error('Invalid enabled value');
126
- const state = writable(id);
127
- state.enabled = enabled;
128
- bump(sessionKey(id), state);
129
- },
130
- snapshot(id) {
131
- const stored = sessions.get(sessionKey(id));
132
- const state = stored ?? emptyState();
133
- const value = { enabled: state.enabled, hasAutomaticDecisions: state.attempts > 0, attempts: state.attempts, failures: state.failures };
134
- if (state.gate.attempts > 0) value.gate = { ...state.gate, actual: { ...state.gate.actual } };
135
- 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 } };
136
- if (state.narrow.attempts > 0) value.narrow = { ...state.narrow };
137
- if (state.complete.attempts > 0) value.complete = { ...state.complete };
138
- if (state.log.length > 0) value.log = state.log.map(entry => ({ ...entry }));
139
- return value;
140
- },
141
- record(id, kind, outcome) {
142
- if (kind !== 'gate' || !['allow', 'ask', 'deny', 'error'].includes(outcome)) throw new Error('Invalid automatic decision');
143
- const state = writable(id);
144
- state.attempts++;
145
- state.gate.attempts++;
146
- if (outcome === 'error') { state.failures++; state.gate.failures++; }
147
- else state.gate[outcome]++;
148
- bump(sessionKey(id), state);
149
- },
150
- recordActual(id, outcome) {
151
- if (!['allow', 'deny', 'error'].includes(outcome)) throw new Error('Invalid actual decision');
152
- const state = writable(id);
153
- state.gate.actual[outcome]++;
154
- bump(sessionKey(id), state);
155
- },
156
- recordCheck(id, outcome, trend) {
157
- if (!['ok', 'low', 'error'].includes(outcome)) throw new Error('Invalid self-check outcome');
158
- const state = writable(id);
159
- state.attempts++;
160
- state.check.attempts++;
161
- if (outcome === 'error') { state.failures++; state.check.failures++; }
162
- else {
163
- if (outcome === 'low') state.check.low++;
164
- // Advance the quality-trend run over this evaluated score. A good score
165
- // resets the run; a high-confidence low score grows it; a low-confidence
166
- // low score neither resets nor grows. `trend` carries the per-sample flags
167
- // and the configured thresholds; absent trend leaves the run unchanged.
168
- if (trend && typeof trend === 'object') {
169
- state.check.run = advanceRun(state.check.run, { lowScore: outcome === 'low', confident: trend.confident === true });
170
- state.check.severity = severityOf(state.check.run, { trendRun: trend.trendRun, trendSevereRun: trend.trendSevereRun });
171
- }
172
- }
173
- bump(sessionKey(id), state);
174
- },
175
- recordNarrow(id, outcome, dropped = 0, applied = outcome === 'applied') {
176
- if (!['ok', 'applied', 'error'].includes(outcome)) throw new Error('Invalid narrowing outcome');
177
- const state = writable(id);
178
- state.attempts++;
179
- state.narrow.attempts++;
180
- if (outcome === 'error') { state.failures++; state.narrow.failures++; }
181
- else if (outcome === 'applied') {
182
- // A vetoed pass (low-keep, too many candidates) still dropped tools on
183
- // paper, but the restriction never took effect; only count real ones.
184
- if (applied) state.narrow.applied++;
185
- if (Number.isSafeInteger(dropped) && dropped > 0) state.narrow.dropped += dropped;
186
- }
187
- bump(sessionKey(id), state);
188
- },
189
- // Task-completion checking. `ok`/`unsatisfied` are real evaluations; `error`
190
- // is an unevaluated failure counted as a failure, never as "unsatisfied".
191
- // The per-condition three-state tallies come from `detail`.
192
- recordComplete(id, outcome, detail = {}) {
193
- if (!['ok', 'unsatisfied', 'error'].includes(outcome)) throw new Error('Invalid task-completion outcome');
194
- const state = writable(id);
195
- state.attempts++;
196
- state.complete.attempts++;
197
- if (outcome === 'error') { state.failures++; state.complete.failures++; }
198
- else {
199
- if (Number.isSafeInteger(detail.satisfied) && detail.satisfied > 0) state.complete.satisfied += detail.satisfied;
200
- if (Number.isSafeInteger(detail.unsatisfied) && detail.unsatisfied > 0) state.complete.unsatisfied += detail.unsatisfied;
201
- if (Number.isSafeInteger(detail.insufficient) && detail.insufficient > 0) state.complete.insufficient += detail.insufficient;
202
- if (detail.steered === true) state.complete.steered++;
203
- }
204
- bump(sessionKey(id), state);
205
- },
206
- log(id, entry) {
207
- if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) throw new Error('Invalid log entry');
208
- if (!['gate', 'check', 'narrow', 'complete'].includes(entry.kind)) throw new Error('Invalid log entry');
209
- // Every decision record carries a stable unique id, generated here so the
210
- // in-memory panel copy and the persisted sink record share the same id.
211
- // Later annotation events reference this id to attribute a rating to one decision.
212
- const record = { id: randomUUID(), at: Date.now(), kind: entry.kind, outcome: typeof entry.outcome === 'string' ? entry.outcome.slice(0, 32) : '' };
213
- const tool = safeTool(entry.tool);
214
- if (tool) record.tool = tool;
215
- if (typeof entry.suggestion === 'string' && entry.suggestion) record.suggestion = entry.suggestion.slice(0, 16);
216
- if (typeof entry.action === 'string' && entry.action) record.action = entry.action.slice(0, 16);
217
- if (typeof entry.reason === 'string' && entry.reason) record.reason = entry.reason.slice(0, 32);
218
- // A short hint of the request a self-check judged, so a log row says which
219
- // turn's output was scored (not just "self-check"). Already collapsed upstream.
220
- if (typeof entry.subject === 'string' && entry.subject.trim()) record.subject = entry.subject.replace(/\s+/g, ' ').trim().slice(0, 120);
221
- // The redacted command/path summary of a gated call, so the log row can show
222
- // what was evaluated (not just the tool). Already secret-stripped upstream;
223
- // kept to a bounded preview here. Newlines collapse to single spaces so one
224
- // multi-line script stays on one readable row.
225
- if (typeof entry.command === 'string' && entry.command.trim()) record.command = entry.command.replace(/\s+/g, ' ').trim().slice(0, 512);
226
- if (typeof entry.path === 'string' && entry.path.trim()) record.path = entry.path.replace(/\s+/g, ' ').trim().slice(0, 512);
227
- if (typeof entry.mode === 'string' && entry.mode) record.mode = entry.mode.slice(0, 16);
228
- if (typeof entry.score === 'number' && Number.isFinite(entry.score)) record.score = Math.round(entry.score * 100) / 100;
229
- if (typeof entry.confidence === 'number' && Number.isFinite(entry.confidence)) record.confidence = Math.round(entry.confidence * 100) / 100;
230
- if (Number.isSafeInteger(entry.dropped) && entry.dropped >= 0) record.dropped = entry.dropped;
231
- if (Number.isSafeInteger(entry.kept) && entry.kept >= 0) record.kept = entry.kept;
232
- if (typeof entry.applied === 'boolean') record.applied = entry.applied;
233
- if (Number.isSafeInteger(entry.candidates) && entry.candidates >= 0) record.candidates = entry.candidates;
234
- if (Number.isSafeInteger(entry.conditions) && entry.conditions >= 0) record.conditions = entry.conditions;
235
- if (Number.isSafeInteger(entry.satisfied) && entry.satisfied >= 0) record.satisfied = entry.satisfied;
236
- if (Number.isSafeInteger(entry.unsatisfied) && entry.unsatisfied >= 0) record.unsatisfied = entry.unsatisfied;
237
- if (Number.isSafeInteger(entry.insufficient) && entry.insufficient >= 0) record.insufficient = entry.insufficient;
238
- if (entry.steered === true) record.steered = true;
239
- if (entry.lowConfidence === true) record.lowConfidence = true;
240
- if (Array.isArray(entry.tools)) {
241
- const names = entry.tools.filter(name => typeof name === 'string' && name.trim()).slice(0, 12).map(name => name.trim().slice(0, 64));
242
- if (names.length > 0) record.tools = names;
243
- }
244
- const state = writable(id);
245
- state.log.push(record);
246
- if (state.log.length > maxLogEntries) state.log.splice(0, state.log.length - maxLogEntries);
247
- // Full, untruncated record for the research log sink (all dropped tool
248
- // names, keyed by session id). The in-memory panel record above stays capped.
249
- if (sink) {
250
- const full = { ...record, sessionId: sessionKey(id) };
251
- if (Array.isArray(entry.tools)) {
252
- const allNames = entry.tools.filter(name => typeof name === 'string' && name.trim()).map(name => name.trim());
253
- if (allNames.length > 0) full.tools = allNames;
254
- }
255
- try { sink(full); } catch { /* persistence is best effort */ }
256
- }
257
- bump(sessionKey(id), state);
258
- },
259
- };
260
- }
@@ -1,100 +0,0 @@
1
- import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
- import { randomUUID } from 'node:crypto';
5
-
6
- export function stateDir() {
7
- return join(homedir(), '.config', 'dsh-decision-layer', 'state');
8
- }
9
-
10
- // A session id is persisted only when it is safe as a single path segment, so a
11
- // crafted id can never escape the state directory. dsh session ids are
12
- // `session-<uuid>`, which pass; anything with separators, leading dot, `..`, or
13
- // an unreasonable length is skipped (that session simply stays in-memory only).
14
- const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
15
- export function safeStateId(id) {
16
- return typeof id === 'string' && SAFE_ID.test(id) && !id.includes('..') ? id : undefined;
17
- }
18
-
19
- // Durable per-session mirror of the live decision counters + recent log. One
20
- // small JSON file per session id (`<id>.json`), written debounced and atomically
21
- // (unique tmp + rename), pruned by age on an occasional flush. This is separate
22
- // from the JSONL research log: it exists only so a resumed session's live panel
23
- // (dock count, metric cards, recent log) survives an instance restart instead of
24
- // resetting to zero. All IO is best-effort — a failure never throws into the
25
- // decision path; at worst the panel loses the last few seconds of counters.
26
- export function createStateStore({ dir = stateDir(), flushIntervalMs = 2000,
27
- retentionDays = 30, now = () => Date.now() } = {}) {
28
- const pending = new Map(); // id -> latest live state reference awaiting a flush
29
- let timer;
30
- let flushing = false;
31
- let lastPrune = 0;
32
-
33
- const fileFor = id => join(dir, `${id}.json`);
34
-
35
- const writeOne = async (id, state) => {
36
- const temporary = join(dir, `${id}.${randomUUID()}.tmp`);
37
- try {
38
- await writeFile(temporary, `${JSON.stringify({ v: 1, at: now(), state })}\n`, { mode: 0o600, flag: 'wx' });
39
- await rename(temporary, fileFor(id));
40
- } finally {
41
- await rm(temporary, { force: true }).catch(() => {});
42
- }
43
- };
44
-
45
- const prune = async () => {
46
- let names;
47
- try { names = await readdir(dir); } catch { return; }
48
- const cutoff = now() - retentionDays * 24 * 60 * 60 * 1000;
49
- await Promise.all(names.map(async name => {
50
- if (!name.endsWith('.json')) return;
51
- try {
52
- const info = await stat(join(dir, name));
53
- if (info.mtimeMs < cutoff) await rm(join(dir, name), { force: true }).catch(() => {});
54
- } catch { /* a file vanishing under us is fine */ }
55
- }));
56
- };
57
-
58
- const flush = async () => {
59
- if (flushing || pending.size === 0) return;
60
- flushing = true;
61
- // Snapshot ids now; each state is JSON.stringify'd at write time so the file
62
- // reflects the latest counters, which is exactly what we want.
63
- const batch = [...pending.entries()];
64
- pending.clear();
65
- try {
66
- await mkdir(dir, { recursive: true, mode: 0o700 });
67
- for (const [id, state] of batch) await writeOne(id, state).catch(() => {});
68
- // Prune at most about once per hour of activity, not on every flush.
69
- if (now() - lastPrune > 60 * 60 * 1000) { lastPrune = now(); await prune(); }
70
- } catch { /* best effort; a dropped flush must never break the decision path */ }
71
- finally { flushing = false; }
72
- };
73
-
74
- return {
75
- // Read a session's persisted state, or undefined when there is no usable file.
76
- async load(id) {
77
- const safe = safeStateId(id);
78
- if (!safe) return undefined;
79
- try {
80
- const parsed = JSON.parse(await readFile(fileFor(safe), 'utf8'));
81
- if (parsed && typeof parsed === 'object' && parsed.state && typeof parsed.state === 'object' && !Array.isArray(parsed.state)) {
82
- return parsed.state;
83
- }
84
- } catch { /* missing or malformed: no seed, start empty */ }
85
- return undefined;
86
- },
87
- // Register the latest live state for a session and schedule a debounced flush.
88
- save(id, state) {
89
- const safe = safeStateId(id);
90
- if (!safe || state === null || typeof state !== 'object') return;
91
- pending.set(safe, state);
92
- if (!timer) timer = setTimeout(() => { timer = undefined; void flush(); }, flushIntervalMs);
93
- },
94
- flush,
95
- dispose() {
96
- if (timer) { clearTimeout(timer); timer = undefined; }
97
- return flush();
98
- },
99
- };
100
- }
@@ -1,25 +0,0 @@
1
- // Shared "steer at most once per turn" gate for the turn-stopping decision
2
- // points (self-check, task-completion). A steer opens a supplemental step, so it
3
- // must fire at most once per (session, turn) — but a transient host failure must
4
- // NOT burn that budget, or one flaky steer permanently suppresses the turn.
5
- //
6
- // `attempt(turn, run)` calls `run()` (which performs the host steer and may
7
- // throw) and records the turn key only after `run()` returns without throwing.
8
- // Returns true iff a steer was actually performed this call.
9
- export function createSteerOnce({ maxTurns = 4096 } = {}) {
10
- const steeredTurns = new Set();
11
- const keyOf = turn => {
12
- const id = turn?.agent?.session?.id;
13
- return typeof id === 'string' && Number.isInteger(turn?.turn) ? `${id}:${turn.turn}` : undefined;
14
- };
15
- return {
16
- attempt(turn, run) {
17
- const key = keyOf(turn);
18
- if (key === undefined || steeredTurns.has(key)) return false;
19
- try { run(); } catch { return false; }
20
- steeredTurns.add(key);
21
- if (steeredTurns.size > maxTurns) steeredTurns.delete(steeredTurns.values().next().value);
22
- return true;
23
- },
24
- };
25
- }
@@ -1,163 +0,0 @@
1
- import { createSteerOnce } from './steer-once.mjs';
2
-
3
- const MAX_STATE = 8 * 1024;
4
- const MAX_CONDITION = 512;
5
- const MAX_CONDITIONS = 20;
6
- const MAX_EVIDENCE = 40;
7
-
8
- const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
9
- const probability = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
10
-
11
- const STEER_TEXT = '本回合任务完成核对发现有明确交付条件尚未满足:请对照用户列出的条件逐项复核,补完未完成的部分。';
12
-
13
- // Deterministic extraction of the explicit deliverable conditions a user listed.
14
- // Only structured forms are recognized — numbered lists, bullet lists, and a
15
- // single line of comma/、-separated multiple commands. Implicit or inferred
16
- // expectations are deliberately out of scope for the first version.
17
- export function extractConditions(userText) {
18
- const text = typeof userText === 'string' ? userText : '';
19
- if (!text.trim()) return [];
20
- const conditions = [];
21
- const push = value => {
22
- const item = value.trim().replace(/\s+/g, ' ').slice(0, MAX_CONDITION);
23
- if (item && !conditions.includes(item)) conditions.push(item);
24
- };
25
- const lines = text.split(/\r?\n/);
26
- let matchedStructured = false;
27
- for (const line of lines) {
28
- // Numbered: `1. `, `1) `, `1、`, `(1)`, `(1)`; or bullets: `- `, `* `, `• `.
29
- const numbered = line.match(/^\s*(?:[((]?\d+[)).、]|\d+\s*[.)、])\s+(.*)$/);
30
- const bullet = line.match(/^\s*[-*•]\s+(.*)$/);
31
- if (numbered && numbered[1].trim()) { push(numbered[1]); matchedStructured = true; }
32
- else if (bullet && bullet[1].trim()) { push(bullet[1]); matchedStructured = true; }
33
- }
34
- // Fallback: a single instruction line joining multiple commands with 、/,/,
35
- // and enough parts to look like an explicit multi-step ask (e.g. "改配置、跑测试、说明失败原因").
36
- if (!matchedStructured) {
37
- const compact = text.trim();
38
- if (!/[\r\n]/.test(compact)) {
39
- const parts = compact.split(/[、,,]/).map(part => part.trim()).filter(Boolean);
40
- if (parts.length >= 2) for (const part of parts) push(part);
41
- }
42
- }
43
- return conditions.slice(0, MAX_CONDITIONS);
44
- }
45
-
46
- // Build one choice question per explicit condition. The state carries the
47
- // conditions, the turn's tool evidence, and the final reply. Tool evidence is
48
- // primary; the reply's own claims are explicitly NOT treated as proof, so the
49
- // model can answer insufficient_evidence when only the reply asserts completion.
50
- export function completionQuestions(conditions, evidence, answer) {
51
- const items = Array.isArray(conditions) ? conditions.slice(0, MAX_CONDITIONS) : [];
52
- const tools = Array.isArray(evidence)
53
- ? evidence.filter(item => isObject(item) && typeof item.tool === 'string').slice(0, MAX_EVIDENCE)
54
- .map(item => ({ tool: item.tool.slice(0, 128), outcome: item.outcome === 'error' ? 'error' : 'ok' }))
55
- : [];
56
- const state = {
57
- conditions: items.map((text, index) => ({ id: `c${index}`, text })),
58
- toolEvidence: tools,
59
- finalReply: String(answer ?? '').slice(0, MAX_STATE),
60
- note: '工具证据(toolEvidence)是判定依据;finalReply 自称完成不作为证据。缺少证据时选 insufficient_evidence。',
61
- };
62
- const questions = Object.fromEntries(items.map((text, index) => [`c${index}`, {
63
- type: 'choice',
64
- instructions: {
65
- condition: text,
66
- question: '根据当前工具证据与最终回复,这个交付条件的完成情况属于哪一种?',
67
- },
68
- criteria: {
69
- satisfied: '有工具证据或用户明确确认表明该条件已完成',
70
- unsatisfied: '有证据表明该条件未完成或做错',
71
- insufficient_evidence: '现有证据不足以判断该条件是否完成',
72
- },
73
- }]));
74
- return { state, questions };
75
- }
76
-
77
- // The confidence floor for interrupting the user with a supplemental prompt.
78
- // Named rather than inlined because config-store's `completeSettings.minConfidence`
79
- // and the config form both document this value; a bare literal in three places
80
- // drifts silently on the next tuning pass.
81
- export const DEFAULT_MIN_CONFIDENCE = 0.4;
82
-
83
- // Orchestrates one task-completion pass at turn end. Read-only by default: it
84
- // records a per-condition verdict and only steers when explicitly configured,
85
- // the model is confident, and at least one condition is not satisfied. A steer
86
- // opens a supplemental step; it never rewrites the already-committed reply.
87
- export function createTaskCompletion({ evaluate, sessions, steer, settings, minConfidence = DEFAULT_MIN_CONFIDENCE } = {}) {
88
- if (typeof evaluate !== 'function') throw new Error('Task completion requires an evaluator');
89
- const steerOnce = createSteerOnce();
90
- const resolveSettings = async () => {
91
- let raw = {};
92
- try { raw = (typeof settings === 'function' ? await settings() : settings) ?? {}; } catch { raw = {}; }
93
- const mode = raw?.mode === 'steer' ? 'steer' : 'observe';
94
- const minConditions = Number.isSafeInteger(raw?.minConditions) && raw.minConditions >= 2 ? raw.minConditions : 2;
95
- // Confidence required before an unmet condition actually opens the
96
- // supplemental step; the constructor argument is the fallback when the
97
- // config does not set it.
98
- const confidenceFloor = probability(raw?.minConfidence) ? raw.minConfidence : minConfidence;
99
- return { mode, minConditions, minConfidence: confidenceFloor };
100
- };
101
- const record = (turn, outcome, detail = {}) => {
102
- const id = turn?.agent?.session?.id;
103
- if (!sessions || typeof id !== 'string' || typeof sessions.recordComplete !== 'function') return;
104
- try { sessions.recordComplete(id, outcome, detail); } catch { /* telemetry is best effort */ }
105
- if (typeof sessions.log !== 'function') return;
106
- try { sessions.log(id, { kind: 'complete', outcome, ...detail }); }
107
- catch { /* logging is best effort */ }
108
- };
109
- const skip = { evaluated: false, steered: false };
110
- return {
111
- async review(turn) {
112
- const output = typeof turn?.output === 'string' ? turn.output.trim() : '';
113
- const { mode, minConditions, minConfidence: confidenceFloor } = await resolveSettings();
114
- const conditions = extractConditions(turn?.userText);
115
- if (conditions.length < minConditions) return { ...skip };
116
- const evidence = Array.isArray(turn?.evidence) ? turn.evidence : [];
117
- if (evidence.length === 0) return { ...skip };
118
- const id = turn?.agent?.session?.id;
119
- if (sessions && typeof id === 'string') {
120
- try { if (sessions.snapshot(id).enabled === false) return { ...skip }; }
121
- catch { return { ...skip }; }
122
- }
123
- let answers;
124
- try {
125
- const result = await evaluate(completionQuestions(conditions, evidence, output), { signal: turn.signal });
126
- answers = isObject(result?.answers) ? result.answers : undefined;
127
- } catch (error) {
128
- record(turn, 'error', { reason: typeof error?.reason === 'string' ? error.reason : 'unreachable' });
129
- return { ...skip };
130
- }
131
- if (!answers) { record(turn, 'error', { reason: 'invalid-response' }); return { ...skip }; }
132
- let satisfied = 0, unsatisfied = 0, insufficient = 0;
133
- // Track the minimum confidence over the `unsatisfied` verdicts only: a steer
134
- // fires on a confident unsatisfied finding, so a low-confidence satisfied /
135
- // insufficient verdict on another condition must not suppress it.
136
- let unsatisfiedMinConf = 1;
137
- for (let index = 0; index < conditions.length; index++) {
138
- const answer = answers[`c${index}`];
139
- if (!isObject(answer) || answer.type !== 'choice' || typeof answer.choice !== 'string') {
140
- insufficient++; continue;
141
- }
142
- if (answer.choice === 'satisfied') satisfied++;
143
- else if (answer.choice === 'unsatisfied') {
144
- unsatisfied++;
145
- if (probability(answer.confidence)) unsatisfiedMinConf = Math.min(unsatisfiedMinConf, answer.confidence);
146
- }
147
- else insufficient++;
148
- }
149
- const outcome = unsatisfied > 0 ? 'unsatisfied' : 'ok';
150
- const detail = { conditions: conditions.length, satisfied, unsatisfied, insufficient };
151
- // A steer is only for a confident finding that a listed condition is not met.
152
- const confident = unsatisfied > 0 && unsatisfiedMinConf >= confidenceFloor;
153
- // The turn budget is consumed only on a successful steer (createSteerOnce),
154
- // so a transient host failure does not permanently suppress this turn.
155
- const steered = mode === 'steer' && confident && typeof steer === 'function' && turn?.agent
156
- ? steerOnce.attempt(turn, () => steer(turn.agent, STEER_TEXT))
157
- : false;
158
- if (steered) detail.steered = true;
159
- record(turn, outcome, detail);
160
- return { evaluated: true, steered, satisfied, unsatisfied, insufficient };
161
- },
162
- };
163
- }