dsh-decision-layer 0.6.4 → 0.7.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.
package/locale/en.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Decision Layer",
4
- "description": "A structured decision layer for the DeepSeek Harness agent loop: a danger-call gate, output self-check, and loop guard backed by a pluggable adjudication model."
4
+ "description": "A structured decision layer for the DeepSeek Harness agent loop: a danger-call gate, output self-check, tool narrowing, task-completion review, and loop guard backed by a pluggable adjudication model.",
5
+ "detail": "docs/en/DETAIL.md"
5
6
  }
6
7
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "决策层",
4
- "description": "为 DeepSeek Harness agent 循环提供的结构化决策层:危险调用门控、产出自检与防循环,由可插拔的裁决模型驱动。"
4
+ "description": "为 DeepSeek Harness agent 循环提供的结构化决策层:危险调用门控、产出自检、工具收窄、任务完成核对与防循环,由可插拔的裁决模型驱动。",
5
+ "detail": "docs/zh/DETAIL.md"
5
6
  }
6
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-decision-layer",
3
- "version": "0.6.4",
3
+ "version": "0.7.1",
4
4
  "description": "A structured decision layer for the DeepSeek Harness agent loop: a danger-call gate, output self-check, tool narrowing, and loop guard backed by a pluggable adjudication model.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -20,6 +20,8 @@
20
20
  "skills",
21
21
  "lib/client.js",
22
22
  "locale/*.json",
23
+ "docs/zh/DETAIL.md",
24
+ "docs/en/DETAIL.md",
23
25
  "icon.svg",
24
26
  "cordis.patch.yml",
25
27
  "README.md",
@@ -35,7 +37,9 @@
35
37
  },
36
38
  "scripts": {
37
39
  "test": "node --test tests/*.test.mjs",
38
- "build:client": "tsc -p tsconfig.client.json && node scripts/build-client.mjs"
40
+ "build:client": "tsc -p tsconfig.client.json && node scripts/build-client.mjs",
41
+ "check": "npm run build:client && npm test",
42
+ "pack:assert": "node scripts/assert-pack.mjs"
39
43
  },
40
44
  "exports": {
41
45
  ".": "./service/dsh.mjs",
@@ -29,6 +29,11 @@ function safeText(value) {
29
29
  return typeof value === 'string' ? value.trim() : '';
30
30
  }
31
31
 
32
+ // A confidence/threshold/probability: any finite number in [0, 1].
33
+ function probability(value) {
34
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
35
+ }
36
+
32
37
  function checkedUrl(value) {
33
38
  if (!value) return '';
34
39
  let url;
@@ -53,9 +58,31 @@ function validRules(value) {
53
58
  return rules;
54
59
  }
55
60
 
61
+ // Danger-gate settings. A gate verdict only intervenes on a high-confidence
62
+ // `deny`, so minConfidence is the single knob that decides how sure the model
63
+ // must be before a dangerous call is actually blocked. Lowering it makes the
64
+ // gate block more (and risk blocking legitimate commands); raising it makes it
65
+ // pass more through. Range 0–1; unset uses the built-in 0.6.
66
+ //
67
+ // Like every sibling validator, an unknown field or an out-of-range value is
68
+ // REJECTED rather than ignored, which makes the whole resolve reject (see the
69
+ // contract note on saveConfig). Callers that read config on a hot path must
70
+ // therefore tolerate a throw and fall back to a safe default — see `featureOn`
71
+ // in dsh.mjs and `resolveMinConfidence` in danger-gate.mjs.
72
+ function validGate(value) {
73
+ if (!safeObject(value)) return undefined;
74
+ if (Object.keys(value).some(key => key !== 'minConfidence')) throw new Error('Invalid gate settings');
75
+ const gate = {};
76
+ if (value.minConfidence !== undefined) {
77
+ if (!probability(value.minConfidence)) throw new Error('Invalid gate settings');
78
+ gate.minConfidence = value.minConfidence;
79
+ }
80
+ return gate;
81
+ }
82
+
56
83
  function validCheck(value) {
57
84
  if (!safeObject(value)) return undefined;
58
- if (Object.keys(value).some(key => !['mode', 'rubric', 'lowScoreThreshold', 'trendMinConfidence', 'trendRun', 'trendSevereRun'].includes(key))) throw new Error('Invalid self-check settings');
85
+ if (Object.keys(value).some(key => !['mode', 'rubric', 'lowScoreThreshold', 'minConfidence', 'trendMinConfidence', 'trendRun', 'trendSevereRun'].includes(key))) throw new Error('Invalid self-check settings');
59
86
  const check = {};
60
87
  if (value.mode !== undefined) {
61
88
  if (value.mode !== 'observe' && value.mode !== 'steer') throw new Error('Invalid self-check settings');
@@ -71,11 +98,18 @@ function validCheck(value) {
71
98
  if (!Number.isSafeInteger(value.lowScoreThreshold) || value.lowScoreThreshold < 0 || value.lowScoreThreshold > 9) throw new Error('Invalid self-check settings');
72
99
  check.lowScoreThreshold = value.lowScoreThreshold;
73
100
  }
101
+ // Confidence required before a low score actually triggers the supplemental
102
+ // prompt. Below this the score is still logged; only interrupting the user
103
+ // warrants the model being sure. Range 0–1; unset uses the built-in 0.4.
104
+ if (value.minConfidence !== undefined) {
105
+ if (!probability(value.minConfidence)) throw new Error('Invalid self-check settings');
106
+ check.minConfidence = value.minConfidence;
107
+ }
74
108
  // Quality-trend alarm thresholds (all unvalidated heuristics, tuned on real logs):
75
109
  // a high-confidence low score requires confidence ≥ trendMinConfidence; a run of
76
110
  // trendRun such scores raises "warn", trendSevereRun raises "severe".
77
111
  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');
112
+ if (!probability(value.trendMinConfidence)) throw new Error('Invalid self-check settings');
79
113
  check.trendMinConfidence = value.trendMinConfidence;
80
114
  }
81
115
  if (value.trendRun !== undefined) {
@@ -104,7 +138,7 @@ function validNarrow(value) {
104
138
  narrow.mode = value.mode;
105
139
  }
106
140
  if (value.threshold !== undefined) {
107
- if (typeof value.threshold !== 'number' || !Number.isFinite(value.threshold) || value.threshold < 0 || value.threshold > 1) throw new Error('Invalid narrowing settings');
141
+ if (!probability(value.threshold)) throw new Error('Invalid narrowing settings');
108
142
  narrow.threshold = value.threshold;
109
143
  }
110
144
  // Tool name prefixes that are always kept and never judged for relevance, on
@@ -144,10 +178,12 @@ export const FEATURE_KEYS = Object.freeze(['gate', 'check', 'narrow', 'complete'
144
178
 
145
179
  // Task-completion checking settings: observe-only by default; steer opts into a
146
180
  // single supplemental prompt on low completion. minConditions is the minimum
147
- // number of explicit conditions a turn must have for the check to run at all.
181
+ // number of explicit conditions a turn must have for the check to run at all;
182
+ // minConfidence is how sure the model must be that a condition is unmet before
183
+ // it actually opens the supplemental step.
148
184
  function validComplete(value) {
149
185
  if (!safeObject(value)) return undefined;
150
- if (Object.keys(value).some(key => !['mode', 'minConditions'].includes(key))) throw new Error('Invalid task-completion settings');
186
+ if (Object.keys(value).some(key => !['mode', 'minConditions', 'minConfidence'].includes(key))) throw new Error('Invalid task-completion settings');
151
187
  const complete = {};
152
188
  if (value.mode !== undefined) {
153
189
  if (value.mode !== 'observe' && value.mode !== 'steer') throw new Error('Invalid task-completion settings');
@@ -157,6 +193,11 @@ function validComplete(value) {
157
193
  if (!Number.isSafeInteger(value.minConditions) || value.minConditions < 2 || value.minConditions > 20) throw new Error('Invalid task-completion settings');
158
194
  complete.minConditions = value.minConditions;
159
195
  }
196
+ // Range 0–1; unset uses the built-in 0.4.
197
+ if (value.minConfidence !== undefined) {
198
+ if (!probability(value.minConfidence)) throw new Error('Invalid task-completion settings');
199
+ complete.minConfidence = value.minConfidence;
200
+ }
160
201
  return complete;
161
202
  }
162
203
 
@@ -186,6 +227,7 @@ function view(stored) {
186
227
  httpApprovedUrl: safeText(stored.httpApprovedUrl),
187
228
  };
188
229
  if (stored.dangerRules !== undefined) result.dangerRules = validRules(stored.dangerRules);
230
+ if (stored.gateSettings !== undefined) result.gateSettings = validGate(stored.gateSettings);
189
231
  if (stored.checkSettings !== undefined) result.checkSettings = validCheck(stored.checkSettings);
190
232
  if (stored.narrowSettings !== undefined) result.narrowSettings = validNarrow(stored.narrowSettings);
191
233
  if (stored.completeSettings !== undefined) result.completeSettings = validComplete(stored.completeSettings);
@@ -207,6 +249,7 @@ export async function resolveConfig({ path = configPath(), env = process.env } =
207
249
  httpApprovedUrl: safeText(stored.httpApprovedUrl),
208
250
  };
209
251
  if (stored.dangerRules !== undefined) resolved.dangerRules = validRules(stored.dangerRules);
252
+ if (stored.gateSettings !== undefined) resolved.gateSettings = validGate(stored.gateSettings);
210
253
  if (stored.checkSettings !== undefined) resolved.checkSettings = validCheck(stored.checkSettings);
211
254
  if (stored.narrowSettings !== undefined) resolved.narrowSettings = validNarrow(stored.narrowSettings);
212
255
  if (stored.completeSettings !== undefined) resolved.completeSettings = validComplete(stored.completeSettings);
@@ -214,8 +257,17 @@ export async function resolveConfig({ path = configPath(), env = process.env } =
214
257
  return resolved;
215
258
  }
216
259
 
260
+ // Write the configuration atomically. Contract for the nested settings sections
261
+ // (dangerRules, gateSettings, checkSettings, narrowSettings, completeSettings,
262
+ // features): a POSTED section REPLACES the stored one wholesale; an OMITTED
263
+ // section keeps its previous value. So a caller updating one field of a section
264
+ // must send the whole section, and a caller that wants to change a field inside
265
+ // a section it does not own must read first and send `{...previous, edit}`.
266
+ // Merge-per-field was considered and rejected: it would make a stored field
267
+ // impossible to unset. service/manager-api.mjs forwards the raw body, so this
268
+ // contract holds for API clients too, not just the config page.
217
269
  export async function saveConfig(input, path = configPath()) {
218
- if (!safeObject(input) || Object.keys(input).some(key => !['url', 'apiKey', 'model', 'confirmHttpUrl', 'dangerRules', 'checkSettings', 'narrowSettings', 'completeSettings', 'features'].includes(key))) {
270
+ if (!safeObject(input) || Object.keys(input).some(key => !['url', 'apiKey', 'model', 'confirmHttpUrl', 'dangerRules', 'gateSettings', 'checkSettings', 'narrowSettings', 'completeSettings', 'features'].includes(key))) {
219
271
  throw new Error('Invalid configuration fields');
220
272
  }
221
273
  for (const key of ['url', 'apiKey', 'model', 'confirmHttpUrl']) {
@@ -229,6 +281,7 @@ export async function saveConfig(input, path = configPath()) {
229
281
  throw new Error('Invalid model or API key');
230
282
  }
231
283
  const dangerRules = input.dangerRules === undefined ? previous.dangerRules : validRules(input.dangerRules);
284
+ const gateSettings = input.gateSettings === undefined ? previous.gateSettings : validGate(input.gateSettings);
232
285
  const checkSettings = input.checkSettings === undefined ? previous.checkSettings : validCheck(input.checkSettings);
233
286
  const narrowSettings = input.narrowSettings === undefined ? previous.narrowSettings : validNarrow(input.narrowSettings);
234
287
  const completeSettings = input.completeSettings === undefined ? previous.completeSettings : validComplete(input.completeSettings);
@@ -240,6 +293,7 @@ export async function saveConfig(input, path = configPath()) {
240
293
  }
241
294
  const next = { url, apiKey, model, httpApprovedUrl: url.startsWith('http://') ? url : '' };
242
295
  if (dangerRules !== undefined) next.dangerRules = dangerRules;
296
+ if (gateSettings !== undefined) next.gateSettings = gateSettings;
243
297
  if (checkSettings !== undefined) next.checkSettings = checkSettings;
244
298
  if (narrowSettings !== undefined) next.narrowSettings = narrowSettings;
245
299
  if (completeSettings !== undefined) next.completeSettings = completeSettings;
@@ -11,7 +11,9 @@ const PATH_KEYS = new Set(['path', 'file_path', 'filePath', 'target', 'file', 'f
11
11
  const MAX_RULES = 64;
12
12
  const MAX_PATTERN_LENGTH = 128;
13
13
  const MAX_METADATA_LENGTH = 2048;
14
- export const MIN_CONFIDENCE = 0.7;
14
+ export const MIN_CONFIDENCE = 0.6;
15
+
16
+ const isProbability = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
15
17
 
16
18
  export const DEFAULT_DANGEROUS_RULES = Object.freeze({
17
19
  toolNames: Object.freeze(['bash', 'pwsh', 'shell', 'exec', 'terminal', 'run_command', 'write', 'edit', 'str_replace_editor', 'file_write', 'write_file']),
@@ -82,6 +84,17 @@ export function createDangerGate({ evaluate, sessions, rules = DEFAULT_DANGEROUS
82
84
  if (typeof rules !== 'function') return rules;
83
85
  try { return (await rules()) ?? DEFAULT_DANGEROUS_RULES; } catch { return DEFAULT_DANGEROUS_RULES; }
84
86
  };
87
+ // The confidence floor may be a fixed number or a live resolver (config-backed),
88
+ // mirroring `rules`, so changing it takes effect without reloading the plugin.
89
+ // An invalid or failed resolution falls back to the built-in floor rather than
90
+ // to 0 — a broken config must never turn the gate into "block every deny".
91
+ const resolveMinConfidence = async () => {
92
+ if (typeof minConfidence !== 'function') return isProbability(minConfidence) ? minConfidence : MIN_CONFIDENCE;
93
+ try {
94
+ const value = await minConfidence();
95
+ return isProbability(value) ? value : MIN_CONFIDENCE;
96
+ } catch { return MIN_CONFIDENCE; }
97
+ };
85
98
  const deniedCalls = new WeakSet();
86
99
  const evaluatedCalls = new WeakSet();
87
100
  const record = (exec, outcome, detail = {}) => {
@@ -139,7 +152,8 @@ export function createDangerGate({ evaluate, sessions, rules = DEFAULT_DANGEROUS
139
152
  const answer = result?.answers?.verdict;
140
153
  const choice = answer?.choice;
141
154
  if (!['allow', 'ask', 'deny'].includes(choice)) return passThrough(exec, 'error', { reason: 'invalid-response' });
142
- const confident = typeof answer.confidence === 'number' && Number.isFinite(answer.confidence) && answer.confidence >= minConfidence;
155
+ const minConfidenceNow = await resolveMinConfidence();
156
+ const confident = typeof answer.confidence === 'number' && Number.isFinite(answer.confidence) && answer.confidence >= minConfidenceNow;
143
157
  // Only a high-confidence deny blocks the call.
144
158
  if (choice === 'deny' && confident) {
145
159
  record(exec, 'deny', { suggestion: 'deny', action: 'deny' });
package/service/dsh.mjs CHANGED
@@ -122,10 +122,23 @@ export async function apply(ctx, options = {}) {
122
122
  evaluate: (input, request) => backend.evaluate(input, request),
123
123
  sessions,
124
124
  rules: async () => normalizeDangerRules((await resolveConfig({ path })).dangerRules),
125
+ // Live config-backed floor (default 0.6): a `deny` only blocks once the model
126
+ // is at least this confident. Read per call so an edit takes effect at once.
127
+ minConfidence: async () => (await resolveConfig({ path })).gateSettings?.minConfidence,
125
128
  });
126
129
  const loopGuard = createLoopGuard({ sessions });
127
130
  // Live per-feature enablement; every decision point defaults to enabled.
128
- const featureOn = async key => featureEnabled((await resolveConfig({ path })).features, key);
131
+ // An invalid config must not fail every tool call: these hooks run inside the
132
+ // host's tools/pre-execute waterfall, which converts a throw into a failed call
133
+ // (the tool never dispatches, so `read`/`edit` can no longer repair the file).
134
+ // An unreadable config therefore degrades to "all features on", and each
135
+ // decision point keeps its own safe fallback (the gate falls back to 0.6 and
136
+ // the built-in rules), so the safety net stays armed instead of the agent
137
+ // losing every tool.
138
+ const featureOn = async key => {
139
+ try { return featureEnabled((await resolveConfig({ path })).features, key); }
140
+ catch { return true; }
141
+ };
129
142
  if (typeof ctx.on === 'function') {
130
143
  if (typeof ctx.tools.guard === 'function') {
131
144
  // Deterministic loop guard runs with no backend; then the dangerous-gate
@@ -207,22 +220,28 @@ export async function apply(ctx, options = {}) {
207
220
  // process, before any gate/check/narrow/complete event mutates them, so a
208
221
  // resumed session continues from its saved totals instead of from zero.
209
222
  if (typeof id === 'string') await sessions.ensureLoaded(id);
210
- if (typeof id === 'string' && Number.isInteger(turn) && await featureOn('narrow')) {
223
+ if (typeof id === 'string' && Number.isInteger(turn)) {
211
224
  const state = narrowedTurns.get(id);
212
225
  if (!state || state.turn !== turn) {
226
+ // Release the previous turn's restriction first: restrict is agent-scoped
227
+ // and stays in force until disposed, so a turn where narrowing is now off
228
+ // must not inherit the earlier allow-list.
213
229
  state?.dispose?.();
214
- const entry = { turn, dispose: undefined };
215
- narrowedTurns.set(id, entry);
216
- if (narrowedTurns.size > 1024) { const oldest = narrowedTurns.keys().next().value; if (oldest !== id) { narrowedTurns.get(oldest)?.dispose?.(); narrowedTurns.delete(oldest); } }
217
- try {
218
- const result = await narrowing.review({
219
- agent, turn, signal: payload.signal,
220
- state: stepTaskText(payload.messages), tools: agentTools(ctx, agent),
221
- });
222
- if (result.mode === 'enforce' && result.applied && typeof agent?.ctx?.tools?.restrict === 'function') {
223
- entry.dispose = agent.ctx.tools.restrict({ allow: result.keep });
224
- }
225
- } catch { /* narrowing is best effort; never block the step */ }
230
+ narrowedTurns.delete(id);
231
+ if (await featureOn('narrow')) {
232
+ const entry = { turn, dispose: undefined };
233
+ narrowedTurns.set(id, entry);
234
+ if (narrowedTurns.size > 1024) { const oldest = narrowedTurns.keys().next().value; if (oldest !== id) { narrowedTurns.get(oldest)?.dispose?.(); narrowedTurns.delete(oldest); } }
235
+ try {
236
+ const result = await narrowing.review({
237
+ agent, turn, signal: payload.signal,
238
+ state: stepTaskText(payload.messages), tools: agentTools(ctx, agent),
239
+ });
240
+ if (result.mode === 'enforce' && result.applied && typeof agent?.ctx?.tools?.restrict === 'function') {
241
+ entry.dispose = agent.ctx.tools.restrict({ allow: result.keep });
242
+ }
243
+ } catch { /* narrowing is best effort; never block the step */ }
244
+ }
226
245
  }
227
246
  }
228
247
  return next();
@@ -1,5 +1,6 @@
1
1
  import { createReadableStream } from './log-read.mjs';
2
2
  import { advanceRun, severityOf } from './quality-trend.mjs';
3
+ import { narrowApplied } from './narrowing.mjs';
3
4
 
4
5
  // Offline analysis over the persisted decision JSONL logs. Pure aggregation:
5
6
  // given a stream of records (decision events + annotation events) for one
@@ -75,7 +76,7 @@ export function buildProfile(decisions) {
75
76
  if (cb) bucket.confidence[cb]++;
76
77
  } else if (kind === 'narrow') {
77
78
  if (record.outcome === 'error') bucket.error++;
78
- else if (record.outcome === 'applied') bucket.applied++;
79
+ else if (narrowApplied(record)) bucket.applied++;
79
80
  if (Number.isSafeInteger(record.dropped) && record.dropped > 0) bucket.droppedSum += record.dropped;
80
81
  if (record.reason === 'too-many-candidates') bucket.tooManyCandidates++;
81
82
  } else if (kind === 'complete') {
@@ -196,7 +197,7 @@ export function buildSeed(decisions, { maxLog = 50, trendConfig = {} } = {}) {
196
197
  }
197
198
  } else if (record.kind === 'narrow') {
198
199
  if (record.outcome === 'applied') {
199
- bucket.applied++;
200
+ if (narrowApplied(record)) bucket.applied++;
200
201
  if (Number.isSafeInteger(record.dropped) && record.dropped > 0) bucket.dropped += record.dropped;
201
202
  }
202
203
  } else if (record.kind === 'complete') {
@@ -234,9 +235,9 @@ export async function reconstructSessionSeed(sessionId, { dir, port, trendConfig
234
235
  // Read one session's persisted decision rows and return a single newest-first
235
236
  // page. Backs the analysis tab's full decision log (the dock panel still shows
236
237
  // only the in-memory recent rows). Decisions are folded by id (latest wins) so a
237
- // re-logged id is not double-counted. Rows carry only the panel fields — tool
238
- // name, outcome, counts — never gate command/path, which is never logged. The
239
- // internal sessionId tag is stripped before the row leaves the server.
238
+ // re-logged id is not double-counted. Rows carry only the panel fields, including
239
+ // the gate's redacted, length-bounded command/path. The internal sessionId tag is
240
+ // stripped before the row leaves the server.
240
241
  export async function listSessionDecisions(sessionId, { dir, port, page = 1, pageSize = 100, readStream = createReadableStream } = {}) {
241
242
  if (typeof sessionId !== 'string' || !sessionId.trim()) throw new Error('Invalid session id');
242
243
  const size = Number.isSafeInteger(pageSize) && pageSize > 0 && pageSize <= 500 ? pageSize : 100;
@@ -91,9 +91,9 @@ export function createManagerRoutes({ path, sessions, backend, logStore, analyze
91
91
  },
92
92
  }),
93
93
  // One newest-first page of this session's full persisted decision rows, for
94
- // the analysis tab's paginated log. Rows carry only panel fields (tool name,
95
- // outcome, counts) — never gate command/path, which is never logged — so this
96
- // exposes nothing the recent-rows /log route does not already.
94
+ // the analysis tab's paginated log. Rows carry the same panel fields as /log,
95
+ // including the gate's already-redacted and length-bounded command/path, so
96
+ // this exposes nothing the recent-rows route does not already.
97
97
  route('logrows', {
98
98
  GET: async req => {
99
99
  if (typeof listRows !== 'function') { const error = new Error('log rows unavailable'); error.code = 'BAD_REQUEST'; throw error; }
@@ -1,5 +1,14 @@
1
1
  const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
2
2
  const probability = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
3
+
4
+ // Whether a narrowing pass actually restricted the tool set. `applied` is the
5
+ // authoritative field; a vetoed pass (low-keep, too many candidates) is never
6
+ // applied, and rows logged before `applied` existed are read from their reason.
7
+ export const narrowApplied = record => {
8
+ if (record?.applied === false) return false;
9
+ if (record?.reason === 'low-keep' || record?.reason === 'too-many-candidates') return false;
10
+ return record?.applied === true || record?.outcome === 'applied';
11
+ };
3
12
  const MAX_STATE = 8 * 1024;
4
13
  // The adjudication backend hard-rejects a request carrying more than 64 questions
5
14
  // (backend.mjs validateRequest). One relevance question is sent per optional tool,
@@ -25,21 +34,28 @@ export const DEFAULT_CORE_TOOLS = Object.freeze([
25
34
  // Tool-name prefixes that are always kept and never judged, on top of the core
26
35
  // tools. Memory/context tools like OpenViking are "keep resident, trigger on
27
36
  // demand": their relevance rarely shows in the literal task text, so per-tool
28
- // relevance judging would keep pruning them. `team_task_` covers the shared
29
- // task-board tools as a group. Users can override this list.
30
- export const DEFAULT_KEEP_PREFIXES = Object.freeze(['mcp__openviking', 'team_task_']);
37
+ // relevance judging would keep pruning them. Teamwork tools (`team_task_`) are
38
+ // deliberately NOT in this list: they are ordinary task-board capabilities and
39
+ // should be judged like any other optional tool. Users can override this list.
40
+ export const DEFAULT_KEEP_PREFIXES = Object.freeze(['mcp__openviking']);
31
41
 
32
42
  // Intersect the host-allowed tool set with the model's relevance verdict. Only
33
43
  // removes tools; never adds. Any missing or invalid noul keeps the tool, and a
34
44
  // verdict that would strip everything is skipped so the agent is never disabled.
35
- // Drop threshold: a tool is dropped only when the model is fairly confident it
36
- // is not needed (noul < threshold). Default 0.3 is deliberately conservative —
37
- // a noul near 0.5 means the model is unsure, and dropping there is the most
38
- // likely to strip a still-useful tool, so uncertain tools are kept.
39
- export function narrowTools(allowed, result, { threshold = 0.3 } = {}) {
45
+ // `keepBase` is the number of always-kept tools outside `allowed` (core tools and
46
+ // keep-prefix matches); the never-strip-everything guard must count them, or
47
+ // dropping every optional tool would be vetoed even though those tools remain.
48
+ // Drop threshold: a tool is dropped only when its relevance probability is below
49
+ // this value (noul < threshold). The default leans aggressive — only tools the
50
+ // model rates as fairly relevant survive, so the surface stays small. A noul near
51
+ // 0.5 means the model is unsure and is dropped under this default; lower the
52
+ // threshold to keep such may-need tools.
53
+ export const DEFAULT_NARROW_THRESHOLD = 0.7;
54
+ export function narrowTools(allowed, result, { threshold = DEFAULT_NARROW_THRESHOLD, keepBase = 0 } = {}) {
40
55
  const tools = Array.isArray(allowed) ? allowed.filter(name => typeof name === 'string') : [];
41
56
  const answers = isObject(result?.answers) ? result.answers : undefined;
42
57
  if (tools.length === 0 || !answers) return { applied: false, keep: [...tools], drop: [] };
58
+ const base = Number.isSafeInteger(keepBase) && keepBase > 0 ? keepBase : 0;
43
59
 
44
60
  const keep = [];
45
61
  const drop = [];
@@ -48,7 +64,7 @@ export function narrowTools(allowed, result, { threshold = 0.3 } = {}) {
48
64
  if (isObject(answer) && answer.type === 'noul' && probability(answer.noul) && answer.noul < threshold) drop.push(name);
49
65
  else keep.push(name);
50
66
  }
51
- if (drop.length === 0 || keep.length === 0) return { applied: false, keep: [...tools], drop: [] };
67
+ if (drop.length === 0 || keep.length + base === 0) return { applied: false, keep: [...tools], drop: [] };
52
68
  return { applied: true, keep, drop };
53
69
  }
54
70
 
@@ -96,7 +112,7 @@ export function createNarrowing({ evaluate, sessions, settings, coreTools = DEFA
96
112
  let raw = {};
97
113
  try { raw = (typeof settings === 'function' ? await settings() : settings) ?? {}; } catch { raw = {}; }
98
114
  const mode = raw?.mode === 'observe' ? 'observe' : 'enforce';
99
- const threshold = typeof raw?.threshold === 'number' && Number.isFinite(raw.threshold) && raw.threshold >= 0 && raw.threshold <= 1 ? raw.threshold : 0.3;
115
+ const threshold = typeof raw?.threshold === 'number' && Number.isFinite(raw.threshold) && raw.threshold >= 0 && raw.threshold <= 1 ? raw.threshold : DEFAULT_NARROW_THRESHOLD;
100
116
  const batchMode = raw?.batchMode === 'single' || raw?.batchMode === 'split' ? raw.batchMode : 'auto';
101
117
  // An explicit array in settings (including empty) overrides the default set.
102
118
  const prefixes = Array.isArray(raw?.keepPrefixes)
@@ -112,7 +128,7 @@ export function createNarrowing({ evaluate, sessions, settings, coreTools = DEFA
112
128
  const record = (turn, outcome, detail = {}) => {
113
129
  const id = turn?.agent?.session?.id;
114
130
  if (!sessions || typeof id !== 'string') return;
115
- try { if (typeof sessions.recordNarrow === 'function') sessions.recordNarrow(id, outcome, detail.dropped ?? 0); }
131
+ try { if (typeof sessions.recordNarrow === 'function') sessions.recordNarrow(id, outcome, detail.dropped ?? 0, narrowApplied({ ...detail, outcome })); }
116
132
  catch { /* telemetry is best effort */ }
117
133
  if (typeof sessions.log !== 'function') return;
118
134
  try { sessions.log(id, { kind: 'narrow', outcome, ...detail }); }
@@ -191,14 +207,14 @@ export function createNarrowing({ evaluate, sessions, settings, coreTools = DEFA
191
207
  }
192
208
  }
193
209
  }
194
- const narrowed = narrowTools(judgedNames, result, { threshold });
210
+ const narrowed = narrowTools(judgedNames, result, { threshold, keepBase: keptNames.length });
195
211
  const keep = [...keptNames, ...narrowed.keep];
196
212
  const drop = narrowed.drop;
197
213
  // C: too few tools left over is a sign the verdict over-pruned; do not apply.
198
214
  const trustworthy = keep.length >= minKeep;
199
215
  const applied = narrowed.applied && trustworthy && !overCap && mode === 'enforce';
200
216
  const dropped = drop.length;
201
- const detail = { dropped, kept: keep.length, mode: effectiveMode, tools: drop };
217
+ const detail = { dropped, kept: keep.length, mode: effectiveMode, tools: drop, applied };
202
218
  // The optional-candidate count (excludes core tools and keep-prefix matches),
203
219
  // recorded so the "too many candidates" reason shows the number that tripped it.
204
220
  if (overCap) { detail.reason = 'too-many-candidates'; detail.candidates = optional.length; }
@@ -33,6 +33,8 @@ function boundedRubric(rubric) {
33
33
  return levels.length >= 2 ? levels : DEFAULT_RUBRIC;
34
34
  }
35
35
 
36
+ const isProbability = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
37
+
36
38
  export function scoreQuestion(output, rubric = DEFAULT_RUBRIC) {
37
39
  return {
38
40
  state: { answer: String(output).slice(0, MAX_OUTPUT) },
@@ -44,10 +46,16 @@ export function scoreQuestion(output, rubric = DEFAULT_RUBRIC) {
44
46
  };
45
47
  }
46
48
 
49
+ // The confidence floor for interrupting the user with a supplemental prompt.
50
+ // Named rather than inlined because config-store's `checkSettings.minConfidence`
51
+ // and the config form both document this value; a bare literal in three places
52
+ // drifts silently on the next tuning pass.
53
+ export const DEFAULT_MIN_CONFIDENCE = 0.4;
54
+
47
55
  // `settings` is an async resolver so rubric/mode/threshold follow live config.
48
56
  // `steer(agent, text)` performs the host-native steer; the caller owns building
49
57
  // the framework UserMessage so this module stays free of DSH message types.
50
- export function createSelfCheck({ evaluate, sessions, steer, settings, minConfidence = 0.4 } = {}) {
58
+ export function createSelfCheck({ evaluate, sessions, steer, settings, minConfidence = DEFAULT_MIN_CONFIDENCE } = {}) {
51
59
  if (typeof evaluate !== 'function') throw new Error('Self-check requires an evaluator');
52
60
  const steerOnce = createSteerOnce();
53
61
  const resolveSettings = async () => {
@@ -55,11 +63,15 @@ export function createSelfCheck({ evaluate, sessions, steer, settings, minConfid
55
63
  try { raw = (typeof settings === 'function' ? await settings() : settings) ?? {}; } catch { raw = {}; }
56
64
  const mode = raw?.mode === 'steer' ? 'steer' : 'observe';
57
65
  const lowScoreThreshold = Number.isSafeInteger(raw?.lowScoreThreshold) && raw.lowScoreThreshold >= 0 ? raw.lowScoreThreshold : 1;
66
+ // Confidence required before a low score actually opens the supplemental
67
+ // prompt. The constructor argument is the fallback so a direct caller (and a
68
+ // config without the field) keeps its own floor.
69
+ const confidenceFloor = isProbability(raw?.minConfidence) ? raw.minConfidence : minConfidence;
58
70
  // Quality-trend thresholds (unvalidated heuristics; see quality-trend.mjs).
59
71
  const trendMinConfidence = typeof raw?.trendMinConfidence === 'number' && Number.isFinite(raw.trendMinConfidence) && raw.trendMinConfidence >= 0 && raw.trendMinConfidence <= 1 ? raw.trendMinConfidence : 0.6;
60
72
  const trendRun = Number.isSafeInteger(raw?.trendRun) && raw.trendRun >= 2 ? raw.trendRun : 3;
61
73
  const trendSevereRun = Number.isSafeInteger(raw?.trendSevereRun) && raw.trendSevereRun > trendRun ? raw.trendSevereRun : Math.max(trendRun + 1, 5);
62
- return { mode, rubric: boundedRubric(raw?.rubric), lowScoreThreshold, trendMinConfidence, trendRun, trendSevereRun };
74
+ return { mode, rubric: boundedRubric(raw?.rubric), lowScoreThreshold, minConfidence: confidenceFloor, trendMinConfidence, trendRun, trendSevereRun };
63
75
  };
64
76
  const record = (turn, outcome, detail = {}, trend) => {
65
77
  const id = turn?.agent?.session?.id;
@@ -82,7 +94,7 @@ export function createSelfCheck({ evaluate, sessions, steer, settings, minConfid
82
94
  // can tell which turn's output each self-check score belongs to.
83
95
  const subject = subjectOf(turn?.userText);
84
96
  const subjectDetail = subject ? { subject } : {};
85
- const { mode, rubric, lowScoreThreshold, trendMinConfidence, trendRun, trendSevereRun } = await resolveSettings();
97
+ const { mode, rubric, lowScoreThreshold, minConfidence: confidenceFloor, trendMinConfidence, trendRun, trendSevereRun } = await resolveSettings();
86
98
  let answer;
87
99
  try {
88
100
  const result = await evaluate(scoreQuestion(output, rubric), { signal: turn.signal });
@@ -107,7 +119,7 @@ export function createSelfCheck({ evaluate, sessions, steer, settings, minConfid
107
119
  if (lowScore && confidence !== undefined && !trend.confident) detail.lowConfidence = true;
108
120
  record(turn, lowScore ? 'low' : 'ok', { score: answer.score, ...detail }, trend);
109
121
  if (!lowScore) return { evaluated: true, lowScore: false, steered: false, score: answer.score };
110
- const confident = confidence !== undefined && confidence >= minConfidence;
122
+ const confident = confidence !== undefined && confidence >= confidenceFloor;
111
123
  // The turn budget is consumed only on a successful steer (createSteerOnce),
112
124
  // so a transient host failure does not permanently suppress this turn.
113
125
  const steered = mode === 'steer' && confident && typeof steer === 'function' && turn?.agent
@@ -172,14 +172,16 @@ export function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onL
172
172
  }
173
173
  bump(sessionKey(id), state);
174
174
  },
175
- recordNarrow(id, outcome, dropped = 0) {
175
+ recordNarrow(id, outcome, dropped = 0, applied = outcome === 'applied') {
176
176
  if (!['ok', 'applied', 'error'].includes(outcome)) throw new Error('Invalid narrowing outcome');
177
177
  const state = writable(id);
178
178
  state.attempts++;
179
179
  state.narrow.attempts++;
180
180
  if (outcome === 'error') { state.failures++; state.narrow.failures++; }
181
181
  else if (outcome === 'applied') {
182
- state.narrow.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++;
183
185
  if (Number.isSafeInteger(dropped) && dropped > 0) state.narrow.dropped += dropped;
184
186
  }
185
187
  bump(sessionKey(id), state);
@@ -227,6 +229,7 @@ export function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onL
227
229
  if (typeof entry.confidence === 'number' && Number.isFinite(entry.confidence)) record.confidence = Math.round(entry.confidence * 100) / 100;
228
230
  if (Number.isSafeInteger(entry.dropped) && entry.dropped >= 0) record.dropped = entry.dropped;
229
231
  if (Number.isSafeInteger(entry.kept) && entry.kept >= 0) record.kept = entry.kept;
232
+ if (typeof entry.applied === 'boolean') record.applied = entry.applied;
230
233
  if (Number.isSafeInteger(entry.candidates) && entry.candidates >= 0) record.candidates = entry.candidates;
231
234
  if (Number.isSafeInteger(entry.conditions) && entry.conditions >= 0) record.conditions = entry.conditions;
232
235
  if (Number.isSafeInteger(entry.satisfied) && entry.satisfied >= 0) record.satisfied = entry.satisfied;
@@ -74,11 +74,17 @@ export function completionQuestions(conditions, evidence, answer) {
74
74
  return { state, questions };
75
75
  }
76
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
+
77
83
  // Orchestrates one task-completion pass at turn end. Read-only by default: it
78
84
  // records a per-condition verdict and only steers when explicitly configured,
79
85
  // the model is confident, and at least one condition is not satisfied. A steer
80
86
  // opens a supplemental step; it never rewrites the already-committed reply.
81
- export function createTaskCompletion({ evaluate, sessions, steer, settings, minConfidence = 0.4 } = {}) {
87
+ export function createTaskCompletion({ evaluate, sessions, steer, settings, minConfidence = DEFAULT_MIN_CONFIDENCE } = {}) {
82
88
  if (typeof evaluate !== 'function') throw new Error('Task completion requires an evaluator');
83
89
  const steerOnce = createSteerOnce();
84
90
  const resolveSettings = async () => {
@@ -86,7 +92,11 @@ export function createTaskCompletion({ evaluate, sessions, steer, settings, minC
86
92
  try { raw = (typeof settings === 'function' ? await settings() : settings) ?? {}; } catch { raw = {}; }
87
93
  const mode = raw?.mode === 'steer' ? 'steer' : 'observe';
88
94
  const minConditions = Number.isSafeInteger(raw?.minConditions) && raw.minConditions >= 2 ? raw.minConditions : 2;
89
- return { mode, minConditions };
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 };
90
100
  };
91
101
  const record = (turn, outcome, detail = {}) => {
92
102
  const id = turn?.agent?.session?.id;
@@ -100,7 +110,7 @@ export function createTaskCompletion({ evaluate, sessions, steer, settings, minC
100
110
  return {
101
111
  async review(turn) {
102
112
  const output = typeof turn?.output === 'string' ? turn.output.trim() : '';
103
- const { mode, minConditions } = await resolveSettings();
113
+ const { mode, minConditions, minConfidence: confidenceFloor } = await resolveSettings();
104
114
  const conditions = extractConditions(turn?.userText);
105
115
  if (conditions.length < minConditions) return { ...skip };
106
116
  const evidence = Array.isArray(turn?.evidence) ? turn.evidence : [];
@@ -139,7 +149,7 @@ export function createTaskCompletion({ evaluate, sessions, steer, settings, minC
139
149
  const outcome = unsatisfied > 0 ? 'unsatisfied' : 'ok';
140
150
  const detail = { conditions: conditions.length, satisfied, unsatisfied, insufficient };
141
151
  // A steer is only for a confident finding that a listed condition is not met.
142
- const confident = unsatisfied > 0 && unsatisfiedMinConf >= minConfidence;
152
+ const confident = unsatisfied > 0 && unsatisfiedMinConf >= confidenceFloor;
143
153
  // The turn budget is consumed only on a successful steer (createSteerOnce),
144
154
  // so a transient host failure does not permanently suppress this turn.
145
155
  const steered = mode === 'steer' && confident && typeof steer === 'function' && turn?.agent