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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-decision-layer",
3
- "version": "0.6.4",
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",
@@ -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
@@ -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
@@ -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