dsh-decision-layer 0.6.4 → 0.7.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 +7 -7
- package/README.md +16 -10
- package/lib/client.js +700 -347
- package/package.json +4 -2
- package/service/config-store.mjs +60 -6
- package/service/danger-gate.mjs +16 -2
- package/service/dsh.mjs +14 -1
- package/service/self-check.mjs +16 -4
- package/service/task-completion.mjs +14 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-decision-layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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",
|
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"test": "node --test tests/*.test.mjs",
|
|
38
|
-
"build:client": "tsc -p tsconfig.client.json && node scripts/build-client.mjs"
|
|
38
|
+
"build:client": "tsc -p tsconfig.client.json && node scripts/build-client.mjs",
|
|
39
|
+
"check": "npm run build:client && npm test",
|
|
40
|
+
"pack:assert": "node scripts/assert-pack.mjs"
|
|
39
41
|
},
|
|
40
42
|
"exports": {
|
|
41
43
|
".": "./service/dsh.mjs",
|
package/service/config-store.mjs
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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;
|
package/service/danger-gate.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
package/service/self-check.mjs
CHANGED
|
@@ -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 =
|
|
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 >=
|
|
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
|
|
@@ -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 =
|
|
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
|
-
|
|
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 >=
|
|
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
|