dsh-decision-layer 0.4.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.
@@ -0,0 +1,175 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
3
+ import { resolveConfig, featureEnabled } from './config-store.mjs';
4
+ import { createBackend } from './backend.mjs';
5
+ import { createManagerRoutes } from './manager-api.mjs';
6
+ import { createSessionState } from './session-state.mjs';
7
+ import { createDangerGate, normalizeDangerRules } from './danger-gate.mjs';
8
+ import { createSelfCheck } from './self-check.mjs';
9
+ import { createNarrowing } from './narrowing.mjs';
10
+ import { createLoopGuard } from './loop-guard.mjs';
11
+ import { createLogStore } from './log-store.mjs';
12
+
13
+ export const name = 'dsh-decision-layer';
14
+ export const inject = ['tools', 'skills'];
15
+
16
+ // Best-effort extraction of the turn's final assistant text. The self-check
17
+ // treats empty output as "skip", so an unreachable shape degrades safely.
18
+ function lastAssistantText(agent) {
19
+ try {
20
+ const messages = agent?.session?.deriveMessages?.();
21
+ if (!Array.isArray(messages)) return '';
22
+ for (let i = messages.length - 1; i >= 0; i--) {
23
+ const message = messages[i];
24
+ if (message?.role !== 'assistant' || !Array.isArray(message.content)) continue;
25
+ return message.content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join('');
26
+ }
27
+ return '';
28
+ } catch { return ''; }
29
+ }
30
+
31
+ // The task text a narrowing pass judges tools against: the user messages
32
+ // entering this step. An unreachable shape yields '', which skips narrowing.
33
+ function stepTaskText(messages) {
34
+ try {
35
+ if (!Array.isArray(messages)) return '';
36
+ return messages
37
+ .filter(message => message?.role === 'user' && Array.isArray(message.content))
38
+ .flatMap(message => message.content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text))
39
+ .join('\n').slice(0, 8 * 1024);
40
+ } catch { return ''; }
41
+ }
42
+
43
+ // Enumerate the tool names/descriptions visible to this agent scope, best-effort.
44
+ function agentTools(ctx, agent) {
45
+ try {
46
+ const schemas = typeof ctx.tools?.schemas === 'function' ? ctx.tools.schemas(agent) : undefined;
47
+ if (!Array.isArray(schemas)) return [];
48
+ return schemas
49
+ .filter(schema => schema && typeof schema.name === 'string')
50
+ .map(schema => ({ name: schema.name, description: typeof schema.description === 'string' ? schema.description : '' }));
51
+ } catch { return []; }
52
+ }
53
+
54
+ export async function apply(ctx, options = {}) {
55
+ const path = options.configPath;
56
+ const backend = createBackend({ config: () => resolveConfig({ path }) });
57
+ // The research log sink is attached once the web server reveals the instance
58
+ // port (used in the file name); until then decision logs stay in memory only.
59
+ let logStore;
60
+ const sessions = createSessionState({ onLog: entry => logStore?.append(entry) });
61
+ const gate = createDangerGate({
62
+ evaluate: (input, request) => backend.evaluate(input, request),
63
+ sessions,
64
+ rules: async () => normalizeDangerRules((await resolveConfig({ path })).dangerRules),
65
+ });
66
+ const loopGuard = createLoopGuard({ sessions });
67
+ // Live per-feature enablement; every decision point defaults to enabled.
68
+ const featureOn = async key => featureEnabled((await resolveConfig({ path })).features, key);
69
+ if (typeof ctx.on === 'function') {
70
+ if (typeof ctx.tools.guard === 'function') {
71
+ // Deterministic loop guard runs with no backend; then the dangerous-gate
72
+ // monotonic denial. Guards can only deny, never re-allow.
73
+ ctx.tools.guard(exec => loopGuard.check(exec));
74
+ ctx.tools.guard(exec => gate.guardReason(exec));
75
+ }
76
+ const install = () => {
77
+ const disposePre = ctx.on('tools/pre-execute', async (exec, next) => {
78
+ if (!(await featureOn('gate'))) return next();
79
+ return gate.preExecute(exec, next);
80
+ });
81
+ const disposePost = ctx.on('tools/post-execute', async (exec, result, next) => {
82
+ gate.observeResult(exec, result);
83
+ return next();
84
+ });
85
+ return () => { disposePre?.(); disposePost?.(); };
86
+ };
87
+ if (typeof ctx.effect === 'function') ctx.effect(install);
88
+ else install();
89
+
90
+ const selfCheck = createSelfCheck({
91
+ evaluate: (input, request) => backend.evaluate(input, request),
92
+ sessions,
93
+ settings: async () => (await resolveConfig({ path })).checkSettings,
94
+ steer: (agent, text) => agent.steer(createUserMessage({
95
+ content: [{ type: 'text', text }],
96
+ source: { kind: 'user' },
97
+ })),
98
+ });
99
+ const installCheck = () => ctx.on('agent/turn-stopping', async payload => {
100
+ if (!(await featureOn('check'))) return;
101
+ return selfCheck.review({
102
+ agent: payload.agent, turn: payload.turn, signal: payload.signal,
103
+ output: lastAssistantText(payload.agent),
104
+ });
105
+ });
106
+ if (typeof ctx.effect === 'function') ctx.effect(installCheck);
107
+ else installCheck();
108
+
109
+ const narrowing = createNarrowing({
110
+ evaluate: (input, request) => backend.evaluate(input, request),
111
+ sessions,
112
+ settings: async () => (await resolveConfig({ path })).narrowSettings,
113
+ });
114
+ // Narrow once per turn, on its first step, and only enforce a restriction in
115
+ // enforce mode. The restriction is agent-scoped and lifted before the next
116
+ // turn's pass so a later turn is judged against the full host-allowed set.
117
+ const narrowedTurns = new Map();
118
+ const installNarrowingHook = () => ctx.on('agent/pre-step', async (payload, next) => {
119
+ const agent = payload?.agent;
120
+ const id = agent?.session?.id;
121
+ const turn = payload?.turn;
122
+ if (typeof id === 'string' && Number.isInteger(turn) && await featureOn('narrow')) {
123
+ const state = narrowedTurns.get(id);
124
+ if (!state || state.turn !== turn) {
125
+ state?.dispose?.();
126
+ const entry = { turn, dispose: undefined };
127
+ narrowedTurns.set(id, entry);
128
+ if (narrowedTurns.size > 1024) { const oldest = narrowedTurns.keys().next().value; if (oldest !== id) { narrowedTurns.get(oldest)?.dispose?.(); narrowedTurns.delete(oldest); } }
129
+ try {
130
+ const result = await narrowing.review({
131
+ agent, turn, signal: payload.signal,
132
+ state: stepTaskText(payload.messages), tools: agentTools(ctx, agent),
133
+ });
134
+ if (result.mode === 'enforce' && result.applied && typeof agent?.ctx?.tools?.restrict === 'function') {
135
+ entry.dispose = agent.ctx.tools.restrict({ allow: result.keep });
136
+ }
137
+ } catch { /* narrowing is best effort; never block the step */ }
138
+ }
139
+ }
140
+ return next();
141
+ });
142
+ if (typeof ctx.effect === 'function') ctx.effect(installNarrowingHook);
143
+ else installNarrowingHook();
144
+ }
145
+ const rawSkill = await readFile(new URL('../skills/decision-layer/SKILL.md', import.meta.url), 'utf8');
146
+ const content = rawSkill.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trim();
147
+ ctx.skills.register({ name: 'decision-layer', description: '显式发起结构化裁决,辅助明确边界的选择与评分。', content, source: 'bundled' });
148
+
149
+ const output = { schema: { type: 'object', additionalProperties: true }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] };
150
+ ctx.tools.register({
151
+ name: 'decision_evaluate', description: '对 state 和 questions 执行一次显式结构化裁决,不修改 agent 行为。',
152
+ parameters: { type: 'object', required: ['state', 'questions'], properties: {
153
+ state: { oneOf: [{ type: 'string' }, { type: 'object', additionalProperties: true }, { type: 'array', items: {} }] },
154
+ questions: { type: 'object', additionalProperties: { type: 'object', additionalProperties: true } },
155
+ }, additionalProperties: false },
156
+ output, timeoutMs: 25_000,
157
+ execute: (args, exec) => backend.evaluate(args, { signal: exec.signal }),
158
+ });
159
+ ctx.tools.register({
160
+ name: 'decision_check_connection', description: '向裁决端点发送一次轻量连通测试,不增加自动裁决指标。',
161
+ parameters: { type: 'object', properties: {}, additionalProperties: false }, output, timeoutMs: 25_000,
162
+ execute: async (_args, exec) => {
163
+ const result = await backend.evaluate({ state: 'Connection test', questions: {
164
+ connected: { type: 'noul', instructions: 'Is this a connection test?' },
165
+ } }, { signal: exec.signal });
166
+ return { connected: true, model: result.model, usage: result.usage };
167
+ },
168
+ });
169
+
170
+ ctx.inject(['webServer'], web => {
171
+ logStore = createLogStore({ port: web.webServer.port });
172
+ const unregister = createManagerRoutes({ path, sessions, backend, bindHost: web.webServer.host }).map(route => web.webServer.register(route));
173
+ return () => { unregister.forEach(dispose => dispose()); const store = logStore; logStore = undefined; void store?.dispose(); };
174
+ });
175
+ }
@@ -0,0 +1,70 @@
1
+ import { mkdir, appendFile, readdir, rm } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ export function logsDir() {
6
+ return join(homedir(), '.config', 'dsh-decision-layer', 'logs');
7
+ }
8
+
9
+ const dayStamp = date => date.toISOString().slice(0, 10);
10
+
11
+ // Batched, rolling JSONL log writer keyed by the running instance's port. One
12
+ // file per UTC day: `decisions-<port>-YYYY-MM-DD.jsonl`. Entries are buffered and
13
+ // flushed on an interval or when the buffer fills; every flush prunes this port's
14
+ // files older than the retention window. Writing is best-effort: a failed flush
15
+ // drops that batch rather than throwing into the decision path.
16
+ export function createLogStore({ port, dir = logsDir(), flushIntervalMs = 5000,
17
+ maxBuffer = 200, retentionDays = 30, now = () => new Date() } = {}) {
18
+ const safePort = Number.isInteger(port) && port > 0 && port < 65536 ? port : 0;
19
+ const prefix = `decisions-${safePort}-`;
20
+ let buffer = [];
21
+ let timer;
22
+ let flushing = false;
23
+
24
+ const fileFor = date => join(dir, `${prefix}${dayStamp(date)}.jsonl`);
25
+
26
+ const prune = async () => {
27
+ let names;
28
+ try { names = await readdir(dir); } catch { return; }
29
+ const cutoff = now().getTime() - retentionDays * 24 * 60 * 60 * 1000;
30
+ await Promise.all(names.map(async name => {
31
+ if (!name.startsWith(prefix) || !name.endsWith('.jsonl')) return;
32
+ const stamp = name.slice(prefix.length, -'.jsonl'.length);
33
+ const day = Date.parse(`${stamp}T00:00:00Z`);
34
+ if (Number.isFinite(day) && day < cutoff) await rm(join(dir, name), { force: true }).catch(() => {});
35
+ }));
36
+ };
37
+
38
+ const flush = async () => {
39
+ if (flushing || buffer.length === 0) return;
40
+ flushing = true;
41
+ const pending = buffer;
42
+ buffer = [];
43
+ try {
44
+ await mkdir(dir, { recursive: true, mode: 0o700 });
45
+ const byFile = new Map();
46
+ for (const entry of pending) {
47
+ const at = Number.isFinite(entry?.at) ? entry.at : now().getTime();
48
+ const file = fileFor(new Date(at));
49
+ (byFile.get(file) ?? byFile.set(file, []).get(file)).push(`${JSON.stringify(entry)}\n`);
50
+ }
51
+ for (const [file, lines] of byFile) await appendFile(file, lines.join(''), { mode: 0o600 });
52
+ await prune();
53
+ } catch { /* best effort; a dropped batch must never break the decision path */ }
54
+ finally { flushing = false; }
55
+ };
56
+
57
+ return {
58
+ append(entry) {
59
+ if (entry === null || typeof entry !== 'object') return;
60
+ buffer.push(entry);
61
+ if (buffer.length >= maxBuffer) { void flush(); return; }
62
+ if (!timer) timer = setTimeout(() => { timer = undefined; void flush(); }, flushIntervalMs);
63
+ },
64
+ flush,
65
+ dispose() {
66
+ if (timer) { clearTimeout(timer); timer = undefined; }
67
+ return flush();
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,52 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const MAX_FINGERPRINT_INPUT = 16 * 1024;
4
+
5
+ export function fingerprintCall(exec) {
6
+ const name = typeof exec?.name === 'string' ? exec.name : '';
7
+ let args = '';
8
+ try { args = JSON.stringify(exec?.arguments ?? null); } catch { args = ''; }
9
+ return createHash('sha256').update(name).update('\0').update(args.slice(0, MAX_FINGERPRINT_INPUT)).digest('hex');
10
+ }
11
+
12
+ // Deterministic, backend-free loop guard: it denies only a run of the SAME tool
13
+ // call repeated consecutively past a threshold. Any different intervening call
14
+ // resets the run, so a legitimate identical call reached again later is allowed.
15
+ export function createLoopGuard({ threshold = 3, maxSessions = 1024, sessions } = {}) {
16
+ if (!Number.isSafeInteger(threshold) || threshold < 2) throw new Error('Invalid loop threshold');
17
+ const runs = new Map();
18
+ const runFor = id => {
19
+ let run = runs.get(id);
20
+ if (!run) {
21
+ if (runs.size >= maxSessions) runs.delete(runs.keys().next().value);
22
+ run = { key: undefined, count: 0 };
23
+ runs.set(id, run);
24
+ }
25
+ return run;
26
+ };
27
+ return {
28
+ check(exec) {
29
+ const id = exec?.agent?.session?.id;
30
+ if (typeof id !== 'string') return undefined;
31
+ // The master switch pauses automatic intervention (mirrors the danger gate);
32
+ // capacity overflow still guards, since that is involuntary, not a user choice.
33
+ if (sessions) {
34
+ try {
35
+ const snapshot = sessions.snapshot(id);
36
+ if (snapshot.enabled === false && !snapshot.capacityExceeded) return undefined;
37
+ } catch { /* fall through and guard */ }
38
+ }
39
+ const run = runFor(id);
40
+ const key = fingerprintCall(exec);
41
+ run.count = key === run.key ? run.count + 1 : 1;
42
+ run.key = key;
43
+ if (run.count >= threshold) {
44
+ return `Identical tool call repeated ${run.count} times in a row; blocked to break a loop`;
45
+ }
46
+ return undefined;
47
+ },
48
+ runLength(id) {
49
+ return runs.get(id)?.count ?? 0;
50
+ },
51
+ };
52
+ }
@@ -0,0 +1,87 @@
1
+ import { loadConfig, resolveConfig, saveConfig } from './config-store.mjs';
2
+
3
+ export const API_PREFIX = '/plugins/dsh-decision-layer/api';
4
+ const BODY_LIMIT = 32 * 1024;
5
+
6
+ function respond(res, status, value) {
7
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store',
8
+ 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer' });
9
+ res.end(JSON.stringify(value));
10
+ }
11
+
12
+ function authorizedBrowser(req) {
13
+ const address = req.socket?.remoteAddress;
14
+ if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) return false;
15
+ const host = req.headers.host;
16
+ if (typeof host !== 'string' || !/^(localhost|127\.0\.0\.1|\[::1\]):\d+$/.test(host)) return false;
17
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false;
18
+ const origin = req.headers.origin;
19
+ if (origin === undefined) return req.method === 'GET';
20
+ try {
21
+ const parsed = new URL(origin);
22
+ return ['http:', 'https:'].includes(parsed.protocol) && parsed.host === host;
23
+ } catch { return false; }
24
+ }
25
+
26
+ async function readBody(req) {
27
+ if (!req.headers['content-type']?.startsWith('application/json')) throw new Error('Expected JSON');
28
+ let size = 0;
29
+ const chunks = [];
30
+ for await (const chunk of req) {
31
+ size += chunk.length;
32
+ if (size > BODY_LIMIT) throw new Error('Request too large');
33
+ chunks.push(chunk);
34
+ }
35
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
36
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected object');
37
+ return value;
38
+ }
39
+
40
+ export function createManagerRoutes({ path, sessions, backend, env = process.env, bindHost = '127.0.0.1' }) {
41
+ const route = (suffix, methods) => ({ kind: 'exact', path: `${API_PREFIX}/${suffix}`, handler: async (req, res) => {
42
+ if (bindHost !== '127.0.0.1' || !authorizedBrowser(req)) return respond(res, 403, { ok: false, error: 'Local same-origin browser request required' });
43
+ if (!Object.hasOwn(methods, req.method)) return respond(res, 405, { ok: false, error: 'Method not allowed' });
44
+ try {
45
+ const value = await methods[req.method](req);
46
+ return respond(res, 200, { ok: true, value });
47
+ } catch (error) {
48
+ if (error?.code === 'BAD_REQUEST') return respond(res, 400, { ok: false, error: error.message });
49
+ return respond(res, 400, { ok: false, error: 'Invalid request or backend unavailable' });
50
+ }
51
+ } });
52
+ const sessionId = req => {
53
+ const id = new URL(req.url ?? '/', 'http://localhost').searchParams.get('sessionId');
54
+ if (!id) { const error = new Error('sessionId is required'); error.code = 'BAD_REQUEST'; throw error; }
55
+ return id;
56
+ };
57
+ return [
58
+ route('config', {
59
+ GET: async () => ({ ...(await loadConfig(path)), effectiveUrl: (await resolveConfig({ path, env })).url }),
60
+ PUT: async req => {
61
+ const saved = await saveConfig(await readBody(req), path);
62
+ return { ...saved, effectiveUrl: (await resolveConfig({ path, env })).url };
63
+ },
64
+ }),
65
+ route('probe', {
66
+ POST: async () => {
67
+ const effectiveUrl = (await resolveConfig({ path, env })).url;
68
+ try {
69
+ const result = await backend.evaluate({ state: 'Connection test', questions: {
70
+ connected: { type: 'noul', instructions: 'Is this a connection test?' },
71
+ } });
72
+ return { connected: true, model: result.model, usage: result.usage, effectiveUrl };
73
+ } catch { return { connected: false, reason: 'unavailable', effectiveUrl }; }
74
+ },
75
+ }),
76
+ route('metrics', { GET: req => sessions.snapshot(sessionId(req)) }),
77
+ route('log', { GET: req => ({ entries: sessions.snapshot(sessionId(req)).log ?? [] }) }),
78
+ route('session', {
79
+ GET: req => sessions.snapshot(sessionId(req)),
80
+ PUT: async req => {
81
+ const body = await readBody(req);
82
+ sessions.setEnabled(body.sessionId, body.enabled);
83
+ return sessions.snapshot(body.sessionId);
84
+ },
85
+ }),
86
+ ];
87
+ }
@@ -0,0 +1,149 @@
1
+ const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
2
+ const probability = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
3
+ const MAX_STATE = 8 * 1024;
4
+
5
+ // Core tools an agent almost always needs. They are never judged for relevance
6
+ // and never dropped, so narrowing cannot strip the agent's fundamental
7
+ // capabilities. Beyond the filesystem/shell/search/todo basics, this includes
8
+ // the DSH agent meta-abilities (ask the user, goals, skills, delivery, images,
9
+ // background jobs, teammates, subagents): their relevance rarely shows in a
10
+ // single turn's task text, so per-tool judging would keep pruning them.
11
+ export const DEFAULT_CORE_TOOLS = Object.freeze([
12
+ 'read', 'write', 'edit', 'bash', 'glob', 'grep', 'ls', 'todo_write',
13
+ 'ask_user_question', 'create_goal', 'update_goal', 'get_goal', 'exit_plan_mode',
14
+ 'skill', 'present', 'read_image', 'workflow',
15
+ 'job_list', 'job_output', 'job_kill',
16
+ 'spawn_teammate', 'interrupt_agent', 'list_agents', 'wait_agent', 'send_message',
17
+ 'subagent', 'subagent_fork',
18
+ ]);
19
+
20
+ // Tool-name prefixes that are always kept and never judged, on top of the core
21
+ // tools. Memory/context tools like OpenViking are "keep resident, trigger on
22
+ // demand": their relevance rarely shows in the literal task text, so per-tool
23
+ // relevance judging would keep pruning them. `team_task_` covers the shared
24
+ // task-board tools as a group. Users can override this list.
25
+ export const DEFAULT_KEEP_PREFIXES = Object.freeze(['mcp__openviking', 'team_task_']);
26
+
27
+ // Intersect the host-allowed tool set with the model's relevance verdict. Only
28
+ // removes tools; never adds. Any missing or invalid noul keeps the tool, and a
29
+ // verdict that would strip everything is skipped so the agent is never disabled.
30
+ // Drop threshold: a tool is dropped only when the model is fairly confident it
31
+ // is not needed (noul < threshold). Default 0.3 is deliberately conservative —
32
+ // a noul near 0.5 means the model is unsure, and dropping there is the most
33
+ // likely to strip a still-useful tool, so uncertain tools are kept.
34
+ export function narrowTools(allowed, result, { threshold = 0.3 } = {}) {
35
+ const tools = Array.isArray(allowed) ? allowed.filter(name => typeof name === 'string') : [];
36
+ const answers = isObject(result?.answers) ? result.answers : undefined;
37
+ if (tools.length === 0 || !answers) return { applied: false, keep: [...tools], drop: [] };
38
+
39
+ const keep = [];
40
+ const drop = [];
41
+ for (const name of tools) {
42
+ const answer = answers[name];
43
+ if (isObject(answer) && answer.type === 'noul' && probability(answer.noul) && answer.noul < threshold) drop.push(name);
44
+ else keep.push(name);
45
+ }
46
+ if (drop.length === 0 || keep.length === 0) return { applied: false, keep: [...tools], drop: [] };
47
+ return { applied: true, keep, drop };
48
+ }
49
+
50
+ // Build one Noul relevance question per candidate tool. The state carries the
51
+ // current task; each question references the tool being judged. Noul answers
52
+ // have no confidence field, so relevance is read straight from the probability.
53
+ export function relevanceQuestions(state, tools) {
54
+ const task = typeof state === 'string' ? state.slice(0, MAX_STATE)
55
+ : isObject(state) ? state : '';
56
+ return {
57
+ state: task,
58
+ questions: Object.fromEntries(tools.map(tool => [tool.name, {
59
+ type: 'noul',
60
+ instructions: {
61
+ tool: { name: tool.name, description: typeof tool.description === 'string' ? tool.description.slice(0, 512) : '' },
62
+ question: '完成当前任务是否会用到 `tool` 描述的这个工具?',
63
+ },
64
+ }])),
65
+ };
66
+ }
67
+
68
+ // Orchestrates one relevance pass over the host-allowed tools. Pure of DSH
69
+ // runtime types: the caller supplies the candidate tools and the task state,
70
+ // and owns applying the restriction (only enforce mode should act on `drop`).
71
+ // `settings` resolves { mode: 'observe' | 'enforce', threshold } live.
72
+ //
73
+ // Guardrails against over-narrowing a large tool surface:
74
+ // A. core tools are always kept and never judged (DEFAULT_CORE_TOOLS);
75
+ // B. when the judged (optional) candidate count exceeds `maxCandidates`,
76
+ // enforce is downgraded to observe so a large surface cannot be mass-pruned;
77
+ // C. if narrowing would leave fewer than `minKeep` tools total, the verdict is
78
+ // treated as untrustworthy and no restriction is applied.
79
+ export function createNarrowing({ evaluate, sessions, settings, coreTools = DEFAULT_CORE_TOOLS,
80
+ keepPrefixes = DEFAULT_KEEP_PREFIXES, minCandidates = 2, maxCandidates = 20, minKeep = 5 } = {}) {
81
+ if (typeof evaluate !== 'function') throw new Error('Narrowing requires an evaluator');
82
+ const core = new Set(Array.isArray(coreTools) ? coreTools.filter(name => typeof name === 'string') : []);
83
+ const defaultPrefixes = Array.isArray(keepPrefixes) ? keepPrefixes.filter(prefix => typeof prefix === 'string' && prefix) : [];
84
+ const resolveSettings = async () => {
85
+ let raw = {};
86
+ try { raw = (typeof settings === 'function' ? await settings() : settings) ?? {}; } catch { raw = {}; }
87
+ const mode = raw?.mode === 'observe' ? 'observe' : 'enforce';
88
+ const threshold = typeof raw?.threshold === 'number' && Number.isFinite(raw.threshold) && raw.threshold >= 0 && raw.threshold <= 1 ? raw.threshold : 0.3;
89
+ // An explicit array in settings (including empty) overrides the default set.
90
+ const prefixes = Array.isArray(raw?.keepPrefixes)
91
+ ? raw.keepPrefixes.filter(prefix => typeof prefix === 'string' && prefix)
92
+ : defaultPrefixes;
93
+ // A configured optional-candidate ceiling overrides the constructor default.
94
+ const cap = Number.isSafeInteger(raw?.maxCandidates) && raw.maxCandidates >= 1 ? raw.maxCandidates : maxCandidates;
95
+ return { mode, threshold, prefixes, cap };
96
+ };
97
+ const record = (turn, outcome, detail = {}) => {
98
+ const id = turn?.agent?.session?.id;
99
+ if (!sessions || typeof id !== 'string') return;
100
+ try { if (typeof sessions.recordNarrow === 'function') sessions.recordNarrow(id, outcome, detail.dropped ?? 0); }
101
+ catch { /* telemetry is best effort */ }
102
+ if (typeof sessions.log !== 'function') return;
103
+ try { sessions.log(id, { kind: 'narrow', outcome, ...detail }); }
104
+ catch { /* logging is best effort */ }
105
+ };
106
+ const skip = { evaluated: false, applied: false, keep: [], drop: [], mode: 'observe' };
107
+ return {
108
+ async review(turn) {
109
+ const tools = Array.isArray(turn?.tools) ? turn.tools.filter(tool => tool && typeof tool.name === 'string') : [];
110
+ const names = tools.map(tool => tool.name);
111
+ const { mode, threshold, prefixes, cap } = await resolveSettings();
112
+ // A: core tools and keep-prefix matches are always kept and never sent to
113
+ // the model for judging (e.g. mcp__openviking memory tools stay resident).
114
+ const alwaysKeep = name => core.has(name) || prefixes.some(prefix => name.startsWith(prefix));
115
+ const keptNames = names.filter(alwaysKeep);
116
+ const optional = tools.filter(tool => !alwaysKeep(tool.name));
117
+ const optionalNames = optional.map(tool => tool.name);
118
+ if (optional.length < minCandidates) return { ...skip, keep: names };
119
+ const hasState = typeof turn?.state === 'string' ? turn.state.trim().length > 0
120
+ : isObject(turn?.state) ? Object.keys(turn.state).length > 0 : false;
121
+ if (!hasState) return { ...skip, keep: names };
122
+ const id = turn?.agent?.session?.id;
123
+ if (sessions && typeof id === 'string') {
124
+ try { if (sessions.snapshot(id).enabled === false) return { ...skip, keep: names }; }
125
+ catch { return { ...skip, keep: names }; }
126
+ }
127
+ // B: a large optional surface must not be enforced — only observed.
128
+ const overCap = optional.length > cap;
129
+ const effectiveMode = overCap ? 'observe' : mode;
130
+ let result;
131
+ try { result = await evaluate(relevanceQuestions(turn.state, optional), { signal: turn.signal }); }
132
+ 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 });
134
+ const keep = [...keptNames, ...narrowed.keep];
135
+ const drop = narrowed.drop;
136
+ // C: too few tools left over is a sign the verdict over-pruned; do not apply.
137
+ const trustworthy = keep.length >= minKeep;
138
+ const applied = narrowed.applied && trustworthy && !overCap && mode === 'enforce';
139
+ const dropped = drop.length;
140
+ const detail = { dropped, kept: keep.length, mode: effectiveMode, tools: drop };
141
+ // The optional-candidate count (excludes core tools and keep-prefix matches),
142
+ // recorded so the "too many candidates" reason shows the number that tripped it.
143
+ if (overCap) { detail.reason = 'too-many-candidates'; detail.candidates = optional.length; }
144
+ else if (dropped > 0 && !trustworthy) detail.reason = 'low-keep';
145
+ record(turn, dropped > 0 ? 'applied' : 'ok', detail);
146
+ return { evaluated: true, applied, keep, drop, mode: effectiveMode, overCap, trustworthy };
147
+ },
148
+ };
149
+ }
@@ -0,0 +1,94 @@
1
+ const MAX_OUTPUT = 24 * 1024;
2
+
3
+ export const DEFAULT_RUBRIC = Object.freeze([
4
+ '明显答非所问,或存在严重遗漏 / 自相矛盾',
5
+ '基本答到问题,但有可察觉的遗漏或不严谨',
6
+ '完整、准确、无自相矛盾地回应了请求',
7
+ ]);
8
+
9
+ const STEER_TEXT = '本回合自检得分偏低:请复核是否答到了用户的问题、有无遗漏或自相矛盾,并在需要时补完。';
10
+
11
+ function boundedRubric(rubric) {
12
+ if (!Array.isArray(rubric)) return DEFAULT_RUBRIC;
13
+ const levels = rubric.filter(item => typeof item === 'string' && item.trim() && item.length <= 256).slice(0, 10);
14
+ return levels.length >= 2 ? levels : DEFAULT_RUBRIC;
15
+ }
16
+
17
+ export function scoreQuestion(output, rubric = DEFAULT_RUBRIC) {
18
+ return {
19
+ state: { answer: String(output).slice(0, MAX_OUTPUT) },
20
+ questions: { quality: {
21
+ type: 'score',
22
+ instructions: '按下列从低到高的档位,评估这次最终产出对用户请求的完成质量。',
23
+ criteria: boundedRubric(rubric),
24
+ } },
25
+ };
26
+ }
27
+
28
+ // `settings` is an async resolver so rubric/mode/threshold follow live config.
29
+ // `steer(agent, text)` performs the host-native steer; the caller owns building
30
+ // the framework UserMessage so this module stays free of DSH message types.
31
+ export function createSelfCheck({ evaluate, sessions, steer, settings, minConfidence = 0.4 } = {}) {
32
+ 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
+ };
38
+ const resolveSettings = async () => {
39
+ let raw = {};
40
+ try { raw = (typeof settings === 'function' ? await settings() : settings) ?? {}; } catch { raw = {}; }
41
+ const mode = raw?.mode === 'steer' ? 'steer' : 'observe';
42
+ const lowScoreThreshold = Number.isSafeInteger(raw?.lowScoreThreshold) && raw.lowScoreThreshold >= 0 ? raw.lowScoreThreshold : 1;
43
+ return { mode, rubric: boundedRubric(raw?.rubric), lowScoreThreshold };
44
+ };
45
+ const record = (turn, outcome, detail = {}) => {
46
+ const id = turn?.agent?.session?.id;
47
+ if (!sessions || typeof id !== 'string' || typeof sessions.recordCheck !== 'function') return;
48
+ try { sessions.recordCheck(id, outcome); } catch { /* telemetry is best effort */ }
49
+ if (typeof sessions.log !== 'function') return;
50
+ try { sessions.log(id, { kind: 'check', outcome, ...detail }); }
51
+ catch { /* logging is best effort */ }
52
+ };
53
+ return {
54
+ async review(turn) {
55
+ const output = typeof turn?.output === 'string' ? turn.output.trim() : '';
56
+ if (!output) return { evaluated: false, lowScore: false, steered: false };
57
+ const id = turn?.agent?.session?.id;
58
+ if (sessions && typeof id === 'string') {
59
+ try { if (sessions.snapshot(id).enabled === false) return { evaluated: false, lowScore: false, steered: false }; }
60
+ catch { return { evaluated: false, lowScore: false, steered: false }; }
61
+ }
62
+ const { mode, rubric, lowScoreThreshold } = await resolveSettings();
63
+ let answer;
64
+ try {
65
+ const result = await evaluate(scoreQuestion(output, rubric), { signal: turn.signal });
66
+ answer = result?.answers?.quality;
67
+ } catch (error) { record(turn, 'error', { reason: typeof error?.reason === 'string' ? error.reason : 'unreachable' }); return { evaluated: false, lowScore: false, steered: false }; }
68
+ if (!answer || answer.type !== 'score' || typeof answer.score !== 'number' || !Number.isFinite(answer.score)) {
69
+ record(turn, 'error', { reason: 'invalid-response' });
70
+ return { evaluated: false, lowScore: false, steered: false };
71
+ }
72
+ // A low-confidence Score is a legitimate answer, not a failure: the score
73
+ // stays usable and is recorded as ok/low. Confidence only gates the steer,
74
+ // because only interrupting the user warrants the model being sure.
75
+ const confidence = typeof answer.confidence === 'number' && Number.isFinite(answer.confidence) ? answer.confidence : undefined;
76
+ const detail = confidence === undefined ? {} : { confidence };
77
+ const lowScore = answer.score <= lowScoreThreshold;
78
+ record(turn, lowScore ? 'low' : 'ok', { score: answer.score, ...detail });
79
+ if (!lowScore) return { evaluated: true, lowScore: false, steered: false, score: answer.score };
80
+ const confident = confidence !== undefined && confidence >= minConfidence;
81
+ if (mode === 'steer' && confident && typeof steer === 'function' && turn?.agent) {
82
+ const key = steerKey(turn);
83
+ if (key !== undefined && !steeredTurns.has(key)) {
84
+ steeredTurns.add(key);
85
+ if (steeredTurns.size > 4096) steeredTurns.delete(steeredTurns.values().next().value);
86
+ try { steer(turn.agent, STEER_TEXT); }
87
+ catch { return { evaluated: true, lowScore: true, steered: false, score: answer.score }; }
88
+ return { evaluated: true, lowScore: true, steered: true, score: answer.score };
89
+ }
90
+ }
91
+ return { evaluated: true, lowScore: true, steered: false, score: answer.score };
92
+ },
93
+ };
94
+ }