dsh-decision-layer 0.5.2 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/lib/client.js +284 -324
- package/package.json +1 -1
- package/service/manager-api.mjs +11 -2
- package/service/narrowing.mjs +18 -6
- package/service/self-check.mjs +9 -16
- package/service/steer-once.mjs +25 -0
- package/service/task-completion.mjs +17 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-decision-layer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
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",
|
package/service/manager-api.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}),
|
package/service/narrowing.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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(
|
|
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.
|
package/service/self-check.mjs
CHANGED
|
@@ -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
|
|
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 = {}; }
|
|
@@ -85,17 +83,12 @@ export function createSelfCheck({ evaluate, sessions, steer, settings, minConfid
|
|
|
85
83
|
record(turn, lowScore ? 'low' : 'ok', { score: answer.score, ...detail }, trend);
|
|
86
84
|
if (!lowScore) return { evaluated: true, lowScore: false, steered: false, score: answer.score };
|
|
87
85
|
const confident = confidence !== undefined && confidence >= minConfidence;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 };
|
|
86
|
+
// The turn budget is consumed only on a successful steer (createSteerOnce),
|
|
87
|
+
// so a transient host failure does not permanently suppress this turn.
|
|
88
|
+
const steered = mode === 'steer' && confident && typeof steer === 'function' && turn?.agent
|
|
89
|
+
? steerOnce.attempt(turn, () => steer(turn.agent, STEER_TEXT))
|
|
90
|
+
: false;
|
|
91
|
+
return { evaluated: true, lowScore: true, steered, score: answer.score };
|
|
99
92
|
},
|
|
100
93
|
};
|
|
101
94
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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')
|
|
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 &&
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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 };
|