dsh-decision-layer 0.5.2 → 0.5.4

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.5.2",
3
+ "version": "0.5.4",
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",
@@ -45,6 +45,10 @@ export function createManagerRoutes({ path, sessions, backend, logStore, analyze
45
45
  const value = await methods[req.method](req);
46
46
  return respond(res, 200, { ok: true, value });
47
47
  } catch (error) {
48
+ // A genuine internal fault (log analysis / annotation write) is a 500; a
49
+ // tagged or untagged validation/config error stays a client 400. Neither
50
+ // body carries error internals.
51
+ if (error?.code === 'INTERNAL') return respond(res, 500, { ok: false, error: 'Internal error' });
48
52
  if (error?.code === 'BAD_REQUEST') return respond(res, 400, { ok: false, error: error.message });
49
53
  return respond(res, 400, { ok: false, error: 'Invalid request or backend unavailable' });
50
54
  }
@@ -81,7 +85,9 @@ export function createManagerRoutes({ path, sessions, backend, logStore, analyze
81
85
  route('logs', {
82
86
  GET: async req => {
83
87
  if (typeof analyze !== 'function') { const error = new Error('analysis unavailable'); error.code = 'BAD_REQUEST'; throw error; }
84
- return analyze(sessionId(req));
88
+ const id = sessionId(req);
89
+ try { return await analyze(id); }
90
+ catch (error) { if (error?.code === 'BAD_REQUEST') throw error; const wrapped = new Error('analysis failed'); wrapped.code = 'INTERNAL'; throw wrapped; }
85
91
  },
86
92
  }),
87
93
  // Append one annotation event for a decision. Explicit user intent: validated
@@ -96,7 +102,10 @@ export function createManagerRoutes({ path, sessions, backend, logStore, analyze
96
102
  if (!target || target.length > 128 || !['good', 'bad', 'unsure'].includes(rating) || !session || session.length > 512) {
97
103
  const error = new Error('Invalid annotation'); error.code = 'BAD_REQUEST'; throw error;
98
104
  }
99
- await logStore.appendAnnotation({ kind: 'annotation', target, rating, sessionId: session });
105
+ // A validated write that still fails is an internal fault (disk / IO), not
106
+ // bad input: surface it as 500 so the client does not read it as a 400.
107
+ try { await logStore.appendAnnotation({ kind: 'annotation', target, rating, sessionId: session }); }
108
+ catch { const error = new Error('annotation write failed'); error.code = 'INTERNAL'; throw error; }
100
109
  return { ok: true, target, rating };
101
110
  },
102
111
  }),
@@ -1,6 +1,11 @@
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
3
  const MAX_STATE = 8 * 1024;
4
+ // The adjudication backend hard-rejects a request carrying more than 64 questions
5
+ // (backend.mjs validateRequest). One relevance question is sent per optional tool,
6
+ // so the candidate cap can never exceed this — otherwise evaluate() throws locally
7
+ // before any network call and the failure is mislabeled as a backend error.
8
+ const MAX_QUESTIONS = 64;
4
9
 
5
10
  // Core tools an agent almost always needs. They are never judged for relevance
6
11
  // and never dropped, so narrowing cannot strip the agent's fundamental
@@ -90,8 +95,11 @@ export function createNarrowing({ evaluate, sessions, settings, coreTools = DEFA
90
95
  const prefixes = Array.isArray(raw?.keepPrefixes)
91
96
  ? raw.keepPrefixes.filter(prefix => typeof prefix === 'string' && prefix)
92
97
  : defaultPrefixes;
93
- // A configured optional-candidate ceiling overrides the constructor default.
94
- const cap = Number.isSafeInteger(raw?.maxCandidates) && raw.maxCandidates >= 1 ? raw.maxCandidates : maxCandidates;
98
+ // A configured optional-candidate ceiling overrides the constructor default,
99
+ // clamped to the backend's per-request question limit so the judged set is
100
+ // never large enough to be rejected before it is sent.
101
+ const configuredCap = Number.isSafeInteger(raw?.maxCandidates) && raw.maxCandidates >= 1 ? raw.maxCandidates : maxCandidates;
102
+ const cap = Math.min(configuredCap, MAX_QUESTIONS);
95
103
  return { mode, threshold, prefixes, cap };
96
104
  };
97
105
  const record = (turn, outcome, detail = {}) => {
@@ -114,7 +122,6 @@ export function createNarrowing({ evaluate, sessions, settings, coreTools = DEFA
114
122
  const alwaysKeep = name => core.has(name) || prefixes.some(prefix => name.startsWith(prefix));
115
123
  const keptNames = names.filter(alwaysKeep);
116
124
  const optional = tools.filter(tool => !alwaysKeep(tool.name));
117
- const optionalNames = optional.map(tool => tool.name);
118
125
  if (optional.length < minCandidates) return { ...skip, keep: names };
119
126
  const hasState = typeof turn?.state === 'string' ? turn.state.trim().length > 0
120
127
  : isObject(turn?.state) ? Object.keys(turn.state).length > 0 : false;
@@ -124,13 +131,18 @@ export function createNarrowing({ evaluate, sessions, settings, coreTools = DEFA
124
131
  try { if (sessions.snapshot(id).enabled === false) return { ...skip, keep: names }; }
125
132
  catch { return { ...skip, keep: names }; }
126
133
  }
127
- // B: a large optional surface must not be enforced — only observed.
134
+ // B: a large optional surface must not be enforced — only observed. When
135
+ // over the cap we still evaluate a bounded subset (never more than the
136
+ // backend's question limit) so the pass is logged without throwing, but the
137
+ // result is observe-only and applies no restriction.
128
138
  const overCap = optional.length > cap;
129
139
  const effectiveMode = overCap ? 'observe' : mode;
140
+ const judged = overCap ? optional.slice(0, cap) : optional;
141
+ const judgedNames = judged.map(tool => tool.name);
130
142
  let result;
131
- try { result = await evaluate(relevanceQuestions(turn.state, optional), { signal: turn.signal }); }
143
+ try { result = await evaluate(relevanceQuestions(turn.state, judged), { signal: turn.signal }); }
132
144
  catch (error) { record(turn, 'error', { reason: typeof error?.reason === 'string' ? error.reason : 'unreachable', mode: effectiveMode }); return { ...skip, keep: names, mode: effectiveMode }; }
133
- const narrowed = narrowTools(optionalNames, result, { threshold });
145
+ const narrowed = narrowTools(judgedNames, result, { threshold });
134
146
  const keep = [...keptNames, ...narrowed.keep];
135
147
  const drop = narrowed.drop;
136
148
  // C: too few tools left over is a sign the verdict over-pruned; do not apply.
@@ -1,3 +1,5 @@
1
+ import { createSteerOnce } from './steer-once.mjs';
2
+
1
3
  const MAX_OUTPUT = 24 * 1024;
2
4
 
3
5
  export const DEFAULT_RUBRIC = Object.freeze([
@@ -30,11 +32,7 @@ export function scoreQuestion(output, rubric = DEFAULT_RUBRIC) {
30
32
  // the framework UserMessage so this module stays free of DSH message types.
31
33
  export function createSelfCheck({ evaluate, sessions, steer, settings, minConfidence = 0.4 } = {}) {
32
34
  if (typeof evaluate !== 'function') throw new Error('Self-check requires an evaluator');
33
- const steeredTurns = new Set();
34
- const steerKey = turn => {
35
- const id = turn?.agent?.session?.id;
36
- return typeof id === 'string' && Number.isInteger(turn?.turn) ? `${id}:${turn.turn}` : undefined;
37
- };
35
+ const steerOnce = createSteerOnce();
38
36
  const resolveSettings = async () => {
39
37
  let raw = {};
40
38
  try { raw = (typeof settings === 'function' ? await settings() : settings) ?? {}; } catch { raw = {}; }
@@ -82,20 +80,19 @@ export function createSelfCheck({ evaluate, sessions, steer, settings, minConfid
82
80
  // The trend run advances over every evaluated score; a high-confidence low
83
81
  // score is the only thing that grows it. Thresholds flow to session-state.
84
82
  const trend = { confident: lowScore && confidence !== undefined && confidence >= trendMinConfidence, trendRun, trendSevereRun };
83
+ // A low score the model is not confident about (confidence present but below
84
+ // the trend threshold) does not advance the quality-trend judgment. Flag it
85
+ // so the decision log can mark it as low-signal noise instead of a real dip.
86
+ if (lowScore && confidence !== undefined && !trend.confident) detail.lowConfidence = true;
85
87
  record(turn, lowScore ? 'low' : 'ok', { score: answer.score, ...detail }, trend);
86
88
  if (!lowScore) return { evaluated: true, lowScore: false, steered: false, score: answer.score };
87
89
  const confident = confidence !== undefined && confidence >= minConfidence;
88
- if (mode === 'steer' && confident && typeof steer === 'function' && turn?.agent) {
89
- const key = steerKey(turn);
90
- if (key !== undefined && !steeredTurns.has(key)) {
91
- steeredTurns.add(key);
92
- if (steeredTurns.size > 4096) steeredTurns.delete(steeredTurns.values().next().value);
93
- try { steer(turn.agent, STEER_TEXT); }
94
- catch { return { evaluated: true, lowScore: true, steered: false, score: answer.score }; }
95
- return { evaluated: true, lowScore: true, steered: true, score: answer.score };
96
- }
97
- }
98
- return { evaluated: true, lowScore: true, steered: false, score: answer.score };
90
+ // The turn budget is consumed only on a successful steer (createSteerOnce),
91
+ // so a transient host failure does not permanently suppress this turn.
92
+ const steered = mode === 'steer' && confident && typeof steer === 'function' && turn?.agent
93
+ ? steerOnce.attempt(turn, () => steer(turn.agent, STEER_TEXT))
94
+ : false;
95
+ return { evaluated: true, lowScore: true, steered, score: answer.score };
99
96
  },
100
97
  };
101
98
  }
@@ -131,6 +131,7 @@ export function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onL
131
131
  if (Number.isSafeInteger(entry.unsatisfied) && entry.unsatisfied >= 0) record.unsatisfied = entry.unsatisfied;
132
132
  if (Number.isSafeInteger(entry.insufficient) && entry.insufficient >= 0) record.insufficient = entry.insufficient;
133
133
  if (entry.steered === true) record.steered = true;
134
+ if (entry.lowConfidence === true) record.lowConfidence = true;
134
135
  if (Array.isArray(entry.tools)) {
135
136
  const names = entry.tools.filter(name => typeof name === 'string' && name.trim()).slice(0, 12).map(name => name.trim().slice(0, 64));
136
137
  if (names.length > 0) record.tools = names;
@@ -0,0 +1,25 @@
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,3 +1,5 @@
1
+ import { createSteerOnce } from './steer-once.mjs';
2
+
1
3
  const MAX_STATE = 8 * 1024;
2
4
  const MAX_CONDITION = 512;
3
5
  const MAX_CONDITIONS = 20;
@@ -78,11 +80,7 @@ export function completionQuestions(conditions, evidence, answer) {
78
80
  // opens a supplemental step; it never rewrites the already-committed reply.
79
81
  export function createTaskCompletion({ evaluate, sessions, steer, settings, minConfidence = 0.4 } = {}) {
80
82
  if (typeof evaluate !== 'function') throw new Error('Task completion requires an evaluator');
81
- const steeredTurns = new Set();
82
- const steerKey = turn => {
83
- const id = turn?.agent?.session?.id;
84
- return typeof id === 'string' && Number.isInteger(turn?.turn) ? `${id}:${turn.turn}` : undefined;
85
- };
83
+ const steerOnce = createSteerOnce();
86
84
  const resolveSettings = async () => {
87
85
  let raw = {};
88
86
  try { raw = (typeof settings === 'function' ? await settings() : settings) ?? {}; } catch { raw = {}; }
@@ -122,31 +120,31 @@ export function createTaskCompletion({ evaluate, sessions, steer, settings, minC
122
120
  }
123
121
  if (!answers) { record(turn, 'error', { reason: 'invalid-response' }); return { ...skip }; }
124
122
  let satisfied = 0, unsatisfied = 0, insufficient = 0;
125
- let minConf = 1;
123
+ // Track the minimum confidence over the `unsatisfied` verdicts only: a steer
124
+ // fires on a confident unsatisfied finding, so a low-confidence satisfied /
125
+ // insufficient verdict on another condition must not suppress it.
126
+ let unsatisfiedMinConf = 1;
126
127
  for (let index = 0; index < conditions.length; index++) {
127
128
  const answer = answers[`c${index}`];
128
129
  if (!isObject(answer) || answer.type !== 'choice' || typeof answer.choice !== 'string') {
129
130
  insufficient++; continue;
130
131
  }
131
- if (probability(answer.confidence)) minConf = Math.min(minConf, answer.confidence);
132
132
  if (answer.choice === 'satisfied') satisfied++;
133
- else if (answer.choice === 'unsatisfied') unsatisfied++;
133
+ else if (answer.choice === 'unsatisfied') {
134
+ unsatisfied++;
135
+ if (probability(answer.confidence)) unsatisfiedMinConf = Math.min(unsatisfiedMinConf, answer.confidence);
136
+ }
134
137
  else insufficient++;
135
138
  }
136
139
  const outcome = unsatisfied > 0 ? 'unsatisfied' : 'ok';
137
140
  const detail = { conditions: conditions.length, satisfied, unsatisfied, insufficient };
138
141
  // A steer is only for a confident finding that a listed condition is not met.
139
- const confident = unsatisfied > 0 && minConf >= minConfidence;
140
- let steered = false;
141
- if (mode === 'steer' && confident && typeof steer === 'function' && turn?.agent) {
142
- const key = steerKey(turn);
143
- if (key !== undefined && !steeredTurns.has(key)) {
144
- steeredTurns.add(key);
145
- if (steeredTurns.size > 4096) steeredTurns.delete(steeredTurns.values().next().value);
146
- try { steer(turn.agent, STEER_TEXT); steered = true; }
147
- catch { steered = false; }
148
- }
149
- }
142
+ const confident = unsatisfied > 0 && unsatisfiedMinConf >= minConfidence;
143
+ // The turn budget is consumed only on a successful steer (createSteerOnce),
144
+ // so a transient host failure does not permanently suppress this turn.
145
+ const steered = mode === 'steer' && confident && typeof steer === 'function' && turn?.agent
146
+ ? steerOnce.attempt(turn, () => steer(turn.agent, STEER_TEXT))
147
+ : false;
150
148
  if (steered) detail.steered = true;
151
149
  record(turn, outcome, detail);
152
150
  return { evaluated: true, steered, satisfied, unsatisfied, insufficient };