dsh-decision-layer 0.7.1 → 0.7.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 +14 -14
- package/README.md +16 -16
- package/docs/en/DETAIL.md +2 -2
- package/docs/zh/DETAIL.md +2 -2
- package/lib/client.js +54 -23
- package/lib/host/backend.d.ts +15 -0
- package/lib/host/backend.js +149 -0
- package/lib/host/config-store.d.ts +12 -0
- package/lib/host/config-store.js +254 -0
- package/lib/host/danger-gate.d.ts +45 -0
- package/lib/host/danger-gate.js +196 -0
- package/lib/host/decision-context.d.ts +21 -0
- package/lib/host/decision-context.js +187 -0
- package/lib/host/excerpt.d.ts +26 -0
- package/lib/host/excerpt.js +76 -0
- package/lib/host/index.d.ts +6 -0
- package/lib/host/index.js +287 -0
- package/lib/host/log-analyze.d.ts +121 -0
- package/lib/host/log-analyze.js +254 -0
- package/lib/host/log-read.d.ts +4 -0
- package/lib/host/log-read.js +34 -0
- package/lib/host/log-store.d.ts +15 -0
- package/lib/host/log-store.js +98 -0
- package/lib/host/loop-guard.d.ts +10 -0
- package/lib/host/loop-guard.js +52 -0
- package/lib/host/manager-api.d.ts +20 -0
- package/lib/host/manager-api.js +174 -0
- package/lib/host/narrowing.d.ts +51 -0
- package/lib/host/narrowing.js +214 -0
- package/lib/host/quality-trend.d.ts +14 -0
- package/lib/host/quality-trend.js +22 -0
- package/lib/host/self-check.d.ts +28 -0
- package/lib/host/self-check.js +111 -0
- package/lib/host/session-state.d.ts +19 -0
- package/lib/host/session-state.js +289 -0
- package/lib/host/state-store.d.ts +14 -0
- package/lib/host/state-store.js +109 -0
- package/lib/host/steer-once.d.ts +13 -0
- package/lib/host/steer-once.js +25 -0
- package/lib/host/task-completion.d.ts +30 -0
- package/lib/host/task-completion.js +166 -0
- package/lib/host/types.d.ts +365 -0
- package/lib/host/types.js +11 -0
- package/package.json +17 -10
- package/service/.gitkeep +0 -0
- package/service/backend.mjs +0 -127
- package/service/config-store.mjs +0 -311
- package/service/danger-gate.mjs +0 -182
- package/service/dsh.mjs +0 -288
- package/service/log-analyze.mjs +0 -257
- package/service/log-read.mjs +0 -27
- package/service/log-store.mjs +0 -82
- package/service/loop-guard.mjs +0 -51
- package/service/manager-api.mjs +0 -137
- package/service/narrowing.mjs +0 -226
- package/service/quality-trend.mjs +0 -29
- package/service/self-check.mjs +0 -131
- package/service/session-state.mjs +0 -260
- package/service/state-store.mjs +0 -100
- package/service/steer-once.mjs +0 -25
- package/service/task-completion.mjs +0 -163
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { loadConfig, resolveConfig, saveConfig } from "./config-store.js";
|
|
2
|
+
import { isObject } from "./types.js";
|
|
3
|
+
const API_PREFIX = "/plugins/dsh-decision-layer/api";
|
|
4
|
+
const BODY_LIMIT = 32 * 1024;
|
|
5
|
+
const codedError = (message, code) => Object.assign(new Error(message), { code });
|
|
6
|
+
function respond(res, status, value) {
|
|
7
|
+
res.writeHead(status, {
|
|
8
|
+
"content-type": "application/json; charset=utf-8",
|
|
9
|
+
"cache-control": "no-store",
|
|
10
|
+
"x-content-type-options": "nosniff",
|
|
11
|
+
"referrer-policy": "no-referrer"
|
|
12
|
+
});
|
|
13
|
+
res.end(JSON.stringify(value));
|
|
14
|
+
}
|
|
15
|
+
function authorizedBrowser(req) {
|
|
16
|
+
const address = req.socket?.remoteAddress;
|
|
17
|
+
if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(address ?? "")) return false;
|
|
18
|
+
const host = req.headers.host;
|
|
19
|
+
if (typeof host !== "string" || !/^(localhost|127\.0\.0\.1|\[::1\]):\d+$/.test(host)) return false;
|
|
20
|
+
if (req.headers["sec-fetch-site"] === "cross-site") return false;
|
|
21
|
+
const origin = req.headers.origin;
|
|
22
|
+
if (origin === void 0) return req.method === "GET";
|
|
23
|
+
if (typeof origin !== "string") return false;
|
|
24
|
+
try {
|
|
25
|
+
const parsed = new URL(origin);
|
|
26
|
+
return ["http:", "https:"].includes(parsed.protocol) && parsed.host === host;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function readBody(req) {
|
|
32
|
+
const contentType = req.headers["content-type"];
|
|
33
|
+
if (typeof contentType !== "string" || !contentType.startsWith("application/json")) throw new Error("Expected JSON");
|
|
34
|
+
let size = 0;
|
|
35
|
+
const chunks = [];
|
|
36
|
+
for await (const chunk of req) {
|
|
37
|
+
size += chunk.length;
|
|
38
|
+
if (size > BODY_LIMIT) throw new Error("Request too large");
|
|
39
|
+
chunks.push(chunk);
|
|
40
|
+
}
|
|
41
|
+
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
42
|
+
if (!isObject(value)) throw new Error("Expected object");
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function createManagerRoutes({ path, sessions, backend, logStore, analyze, listRows, env = process.env, bindHost = "127.0.0.1" }) {
|
|
46
|
+
const route = (suffix, methods) => ({ kind: "exact", path: `${API_PREFIX}/${suffix}`, handler: async (req, res) => {
|
|
47
|
+
if (bindHost !== "127.0.0.1" || !authorizedBrowser(req)) return respond(res, 403, { ok: false, error: "Local same-origin browser request required" });
|
|
48
|
+
const method = req.method ?? "";
|
|
49
|
+
if (!Object.hasOwn(methods, method)) return respond(res, 405, { ok: false, error: "Method not allowed" });
|
|
50
|
+
const handler = methods[method];
|
|
51
|
+
if (typeof handler !== "function") return respond(res, 405, { ok: false, error: "Method not allowed" });
|
|
52
|
+
try {
|
|
53
|
+
const value = await handler(req);
|
|
54
|
+
return respond(res, 200, { ok: true, value });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
const coded = error;
|
|
57
|
+
if (coded?.code === "INTERNAL") return respond(res, 500, { ok: false, error: "Internal error" });
|
|
58
|
+
if (coded?.code === "BAD_REQUEST") return respond(res, 400, { ok: false, error: coded.message });
|
|
59
|
+
return respond(res, 400, { ok: false, error: "Invalid request or backend unavailable" });
|
|
60
|
+
}
|
|
61
|
+
} });
|
|
62
|
+
const sessionId = (req) => {
|
|
63
|
+
const id = new URL(req.url ?? "/", "http://localhost").searchParams.get("sessionId");
|
|
64
|
+
if (!id) throw codedError("sessionId is required", "BAD_REQUEST");
|
|
65
|
+
return id;
|
|
66
|
+
};
|
|
67
|
+
return [
|
|
68
|
+
route("config", {
|
|
69
|
+
GET: async () => ({ ...await loadConfig(path), effectiveUrl: (await resolveConfig({ path, env })).url }),
|
|
70
|
+
PUT: async (req) => {
|
|
71
|
+
const saved = await saveConfig(await readBody(req), path);
|
|
72
|
+
return { ...saved, effectiveUrl: (await resolveConfig({ path, env })).url };
|
|
73
|
+
}
|
|
74
|
+
}),
|
|
75
|
+
route("probe", {
|
|
76
|
+
POST: async () => {
|
|
77
|
+
const effectiveUrl = (await resolveConfig({ path, env })).url;
|
|
78
|
+
try {
|
|
79
|
+
const result = await backend.evaluate({ state: "Connection test", questions: {
|
|
80
|
+
connected: { type: "noul", instructions: "Is this a connection test?" }
|
|
81
|
+
} });
|
|
82
|
+
return { connected: true, model: result.model, usage: result.usage, effectiveUrl };
|
|
83
|
+
} catch {
|
|
84
|
+
return { connected: false, reason: "unavailable", effectiveUrl };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}),
|
|
88
|
+
route("metrics", { GET: async (req) => {
|
|
89
|
+
const id = sessionId(req);
|
|
90
|
+
await sessions.ensureLoaded?.(id);
|
|
91
|
+
return sessions.snapshot(id);
|
|
92
|
+
} }),
|
|
93
|
+
route("log", { GET: async (req) => {
|
|
94
|
+
const id = sessionId(req);
|
|
95
|
+
await sessions.ensureLoaded?.(id);
|
|
96
|
+
return { entries: sessions.snapshot(id).log ?? [] };
|
|
97
|
+
} }),
|
|
98
|
+
// Read-only analysis over the persisted decision logs for one session. Returns
|
|
99
|
+
// only aggregates and annotation counts — never raw log rows — so gate command
|
|
100
|
+
// / path fragments are not echoed back to the browser.
|
|
101
|
+
route("logs", {
|
|
102
|
+
GET: async (req) => {
|
|
103
|
+
if (typeof analyze !== "function") throw codedError("analysis unavailable", "BAD_REQUEST");
|
|
104
|
+
const id = sessionId(req);
|
|
105
|
+
try {
|
|
106
|
+
return await analyze(id);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error?.code === "BAD_REQUEST") throw error;
|
|
109
|
+
throw codedError("analysis failed", "INTERNAL");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}),
|
|
113
|
+
// One newest-first page of this session's full persisted decision rows, for
|
|
114
|
+
// the analysis tab's paginated log. Rows carry the same panel fields as /log,
|
|
115
|
+
// including the gate's already-redacted and length-bounded command/path, so
|
|
116
|
+
// this exposes nothing the recent-rows route does not already.
|
|
117
|
+
route("logrows", {
|
|
118
|
+
GET: async (req) => {
|
|
119
|
+
if (typeof listRows !== "function") throw codedError("log rows unavailable", "BAD_REQUEST");
|
|
120
|
+
const id = sessionId(req);
|
|
121
|
+
const params = new URL(req.url ?? "/", "http://localhost").searchParams;
|
|
122
|
+
const page = Number.parseInt(params.get("page") ?? "1", 10);
|
|
123
|
+
const pageSize = Number.parseInt(params.get("pageSize") ?? "100", 10);
|
|
124
|
+
try {
|
|
125
|
+
return await listRows(id, { page: Number.isNaN(page) ? 1 : page, pageSize: Number.isNaN(pageSize) ? 100 : pageSize });
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error?.code === "BAD_REQUEST") throw error;
|
|
128
|
+
throw codedError("log rows failed", "INTERNAL");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}),
|
|
132
|
+
// Append one annotation event for a decision. Explicit user intent: validated
|
|
133
|
+
// and written immediately; a write failure surfaces as an error, not silence.
|
|
134
|
+
route("annotate", {
|
|
135
|
+
POST: async (req) => {
|
|
136
|
+
if (!logStore || typeof logStore.appendAnnotation !== "function") throw codedError("annotation unavailable", "BAD_REQUEST");
|
|
137
|
+
const body = await readBody(req);
|
|
138
|
+
const target = typeof body.target === "string" ? body.target.trim() : "";
|
|
139
|
+
const rating = body.rating;
|
|
140
|
+
const session = typeof body.sessionId === "string" ? body.sessionId.trim() : "";
|
|
141
|
+
if (!target || target.length > 128 || !["good", "bad", "unsure"].includes(rating) || !session || session.length > 512) {
|
|
142
|
+
throw codedError("Invalid annotation", "BAD_REQUEST");
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
await logStore.appendAnnotation({ kind: "annotation", target, rating, sessionId: session });
|
|
146
|
+
} catch {
|
|
147
|
+
throw codedError("annotation write failed", "INTERNAL");
|
|
148
|
+
}
|
|
149
|
+
return { ok: true, target, rating };
|
|
150
|
+
}
|
|
151
|
+
}),
|
|
152
|
+
route("session", {
|
|
153
|
+
GET: async (req) => {
|
|
154
|
+
const id = sessionId(req);
|
|
155
|
+
await sessions.ensureLoaded?.(id);
|
|
156
|
+
return sessions.snapshot(id);
|
|
157
|
+
},
|
|
158
|
+
PUT: async (req) => {
|
|
159
|
+
const body = await readBody(req);
|
|
160
|
+
const id = body.sessionId;
|
|
161
|
+
const enabled = body.enabled;
|
|
162
|
+
if (typeof id !== "string" || typeof enabled !== "boolean") throw codedError("Invalid session switch", "BAD_REQUEST");
|
|
163
|
+
await sessions.ensureLoaded?.(id);
|
|
164
|
+
if (typeof sessions.setEnabled !== "function") throw codedError("session switch unavailable", "BAD_REQUEST");
|
|
165
|
+
sessions.setEnabled(id, enabled);
|
|
166
|
+
return sessions.snapshot(id);
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
export {
|
|
172
|
+
API_PREFIX,
|
|
173
|
+
createManagerRoutes
|
|
174
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { AgentLike, EvaluateInput, Evaluator, NarrowMode, NarrowSettings, SessionsFace, ToolCandidate } from './types.js';
|
|
2
|
+
export declare const narrowApplied: (record: {
|
|
3
|
+
applied?: unknown;
|
|
4
|
+
reason?: unknown;
|
|
5
|
+
outcome?: unknown;
|
|
6
|
+
} | undefined) => boolean;
|
|
7
|
+
export declare const DEFAULT_CORE_TOOLS: readonly string[];
|
|
8
|
+
export declare const DEFAULT_KEEP_PREFIXES: readonly string[];
|
|
9
|
+
export declare const DEFAULT_NARROW_THRESHOLD = 0.7;
|
|
10
|
+
export declare function narrowTools(allowed: unknown, result: unknown, { threshold, keepBase }?: {
|
|
11
|
+
threshold?: number;
|
|
12
|
+
keepBase?: number;
|
|
13
|
+
}): {
|
|
14
|
+
applied: boolean;
|
|
15
|
+
keep: string[];
|
|
16
|
+
drop: string[];
|
|
17
|
+
};
|
|
18
|
+
export declare function relevanceQuestions(state: unknown, tools: readonly ToolCandidate[]): EvaluateInput;
|
|
19
|
+
export type NarrowTurn = {
|
|
20
|
+
agent?: AgentLike;
|
|
21
|
+
turn?: number;
|
|
22
|
+
step?: number;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
state?: unknown;
|
|
25
|
+
/** Raw request text for the log row; `state` may now be a structured object. */
|
|
26
|
+
taskText?: unknown;
|
|
27
|
+
tools?: unknown;
|
|
28
|
+
};
|
|
29
|
+
export type NarrowResult = {
|
|
30
|
+
evaluated: boolean;
|
|
31
|
+
applied: boolean;
|
|
32
|
+
keep: string[];
|
|
33
|
+
drop: string[];
|
|
34
|
+
mode: NarrowMode;
|
|
35
|
+
overCap?: boolean;
|
|
36
|
+
trustworthy?: boolean;
|
|
37
|
+
};
|
|
38
|
+
export type NarrowingOptions = {
|
|
39
|
+
evaluate?: Evaluator;
|
|
40
|
+
sessions?: SessionsFace;
|
|
41
|
+
settings?: NarrowSettings | (() => Promise<NarrowSettings | undefined>);
|
|
42
|
+
coreTools?: readonly string[];
|
|
43
|
+
keepPrefixes?: readonly string[];
|
|
44
|
+
minCandidates?: number;
|
|
45
|
+
maxCandidates?: number;
|
|
46
|
+
minKeep?: number;
|
|
47
|
+
splitConcurrency?: number;
|
|
48
|
+
};
|
|
49
|
+
export declare function createNarrowing({ evaluate, sessions, settings, coreTools, keepPrefixes, minCandidates, maxCandidates, minKeep, splitConcurrency }?: NarrowingOptions): {
|
|
50
|
+
review(turn: NarrowTurn | undefined): Promise<NarrowResult>;
|
|
51
|
+
};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { taskExcerpt } from "./excerpt.js";
|
|
2
|
+
import { errorReason, isObject, isProbability } from "./types.js";
|
|
3
|
+
const narrowApplied = (record) => {
|
|
4
|
+
if (record?.applied === false) return false;
|
|
5
|
+
if (record?.reason === "low-keep" || record?.reason === "too-many-candidates") return false;
|
|
6
|
+
return record?.applied === true || record?.outcome === "applied";
|
|
7
|
+
};
|
|
8
|
+
const MAX_STATE = 8 * 1024;
|
|
9
|
+
const MAX_QUESTIONS = 64;
|
|
10
|
+
const DEFAULT_CORE_TOOLS = Object.freeze([
|
|
11
|
+
"read",
|
|
12
|
+
"write",
|
|
13
|
+
"edit",
|
|
14
|
+
"bash",
|
|
15
|
+
"glob",
|
|
16
|
+
"grep",
|
|
17
|
+
"ls",
|
|
18
|
+
"todo_write",
|
|
19
|
+
"ask_user_question",
|
|
20
|
+
"create_goal",
|
|
21
|
+
"update_goal",
|
|
22
|
+
"get_goal",
|
|
23
|
+
"exit_plan_mode",
|
|
24
|
+
"skill",
|
|
25
|
+
"present",
|
|
26
|
+
"read_image",
|
|
27
|
+
"workflow",
|
|
28
|
+
"job_list",
|
|
29
|
+
"job_output",
|
|
30
|
+
"job_kill",
|
|
31
|
+
"spawn_teammate",
|
|
32
|
+
"interrupt_agent",
|
|
33
|
+
"list_agents",
|
|
34
|
+
"wait_agent",
|
|
35
|
+
"send_message",
|
|
36
|
+
"subagent",
|
|
37
|
+
"subagent_fork"
|
|
38
|
+
]);
|
|
39
|
+
const DEFAULT_KEEP_PREFIXES = Object.freeze(["mcp__openviking"]);
|
|
40
|
+
const DEFAULT_NARROW_THRESHOLD = 0.7;
|
|
41
|
+
function narrowTools(allowed, result, { threshold = DEFAULT_NARROW_THRESHOLD, keepBase = 0 } = {}) {
|
|
42
|
+
const tools = Array.isArray(allowed) ? allowed.filter((name) => typeof name === "string") : [];
|
|
43
|
+
const answers = isObject(result) && isObject(result.answers) ? result.answers : void 0;
|
|
44
|
+
if (tools.length === 0 || !answers) return { applied: false, keep: [...tools], drop: [] };
|
|
45
|
+
const base = Number.isSafeInteger(keepBase) && keepBase > 0 ? keepBase : 0;
|
|
46
|
+
const keep = [];
|
|
47
|
+
const drop = [];
|
|
48
|
+
for (const name of tools) {
|
|
49
|
+
const answer = answers[name];
|
|
50
|
+
if (isObject(answer) && answer.type === "noul" && isProbability(answer.noul) && answer.noul < threshold) drop.push(name);
|
|
51
|
+
else keep.push(name);
|
|
52
|
+
}
|
|
53
|
+
if (drop.length === 0 || keep.length + base === 0) return { applied: false, keep: [...tools], drop: [] };
|
|
54
|
+
return { applied: true, keep, drop };
|
|
55
|
+
}
|
|
56
|
+
function relevanceQuestions(state, tools) {
|
|
57
|
+
const task = typeof state === "string" ? state.slice(0, MAX_STATE) : isObject(state) ? state : "";
|
|
58
|
+
return {
|
|
59
|
+
state: task,
|
|
60
|
+
questions: Object.fromEntries(tools.map((tool) => [tool.name, {
|
|
61
|
+
type: "noul",
|
|
62
|
+
instructions: {
|
|
63
|
+
tool: { name: tool.name, description: typeof tool.description === "string" ? tool.description.slice(0, 512) : "" },
|
|
64
|
+
question: "\u5B8C\u6210\u5F53\u524D\u4EFB\u52A1\u662F\u5426\u4F1A\u7528\u5230 `tool` \u63CF\u8FF0\u7684\u8FD9\u4E2A\u5DE5\u5177\uFF1F"
|
|
65
|
+
}
|
|
66
|
+
}]))
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function createNarrowing({
|
|
70
|
+
evaluate,
|
|
71
|
+
sessions,
|
|
72
|
+
settings,
|
|
73
|
+
coreTools = DEFAULT_CORE_TOOLS,
|
|
74
|
+
keepPrefixes = DEFAULT_KEEP_PREFIXES,
|
|
75
|
+
minCandidates = 2,
|
|
76
|
+
maxCandidates = 20,
|
|
77
|
+
minKeep = 5,
|
|
78
|
+
splitConcurrency = 8
|
|
79
|
+
} = {}) {
|
|
80
|
+
if (typeof evaluate !== "function") throw new Error("Narrowing requires an evaluator");
|
|
81
|
+
const core = new Set(Array.isArray(coreTools) ? coreTools.filter((name) => typeof name === "string") : []);
|
|
82
|
+
const defaultPrefixes = Array.isArray(keepPrefixes) ? keepPrefixes.filter((prefix) => typeof prefix === "string" && prefix) : [];
|
|
83
|
+
const concurrency = Number.isSafeInteger(splitConcurrency) && splitConcurrency >= 1 ? splitConcurrency : 8;
|
|
84
|
+
let adaptiveSplit = false;
|
|
85
|
+
const resolveSettings = async () => {
|
|
86
|
+
let raw = {};
|
|
87
|
+
try {
|
|
88
|
+
raw = (typeof settings === "function" ? await settings() : settings) ?? {};
|
|
89
|
+
} catch {
|
|
90
|
+
raw = {};
|
|
91
|
+
}
|
|
92
|
+
const mode = raw?.mode === "observe" ? "observe" : "enforce";
|
|
93
|
+
const threshold = typeof raw?.threshold === "number" && Number.isFinite(raw.threshold) && raw.threshold >= 0 && raw.threshold <= 1 ? raw.threshold : DEFAULT_NARROW_THRESHOLD;
|
|
94
|
+
const batchMode = raw?.batchMode === "single" || raw?.batchMode === "split" ? raw.batchMode : "auto";
|
|
95
|
+
const prefixes = Array.isArray(raw?.keepPrefixes) ? raw.keepPrefixes.filter((prefix) => typeof prefix === "string" && Boolean(prefix)) : defaultPrefixes;
|
|
96
|
+
const configuredCap = Number.isSafeInteger(raw?.maxCandidates) && raw.maxCandidates >= 1 ? raw.maxCandidates : maxCandidates;
|
|
97
|
+
const cap = Math.min(configuredCap, MAX_QUESTIONS);
|
|
98
|
+
return { mode, threshold, prefixes, cap, batchMode };
|
|
99
|
+
};
|
|
100
|
+
const record = (turn, outcome, detail = {}) => {
|
|
101
|
+
const id = turn?.agent?.session?.id;
|
|
102
|
+
if (!sessions || typeof id !== "string") return;
|
|
103
|
+
try {
|
|
104
|
+
sessions.recordNarrow?.(id, outcome, detail.dropped ?? 0, narrowApplied({ ...detail, outcome }));
|
|
105
|
+
} catch {
|
|
106
|
+
}
|
|
107
|
+
if (typeof sessions.log !== "function") return;
|
|
108
|
+
try {
|
|
109
|
+
const task = taskExcerpt(turn?.taskText ?? turn?.state);
|
|
110
|
+
const context = task ? { task } : {};
|
|
111
|
+
if (Number.isSafeInteger(turn?.turn) && turn?.turn >= 0) context.turn = turn?.turn;
|
|
112
|
+
if (Number.isSafeInteger(turn?.step) && turn?.step >= 0) context.step = turn?.step;
|
|
113
|
+
sessions.log(id, { kind: "narrow", outcome, ...context, ...detail });
|
|
114
|
+
} catch {
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const evaluateSplit = async (state, tools, signal) => {
|
|
118
|
+
const answers = /* @__PURE__ */ Object.create(null);
|
|
119
|
+
let anyOk = false;
|
|
120
|
+
for (let i = 0; i < tools.length; i += concurrency) {
|
|
121
|
+
const group = tools.slice(i, i + concurrency);
|
|
122
|
+
const settled = await Promise.all(group.map(async (tool) => {
|
|
123
|
+
try {
|
|
124
|
+
const reply = await evaluate(relevanceQuestions(state, [tool]), { signal });
|
|
125
|
+
const answer = reply?.answers?.[tool.name];
|
|
126
|
+
return isObject(answer) ? [tool.name, answer] : void 0;
|
|
127
|
+
} catch {
|
|
128
|
+
return void 0;
|
|
129
|
+
}
|
|
130
|
+
}));
|
|
131
|
+
for (const pair of settled) {
|
|
132
|
+
if (pair) {
|
|
133
|
+
answers[pair[0]] = pair[1];
|
|
134
|
+
anyOk = true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return anyOk ? { model: "split", answers } : null;
|
|
139
|
+
};
|
|
140
|
+
const skip = { evaluated: false, applied: false, keep: [], drop: [], mode: "observe" };
|
|
141
|
+
return {
|
|
142
|
+
async review(turn) {
|
|
143
|
+
const tools = Array.isArray(turn?.tools) ? turn.tools.filter((tool) => isObject(tool) && typeof tool.name === "string") : [];
|
|
144
|
+
const names = tools.map((tool) => tool.name);
|
|
145
|
+
const { mode, threshold, prefixes, cap, batchMode } = await resolveSettings();
|
|
146
|
+
const alwaysKeep = (name) => core.has(name) || prefixes.some((prefix) => name.startsWith(prefix));
|
|
147
|
+
const keptNames = names.filter(alwaysKeep);
|
|
148
|
+
const optional = tools.filter((tool) => !alwaysKeep(tool.name));
|
|
149
|
+
if (optional.length < minCandidates) return { ...skip, keep: names };
|
|
150
|
+
const hasState = typeof turn?.taskText === "string" ? turn.taskText.trim().length > 0 : typeof turn?.state === "string" ? turn.state.trim().length > 0 : isObject(turn?.state) ? Object.keys(turn.state).length > 0 : false;
|
|
151
|
+
if (!hasState) return { ...skip, keep: names };
|
|
152
|
+
const id = turn?.agent?.session?.id;
|
|
153
|
+
if (sessions && typeof id === "string") {
|
|
154
|
+
try {
|
|
155
|
+
if (sessions.snapshot(id).enabled === false) return { ...skip, keep: names };
|
|
156
|
+
} catch {
|
|
157
|
+
return { ...skip, keep: names };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const overCap = optional.length > cap;
|
|
161
|
+
const effectiveMode = overCap ? "observe" : mode;
|
|
162
|
+
const judged = overCap ? optional.slice(0, cap) : optional;
|
|
163
|
+
const judgedNames = judged.map((tool) => tool.name);
|
|
164
|
+
let result = null;
|
|
165
|
+
if (batchMode === "split" || adaptiveSplit) {
|
|
166
|
+
result = await evaluateSplit(turn?.state, judged, turn?.signal);
|
|
167
|
+
if (!result) {
|
|
168
|
+
record(turn, "error", { reason: "unreachable", mode: effectiveMode });
|
|
169
|
+
return { ...skip, keep: names, mode: effectiveMode };
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
try {
|
|
173
|
+
const reply = await evaluate(relevanceQuestions(turn?.state, judged), { signal: turn?.signal });
|
|
174
|
+
result = reply;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const reason = errorReason(error);
|
|
177
|
+
if (batchMode === "auto" && reason === "http-error") {
|
|
178
|
+
adaptiveSplit = true;
|
|
179
|
+
result = await evaluateSplit(turn?.state, judged, turn?.signal);
|
|
180
|
+
if (!result) {
|
|
181
|
+
record(turn, "error", { reason, mode: effectiveMode });
|
|
182
|
+
return { ...skip, keep: names, mode: effectiveMode };
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
record(turn, "error", { reason, mode: effectiveMode });
|
|
186
|
+
return { ...skip, keep: names, mode: effectiveMode };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const narrowed = narrowTools(judgedNames, result, { threshold, keepBase: keptNames.length });
|
|
191
|
+
const keep = [...keptNames, ...narrowed.keep];
|
|
192
|
+
const drop = narrowed.drop;
|
|
193
|
+
const trustworthy = keep.length >= minKeep;
|
|
194
|
+
const applied = narrowed.applied && trustworthy && !overCap && mode === "enforce";
|
|
195
|
+
const dropped = drop.length;
|
|
196
|
+
const detail = { dropped, kept: keep.length, mode: effectiveMode, tools: drop, applied };
|
|
197
|
+
if (overCap) {
|
|
198
|
+
detail.reason = "too-many-candidates";
|
|
199
|
+
detail.candidates = optional.length;
|
|
200
|
+
} else if (dropped > 0 && !trustworthy) detail.reason = "low-keep";
|
|
201
|
+
record(turn, dropped > 0 ? "applied" : "ok", detail);
|
|
202
|
+
return { evaluated: true, applied, keep, drop, mode: effectiveMode, overCap, trustworthy };
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
export {
|
|
207
|
+
DEFAULT_CORE_TOOLS,
|
|
208
|
+
DEFAULT_KEEP_PREFIXES,
|
|
209
|
+
DEFAULT_NARROW_THRESHOLD,
|
|
210
|
+
createNarrowing,
|
|
211
|
+
narrowApplied,
|
|
212
|
+
narrowTools,
|
|
213
|
+
relevanceQuestions
|
|
214
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Severity } from './types.js';
|
|
2
|
+
export declare const DEFAULT_TREND_MIN_CONFIDENCE = 0.6;
|
|
3
|
+
export declare const DEFAULT_TREND_RUN = 3;
|
|
4
|
+
export declare const DEFAULT_TREND_SEVERE_RUN = 5;
|
|
5
|
+
export type TrendSample = {
|
|
6
|
+
lowScore?: boolean;
|
|
7
|
+
confident?: boolean;
|
|
8
|
+
};
|
|
9
|
+
export type TrendThresholds = {
|
|
10
|
+
trendRun?: number;
|
|
11
|
+
trendSevereRun?: number;
|
|
12
|
+
};
|
|
13
|
+
export declare function advanceRun(run: number, sample: TrendSample | undefined): number;
|
|
14
|
+
export declare function severityOf(run: number, { trendRun, trendSevereRun }?: TrendThresholds): Severity;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const DEFAULT_TREND_MIN_CONFIDENCE = 0.6;
|
|
2
|
+
const DEFAULT_TREND_RUN = 3;
|
|
3
|
+
const DEFAULT_TREND_SEVERE_RUN = 5;
|
|
4
|
+
function advanceRun(run, sample) {
|
|
5
|
+
const current = Number.isSafeInteger(run) && run >= 0 ? run : 0;
|
|
6
|
+
if (!sample || sample.lowScore !== true) return 0;
|
|
7
|
+
if (sample.confident === true) return current + 1;
|
|
8
|
+
return current;
|
|
9
|
+
}
|
|
10
|
+
function severityOf(run, { trendRun = DEFAULT_TREND_RUN, trendSevereRun = DEFAULT_TREND_SEVERE_RUN } = {}) {
|
|
11
|
+
const value = Number.isSafeInteger(run) && run >= 0 ? run : 0;
|
|
12
|
+
if (value >= trendSevereRun) return "severe";
|
|
13
|
+
if (value >= trendRun) return "warn";
|
|
14
|
+
return "normal";
|
|
15
|
+
}
|
|
16
|
+
export {
|
|
17
|
+
DEFAULT_TREND_MIN_CONFIDENCE,
|
|
18
|
+
DEFAULT_TREND_RUN,
|
|
19
|
+
DEFAULT_TREND_SEVERE_RUN,
|
|
20
|
+
advanceRun,
|
|
21
|
+
severityOf
|
|
22
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AgentLike, CheckSettings, DecisionContext, EvaluateInput, Evaluator, SessionsFace } from './types.js';
|
|
2
|
+
export declare const DEFAULT_RUBRIC: readonly string[];
|
|
3
|
+
export declare function scoreQuestion(output: unknown, rubric?: readonly string[], context?: DecisionContext): EvaluateInput;
|
|
4
|
+
export declare const DEFAULT_MIN_CONFIDENCE = 0.4;
|
|
5
|
+
export type CheckTurn = {
|
|
6
|
+
agent?: AgentLike;
|
|
7
|
+
turn?: number;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
output?: unknown;
|
|
10
|
+
userText?: unknown;
|
|
11
|
+
context?: DecisionContext;
|
|
12
|
+
};
|
|
13
|
+
export type CheckResult = {
|
|
14
|
+
evaluated: boolean;
|
|
15
|
+
lowScore: boolean;
|
|
16
|
+
steered: boolean;
|
|
17
|
+
score?: number;
|
|
18
|
+
};
|
|
19
|
+
export type SelfCheckOptions = {
|
|
20
|
+
evaluate?: Evaluator;
|
|
21
|
+
sessions?: SessionsFace;
|
|
22
|
+
steer?: (agent: AgentLike, text: string) => unknown;
|
|
23
|
+
settings?: CheckSettings | (() => Promise<CheckSettings | undefined>);
|
|
24
|
+
minConfidence?: number;
|
|
25
|
+
};
|
|
26
|
+
export declare function createSelfCheck({ evaluate, sessions, steer, settings, minConfidence }?: SelfCheckOptions): {
|
|
27
|
+
review(turn: CheckTurn | undefined): Promise<CheckResult>;
|
|
28
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { taskExcerpt } from "./excerpt.js";
|
|
2
|
+
import { createSteerOnce } from "./steer-once.js";
|
|
3
|
+
import { errorReason, isProbability } from "./types.js";
|
|
4
|
+
const MAX_OUTPUT = 24 * 1024;
|
|
5
|
+
const DEFAULT_RUBRIC = Object.freeze([
|
|
6
|
+
"\u660E\u663E\u7B54\u975E\u6240\u95EE\uFF0C\u6216\u5B58\u5728\u4E25\u91CD\u9057\u6F0F / \u81EA\u76F8\u77DB\u76FE",
|
|
7
|
+
"\u57FA\u672C\u7B54\u5230\u95EE\u9898\uFF0C\u4F46\u6709\u53EF\u5BDF\u89C9\u7684\u9057\u6F0F\u6216\u4E0D\u4E25\u8C28",
|
|
8
|
+
"\u5B8C\u6574\u3001\u51C6\u786E\u3001\u65E0\u81EA\u76F8\u77DB\u76FE\u5730\u56DE\u5E94\u4E86\u8BF7\u6C42"
|
|
9
|
+
]);
|
|
10
|
+
const STEER_TEXT = "\u672C\u56DE\u5408\u81EA\u68C0\u5F97\u5206\u504F\u4F4E\uFF1A\u8BF7\u590D\u6838\u662F\u5426\u7B54\u5230\u4E86\u7528\u6237\u7684\u95EE\u9898\u3001\u6709\u65E0\u9057\u6F0F\u6216\u81EA\u76F8\u77DB\u76FE\uFF0C\u5E76\u5728\u9700\u8981\u65F6\u8865\u5B8C\u3002";
|
|
11
|
+
function boundedRubric(rubric) {
|
|
12
|
+
if (!Array.isArray(rubric)) return DEFAULT_RUBRIC;
|
|
13
|
+
const levels = rubric.filter((item) => typeof item === "string" && Boolean(item.trim()) && item.length <= 256).slice(0, 10);
|
|
14
|
+
return levels.length >= 2 ? levels : DEFAULT_RUBRIC;
|
|
15
|
+
}
|
|
16
|
+
function scoreQuestion(output, rubric = DEFAULT_RUBRIC, context) {
|
|
17
|
+
return {
|
|
18
|
+
state: {
|
|
19
|
+
// What the turn was asked to do. Without it the model scores an answer
|
|
20
|
+
// against nothing and cannot detect an off-topic reply.
|
|
21
|
+
...context?.request ? { request: context.request } : {},
|
|
22
|
+
...context?.background?.length ? { background: context.background } : {},
|
|
23
|
+
answer: String(output).slice(0, MAX_OUTPUT)
|
|
24
|
+
},
|
|
25
|
+
questions: { quality: {
|
|
26
|
+
type: "score",
|
|
27
|
+
instructions: "\u6309\u4E0B\u5217\u4ECE\u4F4E\u5230\u9AD8\u7684\u6863\u4F4D\uFF0C\u8BC4\u4F30\u8FD9\u6B21\u6700\u7EC8\u4EA7\u51FA\u5BF9\u7528\u6237\u8BF7\u6C42\u7684\u5B8C\u6210\u8D28\u91CF\u3002",
|
|
28
|
+
criteria: boundedRubric(rubric)
|
|
29
|
+
} }
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const DEFAULT_MIN_CONFIDENCE = 0.4;
|
|
33
|
+
function createSelfCheck({ evaluate, sessions, steer, settings, minConfidence = DEFAULT_MIN_CONFIDENCE } = {}) {
|
|
34
|
+
if (typeof evaluate !== "function") throw new Error("Self-check requires an evaluator");
|
|
35
|
+
const steerOnce = createSteerOnce();
|
|
36
|
+
const resolveSettings = async () => {
|
|
37
|
+
let raw = {};
|
|
38
|
+
try {
|
|
39
|
+
raw = (typeof settings === "function" ? await settings() : settings) ?? {};
|
|
40
|
+
} catch {
|
|
41
|
+
raw = {};
|
|
42
|
+
}
|
|
43
|
+
const mode = raw?.mode === "steer" ? "steer" : "observe";
|
|
44
|
+
const lowScoreThreshold = Number.isSafeInteger(raw?.lowScoreThreshold) && raw.lowScoreThreshold >= 0 ? raw.lowScoreThreshold : 1;
|
|
45
|
+
const confidenceFloor = isProbability(raw?.minConfidence) ? raw.minConfidence : minConfidence;
|
|
46
|
+
const trendMinConfidence = typeof raw?.trendMinConfidence === "number" && Number.isFinite(raw.trendMinConfidence) && raw.trendMinConfidence >= 0 && raw.trendMinConfidence <= 1 ? raw.trendMinConfidence : 0.6;
|
|
47
|
+
const trendRun = Number.isSafeInteger(raw?.trendRun) && raw.trendRun >= 2 ? raw.trendRun : 3;
|
|
48
|
+
const trendSevereRun = Number.isSafeInteger(raw?.trendSevereRun) && raw.trendSevereRun > trendRun ? raw.trendSevereRun : Math.max(trendRun + 1, 5);
|
|
49
|
+
return { mode, rubric: boundedRubric(raw?.rubric), lowScoreThreshold, minConfidence: confidenceFloor, trendMinConfidence, trendRun, trendSevereRun };
|
|
50
|
+
};
|
|
51
|
+
const record = (turn, outcome, detail = {}, trend) => {
|
|
52
|
+
const id = turn?.agent?.session?.id;
|
|
53
|
+
if (!sessions || typeof id !== "string" || typeof sessions.recordCheck !== "function") return;
|
|
54
|
+
try {
|
|
55
|
+
sessions.recordCheck(id, outcome, trend);
|
|
56
|
+
} catch {
|
|
57
|
+
}
|
|
58
|
+
if (typeof sessions.log !== "function") return;
|
|
59
|
+
try {
|
|
60
|
+
sessions.log(id, { kind: "check", outcome, ...detail });
|
|
61
|
+
} catch {
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
async review(turn) {
|
|
66
|
+
const output = typeof turn?.output === "string" ? turn.output.trim() : "";
|
|
67
|
+
if (!output) return { evaluated: false, lowScore: false, steered: false };
|
|
68
|
+
if (turn?.context !== void 0 && !turn.context.request) return { evaluated: false, lowScore: false, steered: false };
|
|
69
|
+
const id = turn?.agent?.session?.id;
|
|
70
|
+
if (sessions && typeof id === "string") {
|
|
71
|
+
try {
|
|
72
|
+
if (sessions.snapshot(id).enabled === false) return { evaluated: false, lowScore: false, steered: false };
|
|
73
|
+
} catch {
|
|
74
|
+
return { evaluated: false, lowScore: false, steered: false };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const task = taskExcerpt(turn?.context?.request ?? turn?.userText);
|
|
78
|
+
const taskDetail = task ? { task } : {};
|
|
79
|
+
if (Number.isSafeInteger(turn?.turn) && turn?.turn >= 0) taskDetail.turn = turn?.turn;
|
|
80
|
+
const { mode, rubric, lowScoreThreshold, minConfidence: confidenceFloor, trendMinConfidence, trendRun, trendSevereRun } = await resolveSettings();
|
|
81
|
+
let answer;
|
|
82
|
+
try {
|
|
83
|
+
const result = await evaluate(scoreQuestion(output, rubric, turn?.context), { signal: turn?.signal });
|
|
84
|
+
answer = result?.answers?.quality;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
record(turn, "error", { ...taskDetail, reason: errorReason(error) });
|
|
87
|
+
return { evaluated: false, lowScore: false, steered: false };
|
|
88
|
+
}
|
|
89
|
+
if (!answer || answer.type !== "score" || typeof answer.score !== "number" || !Number.isFinite(answer.score)) {
|
|
90
|
+
record(turn, "error", { ...taskDetail, reason: "invalid-response" });
|
|
91
|
+
return { evaluated: false, lowScore: false, steered: false };
|
|
92
|
+
}
|
|
93
|
+
const confidence = typeof answer.confidence === "number" && Number.isFinite(answer.confidence) ? answer.confidence : void 0;
|
|
94
|
+
const detail = confidence === void 0 ? { ...taskDetail } : { ...taskDetail, confidence };
|
|
95
|
+
const lowScore = answer.score <= lowScoreThreshold;
|
|
96
|
+
const trend = { confident: lowScore && confidence !== void 0 && confidence >= trendMinConfidence, trendRun, trendSevereRun };
|
|
97
|
+
if (lowScore && confidence !== void 0 && !trend.confident) detail.lowConfidence = true;
|
|
98
|
+
record(turn, lowScore ? "low" : "ok", { score: answer.score, ...detail }, trend);
|
|
99
|
+
if (!lowScore) return { evaluated: true, lowScore: false, steered: false, score: answer.score };
|
|
100
|
+
const confident = confidence !== void 0 && confidence >= confidenceFloor;
|
|
101
|
+
const steered = mode === "steer" && confident && typeof steer === "function" && turn?.agent ? steerOnce.attempt(turn, () => steer(turn.agent, STEER_TEXT)) : false;
|
|
102
|
+
return { evaluated: true, lowScore: true, steered, score: answer.score };
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export {
|
|
107
|
+
DEFAULT_MIN_CONFIDENCE,
|
|
108
|
+
DEFAULT_RUBRIC,
|
|
109
|
+
createSelfCheck,
|
|
110
|
+
scoreQuestion
|
|
111
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { SessionSinkRow, SessionSnapshot, SessionState } from './types.js';
|
|
2
|
+
export type SessionStateOptions = {
|
|
3
|
+
maxSessions?: number;
|
|
4
|
+
maxLogEntries?: number;
|
|
5
|
+
onLog?: (entry: SessionSinkRow) => void;
|
|
6
|
+
loadState?: (id: string) => Promise<unknown> | unknown;
|
|
7
|
+
persistState?: (id: string, state: SessionState) => unknown;
|
|
8
|
+
};
|
|
9
|
+
export declare function createSessionState({ maxSessions, maxLogEntries, onLog, loadState, persistState }?: SessionStateOptions): {
|
|
10
|
+
ensureLoaded(id: unknown): Promise<void>;
|
|
11
|
+
setEnabled(id: unknown, enabled: unknown): void;
|
|
12
|
+
snapshot(id: unknown): SessionSnapshot;
|
|
13
|
+
record(id: unknown, kind: string, outcome: string): void;
|
|
14
|
+
recordActual(id: unknown, outcome: string): void;
|
|
15
|
+
recordCheck(id: unknown, outcome: string, trend?: Record<string, unknown>): void;
|
|
16
|
+
recordNarrow(id: unknown, outcome: string, dropped?: number, applied?: boolean): void;
|
|
17
|
+
recordComplete(id: unknown, outcome: string, detail?: Record<string, unknown>): void;
|
|
18
|
+
log(id: unknown, entry: Record<string, unknown>): void;
|
|
19
|
+
};
|