dsh-decision-layer 0.7.1 → 0.7.2
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 +8 -8
- package/README.md +10 -10
- package/lib/client.js +39 -14
- 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/excerpt.d.ts +25 -0
- package/lib/host/excerpt.js +62 -0
- package/lib/host/index.d.ts +6 -0
- package/lib/host/index.js +290 -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 +48 -0
- package/lib/host/narrowing.js +213 -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 +27 -0
- package/lib/host/self-check.js +104 -0
- package/lib/host/session-state.d.ts +19 -0
- package/lib/host/session-state.js +288 -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 +29 -0
- package/lib/host/task-completion.js +148 -0
- package/lib/host/types.d.ts +343 -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,34 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { logsDir } from "./log-store.js";
|
|
6
|
+
async function* createReadableStream({ dir = logsDir(), port } = {}) {
|
|
7
|
+
const prefix = Number.isInteger(port) && port > 0 ? `decisions-${port}-` : "decisions-";
|
|
8
|
+
let names;
|
|
9
|
+
try {
|
|
10
|
+
names = await readdir(dir);
|
|
11
|
+
} catch {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const files = names.filter((name) => name.startsWith(prefix) && name.endsWith(".jsonl")).sort();
|
|
15
|
+
for (const name of files) {
|
|
16
|
+
const stream = createReadStream(join(dir, name), { encoding: "utf8" });
|
|
17
|
+
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
18
|
+
try {
|
|
19
|
+
for await (const line of lines) {
|
|
20
|
+
if (!line.trim()) continue;
|
|
21
|
+
try {
|
|
22
|
+
yield JSON.parse(line);
|
|
23
|
+
} catch {
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
} finally {
|
|
27
|
+
lines.close();
|
|
28
|
+
stream.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export {
|
|
33
|
+
createReadableStream
|
|
34
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare function logsDir(): string;
|
|
2
|
+
export type LogStoreOptions = {
|
|
3
|
+
port?: number;
|
|
4
|
+
dir?: string;
|
|
5
|
+
flushIntervalMs?: number;
|
|
6
|
+
maxBuffer?: number;
|
|
7
|
+
retentionDays?: number;
|
|
8
|
+
now?: () => Date;
|
|
9
|
+
};
|
|
10
|
+
export declare function createLogStore({ port, dir, flushIntervalMs, maxBuffer, retentionDays, now }?: LogStoreOptions): {
|
|
11
|
+
append(entry: unknown): void;
|
|
12
|
+
appendAnnotation(entry: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
13
|
+
flush: () => Promise<void>;
|
|
14
|
+
dispose(): Promise<void>;
|
|
15
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { mkdir, appendFile, readdir, rm } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
function logsDir() {
|
|
5
|
+
return join(homedir(), ".config", "dsh-decision-layer", "logs");
|
|
6
|
+
}
|
|
7
|
+
const dayStamp = (date) => date.toISOString().slice(0, 10);
|
|
8
|
+
function createLogStore({
|
|
9
|
+
port,
|
|
10
|
+
dir = logsDir(),
|
|
11
|
+
flushIntervalMs = 5e3,
|
|
12
|
+
maxBuffer = 200,
|
|
13
|
+
retentionDays = 30,
|
|
14
|
+
now = () => /* @__PURE__ */ new Date()
|
|
15
|
+
} = {}) {
|
|
16
|
+
const safePort = Number.isInteger(port) && port > 0 && port < 65536 ? port : 0;
|
|
17
|
+
const prefix = `decisions-${safePort}-`;
|
|
18
|
+
let buffer = [];
|
|
19
|
+
let timer;
|
|
20
|
+
let flushing = false;
|
|
21
|
+
const fileFor = (date) => join(dir, `${prefix}${dayStamp(date)}.jsonl`);
|
|
22
|
+
const prune = async () => {
|
|
23
|
+
let names;
|
|
24
|
+
try {
|
|
25
|
+
names = await readdir(dir);
|
|
26
|
+
} catch {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const cutoff = now().getTime() - retentionDays * 24 * 60 * 60 * 1e3;
|
|
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: 448 });
|
|
45
|
+
const byFile = /* @__PURE__ */ 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)}
|
|
50
|
+
`);
|
|
51
|
+
}
|
|
52
|
+
for (const [file, lines] of byFile) await appendFile(file, lines.join(""), { mode: 384 });
|
|
53
|
+
await prune();
|
|
54
|
+
} catch {
|
|
55
|
+
} finally {
|
|
56
|
+
flushing = false;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
append(entry) {
|
|
61
|
+
if (entry === null || typeof entry !== "object") return;
|
|
62
|
+
buffer.push(entry);
|
|
63
|
+
if (buffer.length >= maxBuffer) {
|
|
64
|
+
void flush();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!timer) timer = setTimeout(() => {
|
|
68
|
+
timer = void 0;
|
|
69
|
+
void flush();
|
|
70
|
+
}, flushIntervalMs);
|
|
71
|
+
},
|
|
72
|
+
// An annotation is an explicit user action, not telemetry: write it
|
|
73
|
+
// immediately (not batched) and surface failure to the caller instead of
|
|
74
|
+
// dropping it. It lands in the same JSONL directory as a `kind:"annotation"`
|
|
75
|
+
// event and is pruned with the decision logs on the next flush.
|
|
76
|
+
async appendAnnotation(entry) {
|
|
77
|
+
if (entry === null || typeof entry !== "object") throw new Error("Invalid annotation");
|
|
78
|
+
const at = Number.isFinite(entry.at) ? entry.at : now().getTime();
|
|
79
|
+
const record = { ...entry, at };
|
|
80
|
+
await mkdir(dir, { recursive: true, mode: 448 });
|
|
81
|
+
await appendFile(fileFor(new Date(at)), `${JSON.stringify(record)}
|
|
82
|
+
`, { mode: 384 });
|
|
83
|
+
return record;
|
|
84
|
+
},
|
|
85
|
+
flush,
|
|
86
|
+
dispose() {
|
|
87
|
+
if (timer) {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
timer = void 0;
|
|
90
|
+
}
|
|
91
|
+
return flush();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export {
|
|
96
|
+
createLogStore,
|
|
97
|
+
logsDir
|
|
98
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SessionsFace, ToolExec } from './types.js';
|
|
2
|
+
export declare function fingerprintCall(exec: ToolExec | undefined): string;
|
|
3
|
+
export declare function createLoopGuard({ threshold, maxSessions, sessions }?: {
|
|
4
|
+
threshold?: number;
|
|
5
|
+
maxSessions?: number;
|
|
6
|
+
sessions?: SessionsFace;
|
|
7
|
+
}): {
|
|
8
|
+
check(exec: ToolExec | undefined): string | undefined;
|
|
9
|
+
runLength(id: string): number;
|
|
10
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
const MAX_FINGERPRINT_INPUT = 16 * 1024;
|
|
3
|
+
function fingerprintCall(exec) {
|
|
4
|
+
const name = typeof exec?.name === "string" ? exec.name : "";
|
|
5
|
+
let args = "";
|
|
6
|
+
try {
|
|
7
|
+
args = JSON.stringify(exec?.arguments ?? null);
|
|
8
|
+
} catch {
|
|
9
|
+
args = "";
|
|
10
|
+
}
|
|
11
|
+
return createHash("sha256").update(name).update("\0").update(args.slice(0, MAX_FINGERPRINT_INPUT)).digest("hex");
|
|
12
|
+
}
|
|
13
|
+
function createLoopGuard({ threshold = 3, maxSessions = 1024, sessions } = {}) {
|
|
14
|
+
if (!Number.isSafeInteger(threshold) || threshold < 2) throw new Error("Invalid loop threshold");
|
|
15
|
+
const runs = /* @__PURE__ */ new Map();
|
|
16
|
+
const runFor = (id) => {
|
|
17
|
+
let run = runs.get(id);
|
|
18
|
+
if (!run) {
|
|
19
|
+
if (runs.size >= maxSessions) runs.delete(runs.keys().next().value);
|
|
20
|
+
run = { key: void 0, count: 0 };
|
|
21
|
+
runs.set(id, run);
|
|
22
|
+
}
|
|
23
|
+
return run;
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
check(exec) {
|
|
27
|
+
const id = exec?.agent?.session?.id;
|
|
28
|
+
if (typeof id !== "string") return void 0;
|
|
29
|
+
if (sessions) {
|
|
30
|
+
try {
|
|
31
|
+
if (sessions.snapshot(id).enabled === false) return void 0;
|
|
32
|
+
} catch {
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const run = runFor(id);
|
|
36
|
+
const key = fingerprintCall(exec);
|
|
37
|
+
run.count = key === run.key ? run.count + 1 : 1;
|
|
38
|
+
run.key = key;
|
|
39
|
+
if (run.count >= threshold) {
|
|
40
|
+
return `Identical tool call repeated ${run.count} times in a row; blocked to break a loop`;
|
|
41
|
+
}
|
|
42
|
+
return void 0;
|
|
43
|
+
},
|
|
44
|
+
runLength(id) {
|
|
45
|
+
return runs.get(id)?.count ?? 0;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export {
|
|
50
|
+
createLoopGuard,
|
|
51
|
+
fingerprintCall
|
|
52
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Evaluator, ManagerRoute, SessionsFace } from './types.js';
|
|
2
|
+
export declare const API_PREFIX = "/plugins/dsh-decision-layer/api";
|
|
3
|
+
export type ManagerRouteDeps = {
|
|
4
|
+
path?: string;
|
|
5
|
+
sessions: SessionsFace;
|
|
6
|
+
backend: {
|
|
7
|
+
evaluate: Evaluator;
|
|
8
|
+
};
|
|
9
|
+
logStore?: {
|
|
10
|
+
appendAnnotation: (entry: Record<string, unknown>) => Promise<unknown>;
|
|
11
|
+
};
|
|
12
|
+
analyze?: (id: string) => Promise<unknown>;
|
|
13
|
+
listRows?: (id: string, options: {
|
|
14
|
+
page: number;
|
|
15
|
+
pageSize: number;
|
|
16
|
+
}) => Promise<unknown>;
|
|
17
|
+
env?: Record<string, string | undefined>;
|
|
18
|
+
bindHost?: string;
|
|
19
|
+
};
|
|
20
|
+
export declare function createManagerRoutes({ path, sessions, backend, logStore, analyze, listRows, env, bindHost }: ManagerRouteDeps): ManagerRoute[];
|
|
@@ -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,48 @@
|
|
|
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
|
+
signal?: AbortSignal;
|
|
23
|
+
state?: unknown;
|
|
24
|
+
tools?: unknown;
|
|
25
|
+
};
|
|
26
|
+
export type NarrowResult = {
|
|
27
|
+
evaluated: boolean;
|
|
28
|
+
applied: boolean;
|
|
29
|
+
keep: string[];
|
|
30
|
+
drop: string[];
|
|
31
|
+
mode: NarrowMode;
|
|
32
|
+
overCap?: boolean;
|
|
33
|
+
trustworthy?: boolean;
|
|
34
|
+
};
|
|
35
|
+
export type NarrowingOptions = {
|
|
36
|
+
evaluate?: Evaluator;
|
|
37
|
+
sessions?: SessionsFace;
|
|
38
|
+
settings?: NarrowSettings | (() => Promise<NarrowSettings | undefined>);
|
|
39
|
+
coreTools?: readonly string[];
|
|
40
|
+
keepPrefixes?: readonly string[];
|
|
41
|
+
minCandidates?: number;
|
|
42
|
+
maxCandidates?: number;
|
|
43
|
+
minKeep?: number;
|
|
44
|
+
splitConcurrency?: number;
|
|
45
|
+
};
|
|
46
|
+
export declare function createNarrowing({ evaluate, sessions, settings, coreTools, keepPrefixes, minCandidates, maxCandidates, minKeep, splitConcurrency }?: NarrowingOptions): {
|
|
47
|
+
review(turn: NarrowTurn | undefined): Promise<NarrowResult>;
|
|
48
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
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?.state);
|
|
110
|
+
const context = task ? { task } : {};
|
|
111
|
+
if (Number.isSafeInteger(turn?.turn) && turn?.turn >= 0) context.turn = turn?.turn;
|
|
112
|
+
sessions.log(id, { kind: "narrow", outcome, ...context, ...detail });
|
|
113
|
+
} catch {
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const evaluateSplit = async (state, tools, signal) => {
|
|
117
|
+
const answers = /* @__PURE__ */ Object.create(null);
|
|
118
|
+
let anyOk = false;
|
|
119
|
+
for (let i = 0; i < tools.length; i += concurrency) {
|
|
120
|
+
const group = tools.slice(i, i + concurrency);
|
|
121
|
+
const settled = await Promise.all(group.map(async (tool) => {
|
|
122
|
+
try {
|
|
123
|
+
const reply = await evaluate(relevanceQuestions(state, [tool]), { signal });
|
|
124
|
+
const answer = reply?.answers?.[tool.name];
|
|
125
|
+
return isObject(answer) ? [tool.name, answer] : void 0;
|
|
126
|
+
} catch {
|
|
127
|
+
return void 0;
|
|
128
|
+
}
|
|
129
|
+
}));
|
|
130
|
+
for (const pair of settled) {
|
|
131
|
+
if (pair) {
|
|
132
|
+
answers[pair[0]] = pair[1];
|
|
133
|
+
anyOk = true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return anyOk ? { model: "split", answers } : null;
|
|
138
|
+
};
|
|
139
|
+
const skip = { evaluated: false, applied: false, keep: [], drop: [], mode: "observe" };
|
|
140
|
+
return {
|
|
141
|
+
async review(turn) {
|
|
142
|
+
const tools = Array.isArray(turn?.tools) ? turn.tools.filter((tool) => isObject(tool) && typeof tool.name === "string") : [];
|
|
143
|
+
const names = tools.map((tool) => tool.name);
|
|
144
|
+
const { mode, threshold, prefixes, cap, batchMode } = await resolveSettings();
|
|
145
|
+
const alwaysKeep = (name) => core.has(name) || prefixes.some((prefix) => name.startsWith(prefix));
|
|
146
|
+
const keptNames = names.filter(alwaysKeep);
|
|
147
|
+
const optional = tools.filter((tool) => !alwaysKeep(tool.name));
|
|
148
|
+
if (optional.length < minCandidates) return { ...skip, keep: names };
|
|
149
|
+
const hasState = typeof turn?.state === "string" ? turn.state.trim().length > 0 : isObject(turn?.state) ? Object.keys(turn.state).length > 0 : false;
|
|
150
|
+
if (!hasState) return { ...skip, keep: names };
|
|
151
|
+
const id = turn?.agent?.session?.id;
|
|
152
|
+
if (sessions && typeof id === "string") {
|
|
153
|
+
try {
|
|
154
|
+
if (sessions.snapshot(id).enabled === false) return { ...skip, keep: names };
|
|
155
|
+
} catch {
|
|
156
|
+
return { ...skip, keep: names };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const overCap = optional.length > cap;
|
|
160
|
+
const effectiveMode = overCap ? "observe" : mode;
|
|
161
|
+
const judged = overCap ? optional.slice(0, cap) : optional;
|
|
162
|
+
const judgedNames = judged.map((tool) => tool.name);
|
|
163
|
+
let result = null;
|
|
164
|
+
if (batchMode === "split" || adaptiveSplit) {
|
|
165
|
+
result = await evaluateSplit(turn?.state, judged, turn?.signal);
|
|
166
|
+
if (!result) {
|
|
167
|
+
record(turn, "error", { reason: "unreachable", mode: effectiveMode });
|
|
168
|
+
return { ...skip, keep: names, mode: effectiveMode };
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
try {
|
|
172
|
+
const reply = await evaluate(relevanceQuestions(turn?.state, judged), { signal: turn?.signal });
|
|
173
|
+
result = reply;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
const reason = errorReason(error);
|
|
176
|
+
if (batchMode === "auto" && reason === "http-error") {
|
|
177
|
+
adaptiveSplit = true;
|
|
178
|
+
result = await evaluateSplit(turn?.state, judged, turn?.signal);
|
|
179
|
+
if (!result) {
|
|
180
|
+
record(turn, "error", { reason, mode: effectiveMode });
|
|
181
|
+
return { ...skip, keep: names, mode: effectiveMode };
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
record(turn, "error", { reason, mode: effectiveMode });
|
|
185
|
+
return { ...skip, keep: names, mode: effectiveMode };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const narrowed = narrowTools(judgedNames, result, { threshold, keepBase: keptNames.length });
|
|
190
|
+
const keep = [...keptNames, ...narrowed.keep];
|
|
191
|
+
const drop = narrowed.drop;
|
|
192
|
+
const trustworthy = keep.length >= minKeep;
|
|
193
|
+
const applied = narrowed.applied && trustworthy && !overCap && mode === "enforce";
|
|
194
|
+
const dropped = drop.length;
|
|
195
|
+
const detail = { dropped, kept: keep.length, mode: effectiveMode, tools: drop, applied };
|
|
196
|
+
if (overCap) {
|
|
197
|
+
detail.reason = "too-many-candidates";
|
|
198
|
+
detail.candidates = optional.length;
|
|
199
|
+
} else if (dropped > 0 && !trustworthy) detail.reason = "low-keep";
|
|
200
|
+
record(turn, dropped > 0 ? "applied" : "ok", detail);
|
|
201
|
+
return { evaluated: true, applied, keep, drop, mode: effectiveMode, overCap, trustworthy };
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
export {
|
|
206
|
+
DEFAULT_CORE_TOOLS,
|
|
207
|
+
DEFAULT_KEEP_PREFIXES,
|
|
208
|
+
DEFAULT_NARROW_THRESHOLD,
|
|
209
|
+
createNarrowing,
|
|
210
|
+
narrowApplied,
|
|
211
|
+
narrowTools,
|
|
212
|
+
relevanceQuestions
|
|
213
|
+
};
|
|
@@ -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;
|