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,187 @@
|
|
|
1
|
+
import { redactCredentials, stripInjected } from "./excerpt.js";
|
|
2
|
+
import { summarizeExecution } from "./danger-gate.js";
|
|
3
|
+
import { isObject } from "./types.js";
|
|
4
|
+
function clip(text, maxBytes) {
|
|
5
|
+
let size = 0;
|
|
6
|
+
let result = "";
|
|
7
|
+
for (const char of text) {
|
|
8
|
+
size += Buffer.byteLength(char);
|
|
9
|
+
if (size > maxBytes) break;
|
|
10
|
+
result += char;
|
|
11
|
+
}
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
function decisionText(value, maxBytes = 1536) {
|
|
15
|
+
if (typeof value !== "string" || maxBytes <= 0) return "";
|
|
16
|
+
const clean = redactCredentials(stripInjected(value.slice(0, 32 * 1024))).trim();
|
|
17
|
+
return clip(clean, maxBytes);
|
|
18
|
+
}
|
|
19
|
+
const OPAQUE = /[A-Za-z0-9_+-]{24,}/g;
|
|
20
|
+
const resultExcerpt = (value, maxBytes) => clip(decisionText(value, 32 * 1024).replace(OPAQUE, "[redacted]"), maxBytes);
|
|
21
|
+
const messagesOf = (value) => Array.isArray(value) ? value.filter(isObject) : [];
|
|
22
|
+
const textOf = (message) => Array.isArray(message.content) ? message.content.filter(isObject).filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n") : "";
|
|
23
|
+
const human = (message) => message.role === "user" && (!message.source || isObject(message.source) && message.source.kind === "user");
|
|
24
|
+
function historyOf(agent) {
|
|
25
|
+
try {
|
|
26
|
+
const derived = agent?.session?.deriveMessages?.();
|
|
27
|
+
return Array.isArray(derived) ? messagesOf(derived) : void 0;
|
|
28
|
+
} catch {
|
|
29
|
+
return void 0;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function backgroundOf(messages, isHuman) {
|
|
33
|
+
const lastUser = messages.findLastIndex(isHuman);
|
|
34
|
+
const lastAssistant = messages.findLastIndex((message) => message.role === "assistant");
|
|
35
|
+
return [lastUser, lastAssistant].filter((index) => index >= 0).sort((a, b) => a - b).map((index) => {
|
|
36
|
+
const message = messages[index];
|
|
37
|
+
if (!message) return void 0;
|
|
38
|
+
const role = isHuman(message) ? "user" : "assistant";
|
|
39
|
+
return { role, text: decisionText(textOf(message), 512) };
|
|
40
|
+
}).filter((item) => Boolean(item?.text));
|
|
41
|
+
}
|
|
42
|
+
function evidenceOf(messages) {
|
|
43
|
+
const calls = /* @__PURE__ */ new Map();
|
|
44
|
+
const evidence = [];
|
|
45
|
+
for (const message of messages) {
|
|
46
|
+
if (message.role === "assistant" && Array.isArray(message.content)) {
|
|
47
|
+
for (const block of message.content.filter(isObject)) {
|
|
48
|
+
if (block.type !== "tool-call" || typeof block.id !== "string" || typeof block.name !== "string") continue;
|
|
49
|
+
let args = block.arguments;
|
|
50
|
+
if (typeof args === "string") {
|
|
51
|
+
try {
|
|
52
|
+
args = JSON.parse(args);
|
|
53
|
+
} catch {
|
|
54
|
+
args = void 0;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
calls.set(block.id, { name: block.name, arguments: args });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (message.role !== "tool") continue;
|
|
61
|
+
const source = isObject(message.source) ? message.source : {};
|
|
62
|
+
const id = typeof message.toolCallId === "string" ? message.toolCallId : source.callId;
|
|
63
|
+
const call = typeof id === "string" ? calls.get(id) : void 0;
|
|
64
|
+
const name = call?.name ?? (typeof message.name === "string" ? message.name : typeof message.toolName === "string" ? message.toolName : "");
|
|
65
|
+
if (!name) continue;
|
|
66
|
+
const item = { tool: decisionText(name, 96), outcome: message.isError === true ? "error" : "ok" };
|
|
67
|
+
if (call) {
|
|
68
|
+
const summary = summarizeExecution(call);
|
|
69
|
+
if (summary.command) item.command = decisionText(summary.command, 160);
|
|
70
|
+
if (summary.path) item.path = decisionText(summary.path, 160);
|
|
71
|
+
}
|
|
72
|
+
if (["bash", "pwsh", "shell", "exec", "terminal", "run_command"].includes(name)) {
|
|
73
|
+
const result = resultExcerpt(textOf(message), 256);
|
|
74
|
+
if (result) item.result = result;
|
|
75
|
+
}
|
|
76
|
+
evidence.push(item);
|
|
77
|
+
}
|
|
78
|
+
const bounded = evidence.length > 12 ? [...evidence.slice(0, 4), ...evidence.slice(-8)] : [...evidence];
|
|
79
|
+
while (Buffer.byteLength(JSON.stringify(bounded)) > 4096) bounded.shift();
|
|
80
|
+
return bounded;
|
|
81
|
+
}
|
|
82
|
+
const REPLACEMENTS = /* @__PURE__ */ new Set(["compact-checkpoint", "dsh-rewind"]);
|
|
83
|
+
function afterLastReplacement(messages) {
|
|
84
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
85
|
+
const message = messages[i];
|
|
86
|
+
if (!message) continue;
|
|
87
|
+
const source = isObject(message.source) ? message.source : void 0;
|
|
88
|
+
if (source && typeof source.kind === "string" && REPLACEMENTS.has(source.kind)) return messages.slice(i + 1);
|
|
89
|
+
}
|
|
90
|
+
return messages;
|
|
91
|
+
}
|
|
92
|
+
function boundaryHolds(history, boundary) {
|
|
93
|
+
if (boundary.start <= 0) return true;
|
|
94
|
+
const previous = history[boundary.start - 1];
|
|
95
|
+
if (!previous) return false;
|
|
96
|
+
return previous === boundary.anchor || boundary.anchorId !== void 0 && previous.id === boundary.anchorId;
|
|
97
|
+
}
|
|
98
|
+
function snapshotOf(history, boundary, isHuman) {
|
|
99
|
+
const messages = history ?? [];
|
|
100
|
+
const lastHuman = messages.findLastIndex(isHuman);
|
|
101
|
+
const window = boundary === void 0 ? messages.slice(lastHuman >= 0 ? lastHuman : messages.length) : boundary.broken ? [] : boundaryHolds(messages, boundary) ? messages.slice(boundary.start) : [];
|
|
102
|
+
const current = afterLastReplacement(window);
|
|
103
|
+
const requests = boundary?.requests ?? current.filter(isHuman).map((message) => decisionText(textOf(message), 8192));
|
|
104
|
+
const request = decisionText(requests.at(-1) ?? "", 1536);
|
|
105
|
+
const evidence = evidenceOf(current);
|
|
106
|
+
const context = {
|
|
107
|
+
request,
|
|
108
|
+
// Earlier real requests in this turn stay visible as background: with only the
|
|
109
|
+
// last one, a short follow-up ("继续") would hide the main task. Pre-turn
|
|
110
|
+
// context is kept too, but the size cap below drops it first.
|
|
111
|
+
background: [
|
|
112
|
+
...boundary?.background ?? backgroundOf(messages.slice(0, Math.max(lastHuman, 0)), isHuman),
|
|
113
|
+
...requests.slice(0, -1).slice(-2).map((text) => ({ role: "user", text: decisionText(text, 1024) }))
|
|
114
|
+
].slice(-4),
|
|
115
|
+
progress: evidence.slice(-3)
|
|
116
|
+
};
|
|
117
|
+
while (Buffer.byteLength(JSON.stringify(context.progress)) > 1024 && context.progress.length > 1) context.progress.shift();
|
|
118
|
+
while (Buffer.byteLength(JSON.stringify(context)) > 4096 && (context.background.length || context.progress.length > 1)) {
|
|
119
|
+
if (context.background.length) context.background.shift();
|
|
120
|
+
else context.progress.shift();
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
context,
|
|
124
|
+
evidence,
|
|
125
|
+
// Completion conditions come from the latest real request only. Joining a
|
|
126
|
+
// turn's requests would resurrect work the user just cancelled ("drop the
|
|
127
|
+
// earlier list, only review"); when the latest request has no explicit
|
|
128
|
+
// conditions the check skips rather than infer from an older revision.
|
|
129
|
+
conditionsText: requests.at(-1) ?? ""
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function createDecisionContext({ ignored } = {}) {
|
|
133
|
+
const boundaries = /* @__PURE__ */ new WeakMap();
|
|
134
|
+
const isHuman = (message) => human(message) && !(typeof message.id === "string" && ignored?.(message.id) === true);
|
|
135
|
+
const enter = (agent, position) => {
|
|
136
|
+
const known = agent ? boundaries.get(agent) : void 0;
|
|
137
|
+
const first = !known || known.turn !== position.turn;
|
|
138
|
+
const inputs = messagesOf(position.messages).filter(isHuman).map((message) => decisionText(textOf(message), 8192)).filter(Boolean);
|
|
139
|
+
if (!first && inputs.length === 0) return { changed: false };
|
|
140
|
+
const history = historyOf(agent);
|
|
141
|
+
let boundary = known;
|
|
142
|
+
const anchor = history?.at(-1);
|
|
143
|
+
if (first) {
|
|
144
|
+
boundary = {
|
|
145
|
+
turn: position.turn,
|
|
146
|
+
start: history?.length ?? 0,
|
|
147
|
+
anchor,
|
|
148
|
+
broken: history === void 0,
|
|
149
|
+
...isObject(anchor) && typeof anchor.id === "string" ? { anchorId: anchor.id } : {},
|
|
150
|
+
requests: [],
|
|
151
|
+
background: history ? backgroundOf(history, isHuman) : []
|
|
152
|
+
};
|
|
153
|
+
if (agent) boundaries.set(agent, boundary);
|
|
154
|
+
} else if (boundary?.broken) {
|
|
155
|
+
boundary.broken = history === void 0;
|
|
156
|
+
boundary.start = history?.length ?? 0;
|
|
157
|
+
boundary.anchor = anchor;
|
|
158
|
+
if (isObject(anchor) && typeof anchor.id === "string") boundary.anchorId = anchor.id;
|
|
159
|
+
else delete boundary.anchorId;
|
|
160
|
+
boundary.background = history ? backgroundOf(history, isHuman) : [];
|
|
161
|
+
}
|
|
162
|
+
const active = boundary;
|
|
163
|
+
active.requests.push(...inputs);
|
|
164
|
+
active.requests = active.requests.slice(-8);
|
|
165
|
+
return { changed: true, snapshot: snapshotOf(history, active, isHuman) };
|
|
166
|
+
};
|
|
167
|
+
return {
|
|
168
|
+
enter(agent, position) {
|
|
169
|
+
try {
|
|
170
|
+
return enter(agent, position);
|
|
171
|
+
} catch {
|
|
172
|
+
return { changed: false };
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
snapshot(agent) {
|
|
176
|
+
try {
|
|
177
|
+
return snapshotOf(historyOf(agent), agent ? boundaries.get(agent) : void 0, isHuman);
|
|
178
|
+
} catch {
|
|
179
|
+
return { context: { request: "", background: [], progress: [] }, conditionsText: "", evidence: [] };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
export {
|
|
185
|
+
createDecisionContext,
|
|
186
|
+
decisionText
|
|
187
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one bounded, redacted "what was this turn about" excerpt every decision
|
|
3
|
+
* row may carry into the local decision log.
|
|
4
|
+
*
|
|
5
|
+
* It is a human-review hint, never a dump of structured state: injected host
|
|
6
|
+
* context (memory blocks, system reminders) is stripped first, then common
|
|
7
|
+
* credential forms are masked, and only then is the text collapsed and cut.
|
|
8
|
+
* The same rules must hold for every decision point — the panel and the JSONL
|
|
9
|
+
* sink store what this returns, so a weaker copy elsewhere would leak.
|
|
10
|
+
*
|
|
11
|
+
* Masking deliberately errs toward over-redaction: a false positive only costs
|
|
12
|
+
* the reader some context, while a miss writes a secret to disk for 30 days.
|
|
13
|
+
* The rules cover labeled key/value pairs (`password`, `token`, Chinese
|
|
14
|
+
* 密码/令牌 …), HTTP credential headers, URL userinfo, CLI flags, copula forms
|
|
15
|
+
* (`token is …`), and well-known token shapes that appear without any label.
|
|
16
|
+
*/
|
|
17
|
+
export declare const TASK_EXCERPT_LIMIT = 512;
|
|
18
|
+
/**
|
|
19
|
+
* Mask the credential forms that show up in a task excerpt. Exported so the
|
|
20
|
+
* storage layer can apply the same rules as a second boundary (it is cheap and
|
|
21
|
+
* idempotent), keeping a future caller that forgets `taskExcerpt` from writing
|
|
22
|
+
* a secret to the log.
|
|
23
|
+
*/
|
|
24
|
+
export declare function redactCredentials(input: string): string;
|
|
25
|
+
export declare function stripInjected(input: string): string;
|
|
26
|
+
export declare function taskExcerpt(state: unknown, limit?: number): string | undefined;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const MAX_INPUT = 8 * 1024;
|
|
2
|
+
const TASK_EXCERPT_LIMIT = 512;
|
|
3
|
+
const TOKEN_KEYS = String.raw`token|api[ _-]*key`;
|
|
4
|
+
const PHRASE_KEYS = String.raw`password|passw(?:or)?d|passphrase|secret[ _-]?key|access[ _-]?key|private[ _-]?key|secret`;
|
|
5
|
+
const CN_KEYS = String.raw`密码|口令|密码短语|密钥|令牌|凭证|私钥`;
|
|
6
|
+
const COMBINED_KEYS = String.raw`${PHRASE_KEYS}|${TOKEN_KEYS}`;
|
|
7
|
+
const ENV_PHRASE_KEYS = String.raw`PASSW(?:OR)?D|SECRET|ACCESS[_-]?KEY|PRIVATE[_-]?KEY`;
|
|
8
|
+
const ENV_TOKEN_KEYS = String.raw`TOKEN|API[_-]?KEY`;
|
|
9
|
+
const SEPARATOR = String.raw`[:=:=]`;
|
|
10
|
+
const VALUE = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;},;、]+)`;
|
|
11
|
+
const CLAUSE = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\r\n,;,;、]*)`;
|
|
12
|
+
const COPULA = String.raw`is|are|was|were|是|为`;
|
|
13
|
+
const VALUE_LIKE = String.raw`(?=\S*(?:\d|[A-Za-z0-9_-]{12,}))`;
|
|
14
|
+
const TOKEN_SHAPES = [
|
|
15
|
+
[/\b(sk|pk|rk)-[A-Za-z0-9_-]{8,}/gi, "$1-[redacted]"],
|
|
16
|
+
[/\b(gh[pousr]_)[A-Za-z0-9]{20,}/g, "$1[redacted]"],
|
|
17
|
+
[/\b(xox[baprs]-)[A-Za-z0-9-]{10,}/g, "$1[redacted]"],
|
|
18
|
+
[/\b(AKIA)[0-9A-Z]{16}\b/g, "$1[redacted]"],
|
|
19
|
+
[/\b(glpat-)[A-Za-z0-9_-]{10,}/g, "$1[redacted]"],
|
|
20
|
+
[/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]*/g, "[redacted]"]
|
|
21
|
+
];
|
|
22
|
+
const RULE = {
|
|
23
|
+
// Template placeholders (`{{SECRET_...}}`) hide a value inside braces the
|
|
24
|
+
// value rules deliberately do not consume, so mask the whole placeholder.
|
|
25
|
+
template: /\{\{[^{}\r\n]*\}\}/g,
|
|
26
|
+
bearer: /\bBearer\s+[^\s,;]+/gi,
|
|
27
|
+
urlUserinfo: /([a-z][a-z0-9+.-]*:\/\/)[^\s/]*@/gi,
|
|
28
|
+
header: new RegExp(String.raw`(\b(?:authorization|cookie|set-cookie)\b["']?\s*${SEPARATOR}\s*)[^\r\n]*`, "gi"),
|
|
29
|
+
phrase: new RegExp(String.raw`(\b(?:${PHRASE_KEYS})\b["']?\s*${SEPARATOR}\s*)${CLAUSE}`, "gi"),
|
|
30
|
+
cnKey: new RegExp(String.raw`((?:${CN_KEYS})\s*[::=]\s*)${VALUE}`, "g"),
|
|
31
|
+
token: new RegExp(String.raw`(\b(?:${TOKEN_KEYS})\b["']?\s*${SEPARATOR}\s*)${VALUE}`, "gi"),
|
|
32
|
+
asciiCopula: new RegExp(String.raw`(\b(?:${COMBINED_KEYS})\b\s*(?:${COPULA})\s*)${VALUE_LIKE}${VALUE}`, "gi"),
|
|
33
|
+
cnCopula: new RegExp(String.raw`((?:${CN_KEYS})\s*(?:是|为)\s*)${VALUE_LIKE}${VALUE}`, "g"),
|
|
34
|
+
flag: new RegExp(String.raw`(^|\s)(--(?:${COMBINED_KEYS})(?:\s+|=))${VALUE}`, "gi"),
|
|
35
|
+
// Env-style keys are often a suffix of a longer identifier (`DB_PASSWORD`,
|
|
36
|
+
// `AWS_SECRET_ACCESS_KEY`), where `\b` cannot match across `_`. JSON and quoted
|
|
37
|
+
// .env output place a closing quote between key and separator, and a quoted
|
|
38
|
+
// passphrase may contain spaces, so the key allows a quote and the value is the
|
|
39
|
+
// whole clause (over-redaction is the accepted direction).
|
|
40
|
+
envPhrase: new RegExp(String.raw`([A-Za-z0-9_]*(?:${ENV_PHRASE_KEYS})[A-Za-z0-9_]*["']?\s*${SEPARATOR}\s*)${CLAUSE}`, "gi"),
|
|
41
|
+
envToken: new RegExp(String.raw`([A-Za-z0-9_]*(?:${ENV_TOKEN_KEYS})[A-Za-z0-9_]*["']?\s*${SEPARATOR}\s*)${VALUE}`, "gi"),
|
|
42
|
+
// Legacy space form (`token abc`), but never swallow a copula: the rule above
|
|
43
|
+
// masks the value that follows it.
|
|
44
|
+
loose: new RegExp(String.raw`(\b(?:${COMBINED_KEYS})\b\s+)(?!(?:${COPULA})(?:\s|$)|${SEPARATOR})${VALUE}`, "gi")
|
|
45
|
+
};
|
|
46
|
+
function redactCredentials(input) {
|
|
47
|
+
let text = input.replace(RULE.template, "[redacted]");
|
|
48
|
+
text = text.replace(RULE.bearer, "Bearer [redacted]");
|
|
49
|
+
text = text.replace(RULE.urlUserinfo, "$1[redacted]@");
|
|
50
|
+
text = text.replace(RULE.header, "$1[redacted]");
|
|
51
|
+
text = text.replace(RULE.phrase, "$1[redacted]");
|
|
52
|
+
text = text.replace(RULE.cnKey, "$1[redacted]");
|
|
53
|
+
text = text.replace(RULE.token, "$1[redacted]");
|
|
54
|
+
text = text.replace(RULE.asciiCopula, "$1[redacted]");
|
|
55
|
+
text = text.replace(RULE.cnCopula, "$1[redacted]");
|
|
56
|
+
text = text.replace(RULE.flag, "$1$2[redacted]");
|
|
57
|
+
text = text.replace(RULE.envPhrase, "$1[redacted]");
|
|
58
|
+
text = text.replace(RULE.envToken, "$1[redacted]");
|
|
59
|
+
text = text.replace(RULE.loose, "$1[redacted]");
|
|
60
|
+
for (const [pattern, replacement] of TOKEN_SHAPES) text = text.replace(pattern, replacement);
|
|
61
|
+
return text;
|
|
62
|
+
}
|
|
63
|
+
function stripInjected(input) {
|
|
64
|
+
return input.replace(/<(openviking-context|system-reminder)\b[^>]*>[\s\S]*?<\/\1>/gi, " ").replace(/<(openviking-context|system-reminder)\b[\s\S]*$/i, " ");
|
|
65
|
+
}
|
|
66
|
+
function taskExcerpt(state, limit = TASK_EXCERPT_LIMIT) {
|
|
67
|
+
if (typeof state !== "string") return void 0;
|
|
68
|
+
const text = redactCredentials(stripInjected(state.slice(0, MAX_INPUT))).replace(/\s+/g, " ").trim();
|
|
69
|
+
return text ? text.slice(0, limit) : void 0;
|
|
70
|
+
}
|
|
71
|
+
export {
|
|
72
|
+
TASK_EXCERPT_LIMIT,
|
|
73
|
+
redactCredentials,
|
|
74
|
+
stripInjected,
|
|
75
|
+
taskExcerpt
|
|
76
|
+
};
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
3
|
+
import { resolveConfig, featureEnabled } from "./config-store.js";
|
|
4
|
+
import { createBackend } from "./backend.js";
|
|
5
|
+
import { createManagerRoutes } from "./manager-api.js";
|
|
6
|
+
import { createSessionState } from "./session-state.js";
|
|
7
|
+
import { createDangerGate, normalizeDangerRules } from "./danger-gate.js";
|
|
8
|
+
import { createSelfCheck } from "./self-check.js";
|
|
9
|
+
import { createNarrowing } from "./narrowing.js";
|
|
10
|
+
import { createTaskCompletion } from "./task-completion.js";
|
|
11
|
+
import { createLoopGuard } from "./loop-guard.js";
|
|
12
|
+
import { createLogStore } from "./log-store.js";
|
|
13
|
+
import { createStateStore } from "./state-store.js";
|
|
14
|
+
import { createDecisionContext } from "./decision-context.js";
|
|
15
|
+
import { analyzeSessionLogs, reconstructSessionSeed, listSessionDecisions } from "./log-analyze.js";
|
|
16
|
+
import { isObject } from "./types.js";
|
|
17
|
+
const name = "dsh-decision-layer";
|
|
18
|
+
const inject = ["tools", "skills"];
|
|
19
|
+
const asMessage = (value) => isObject(value) ? value : void 0;
|
|
20
|
+
const textOf = (content) => Array.isArray(content) ? content.filter((block) => isObject(block) && block.type === "text" && typeof block.text === "string").map((block) => block.text).join("") : "";
|
|
21
|
+
const derivedMessages = (agent) => {
|
|
22
|
+
const messages = agent?.session?.deriveMessages?.();
|
|
23
|
+
return Array.isArray(messages) ? messages : void 0;
|
|
24
|
+
};
|
|
25
|
+
function lastAssistantText(agent) {
|
|
26
|
+
try {
|
|
27
|
+
const messages = derivedMessages(agent);
|
|
28
|
+
if (!messages) return "";
|
|
29
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
30
|
+
const message = asMessage(messages[i]);
|
|
31
|
+
if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
32
|
+
return textOf(message.content);
|
|
33
|
+
}
|
|
34
|
+
return "";
|
|
35
|
+
} catch {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function agentTools(ctx, agent) {
|
|
40
|
+
try {
|
|
41
|
+
const schemas = typeof ctx.tools?.schemas === "function" ? ctx.tools.schemas(agent) : void 0;
|
|
42
|
+
if (!Array.isArray(schemas)) return [];
|
|
43
|
+
return schemas.filter((schema) => isObject(schema) && typeof schema.name === "string").map((schema) => ({ name: schema.name, description: typeof schema.description === "string" ? schema.description : "" }));
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function apply(ctx, options = {}) {
|
|
49
|
+
const path = options.configPath;
|
|
50
|
+
const backend = createBackend({ config: () => resolveConfig({ path }) });
|
|
51
|
+
let logStore;
|
|
52
|
+
const stateStore = createStateStore();
|
|
53
|
+
const sessions = createSessionState({
|
|
54
|
+
onLog: (entry) => logStore?.append(entry),
|
|
55
|
+
// Restore a resumed session from its durable snapshot; if it has none yet but
|
|
56
|
+
// has persisted JSONL decision history (e.g. a session that predates this
|
|
57
|
+
// persistence, or whose snapshot was pruned), reconstruct a seed from the
|
|
58
|
+
// logs instead. Logs are read across all ports and filtered by the globally
|
|
59
|
+
// unique session id, so a changed instance port never orphans the history.
|
|
60
|
+
loadState: async (id) => await stateStore.load(id) ?? reconstructSessionSeed(id, {
|
|
61
|
+
trendConfig: (await resolveConfig({ path })).checkSettings
|
|
62
|
+
}).catch(() => void 0),
|
|
63
|
+
persistState: (id, state) => stateStore.save(id, state)
|
|
64
|
+
});
|
|
65
|
+
if (typeof ctx.effect === "function") ctx.effect(() => () => {
|
|
66
|
+
void stateStore.dispose();
|
|
67
|
+
});
|
|
68
|
+
const steeredIds = /* @__PURE__ */ new Map();
|
|
69
|
+
const rememberSteer = (message) => {
|
|
70
|
+
const id = isObject(message) && typeof message.id === "string" ? message.id : void 0;
|
|
71
|
+
if (!id) return;
|
|
72
|
+
steeredIds.set(id, true);
|
|
73
|
+
if (steeredIds.size > 256) steeredIds.delete(steeredIds.keys().next().value);
|
|
74
|
+
};
|
|
75
|
+
const steerAgent = (agent, text) => {
|
|
76
|
+
if (typeof agent.steer !== "function") throw new Error("Agent cannot be steered");
|
|
77
|
+
const message = createUserMessage({ content: [{ type: "text", text }], source: { kind: "user" } });
|
|
78
|
+
rememberSteer(message);
|
|
79
|
+
return agent.steer(message);
|
|
80
|
+
};
|
|
81
|
+
const decisionContext = createDecisionContext({ ignored: (id) => steeredIds.has(id) });
|
|
82
|
+
const gate = createDangerGate({
|
|
83
|
+
evaluate: (input, request) => backend.evaluate(input, request),
|
|
84
|
+
sessions,
|
|
85
|
+
rules: async () => normalizeDangerRules((await resolveConfig({ path })).dangerRules),
|
|
86
|
+
// Live config-backed floor (default 0.6): a `deny` only blocks once the model
|
|
87
|
+
// is at least this confident. Read per call so an edit takes effect at once.
|
|
88
|
+
minConfidence: async () => (await resolveConfig({ path })).gateSettings?.minConfidence,
|
|
89
|
+
// Read lazily (only when a dangerous call is recorded) so ordinary tool calls
|
|
90
|
+
// never pay for deriveMessages(). The gate itself still sends only the call
|
|
91
|
+
// summary; this excerpt is local-log context, from the same snapshot the other
|
|
92
|
+
// decision points use.
|
|
93
|
+
taskText: (agent) => decisionContext.snapshot(agent).context.request
|
|
94
|
+
});
|
|
95
|
+
const loopGuard = createLoopGuard({ sessions });
|
|
96
|
+
const featureOn = async (key) => {
|
|
97
|
+
try {
|
|
98
|
+
return featureEnabled((await resolveConfig({ path })).features, key);
|
|
99
|
+
} catch {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
if (typeof ctx.on === "function") {
|
|
104
|
+
if (typeof ctx.tools.guard === "function") {
|
|
105
|
+
ctx.tools.guard((exec) => loopGuard.check(exec));
|
|
106
|
+
ctx.tools.guard((exec) => gate.guardReason(exec));
|
|
107
|
+
}
|
|
108
|
+
const install = () => {
|
|
109
|
+
const disposePre = ctx.on("tools/pre-execute", async (exec, next) => {
|
|
110
|
+
if (!await featureOn("gate")) return next();
|
|
111
|
+
return gate.preExecute(exec, next);
|
|
112
|
+
});
|
|
113
|
+
const disposePost = ctx.on("tools/post-execute", async (exec, result, next) => {
|
|
114
|
+
gate.observeResult(exec, result);
|
|
115
|
+
return next();
|
|
116
|
+
});
|
|
117
|
+
return () => {
|
|
118
|
+
disposePre?.();
|
|
119
|
+
disposePost?.();
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
if (typeof ctx.effect === "function") ctx.effect(install);
|
|
123
|
+
else install();
|
|
124
|
+
const selfCheck = createSelfCheck({
|
|
125
|
+
evaluate: (input, request) => backend.evaluate(input, request),
|
|
126
|
+
sessions,
|
|
127
|
+
settings: async () => (await resolveConfig({ path })).checkSettings,
|
|
128
|
+
steer: steerAgent
|
|
129
|
+
});
|
|
130
|
+
const installCheck = () => ctx.on("agent/turn-stopping", async (payload) => {
|
|
131
|
+
if (!await featureOn("check")) return;
|
|
132
|
+
const snapshot = decisionContext.snapshot(payload.agent);
|
|
133
|
+
return selfCheck.review({
|
|
134
|
+
agent: payload.agent,
|
|
135
|
+
turn: payload.turn,
|
|
136
|
+
signal: payload.signal,
|
|
137
|
+
output: lastAssistantText(payload.agent),
|
|
138
|
+
context: snapshot.context
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
if (typeof ctx.effect === "function") ctx.effect(installCheck);
|
|
142
|
+
else installCheck();
|
|
143
|
+
const taskCompletion = createTaskCompletion({
|
|
144
|
+
evaluate: (input, request) => backend.evaluate(input, request),
|
|
145
|
+
sessions,
|
|
146
|
+
settings: async () => (await resolveConfig({ path })).completeSettings,
|
|
147
|
+
steer: steerAgent
|
|
148
|
+
});
|
|
149
|
+
const installCompletion = () => ctx.on("agent/turn-stopping", async (payload) => {
|
|
150
|
+
if (!await featureOn("complete")) return;
|
|
151
|
+
const snapshot = decisionContext.snapshot(payload.agent);
|
|
152
|
+
return taskCompletion.review({
|
|
153
|
+
agent: payload.agent,
|
|
154
|
+
turn: payload.turn,
|
|
155
|
+
signal: payload.signal,
|
|
156
|
+
output: lastAssistantText(payload.agent),
|
|
157
|
+
// Conditions come only from this turn's real user text; evidence only from
|
|
158
|
+
// this turn's window, so an earlier task's successes cannot vouch for it.
|
|
159
|
+
userText: snapshot.conditionsText,
|
|
160
|
+
evidence: snapshot.evidence,
|
|
161
|
+
context: snapshot.context
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
if (typeof ctx.effect === "function") ctx.effect(installCompletion);
|
|
165
|
+
else installCompletion();
|
|
166
|
+
const narrowing = createNarrowing({
|
|
167
|
+
evaluate: (input, request) => backend.evaluate(input, request),
|
|
168
|
+
sessions,
|
|
169
|
+
settings: async () => (await resolveConfig({ path })).narrowSettings
|
|
170
|
+
});
|
|
171
|
+
const narrowedTurns = /* @__PURE__ */ new Map();
|
|
172
|
+
const releaseNarrowing = (id) => {
|
|
173
|
+
const entry = narrowedTurns.get(id);
|
|
174
|
+
if (!entry) return;
|
|
175
|
+
try {
|
|
176
|
+
entry.dispose?.();
|
|
177
|
+
} catch {
|
|
178
|
+
}
|
|
179
|
+
narrowedTurns.delete(id);
|
|
180
|
+
};
|
|
181
|
+
const installNarrowingHook = () => {
|
|
182
|
+
const disposeHook = ctx.on("agent/pre-step", async (payload, next) => {
|
|
183
|
+
const agent = payload?.agent;
|
|
184
|
+
const id = agent?.session?.id;
|
|
185
|
+
const turn = payload?.turn;
|
|
186
|
+
if (typeof id === "string") await sessions.ensureLoaded(id);
|
|
187
|
+
if (typeof id === "string" && Number.isInteger(turn)) {
|
|
188
|
+
const entered = decisionContext.enter(agent, { turn, messages: payload.messages });
|
|
189
|
+
const previous = narrowedTurns.get(id);
|
|
190
|
+
const turnChanged = !previous || previous.turn !== turn;
|
|
191
|
+
if (turnChanged || entered.changed) {
|
|
192
|
+
releaseNarrowing(id);
|
|
193
|
+
if (await featureOn("narrow")) {
|
|
194
|
+
const entry = { turn, dispose: void 0 };
|
|
195
|
+
narrowedTurns.set(id, entry);
|
|
196
|
+
if (narrowedTurns.size > 1024) {
|
|
197
|
+
const oldest = narrowedTurns.keys().next().value;
|
|
198
|
+
if (oldest !== id) releaseNarrowing(oldest);
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const { context } = entered.snapshot ?? decisionContext.snapshot(agent);
|
|
202
|
+
const result = await narrowing.review({
|
|
203
|
+
agent,
|
|
204
|
+
turn,
|
|
205
|
+
step: payload.step,
|
|
206
|
+
signal: payload.signal,
|
|
207
|
+
state: { request: context.request, background: context.background, progress: context.progress },
|
|
208
|
+
taskText: context.request,
|
|
209
|
+
tools: agentTools(ctx, agent)
|
|
210
|
+
});
|
|
211
|
+
if (result.mode === "enforce" && result.applied && typeof agent?.ctx?.tools?.restrict === "function") {
|
|
212
|
+
const restriction = agent.ctx.tools.restrict({ allow: result.keep });
|
|
213
|
+
if (narrowedTurns.get(id) === entry) entry.dispose = restriction;
|
|
214
|
+
else restriction?.();
|
|
215
|
+
}
|
|
216
|
+
} catch {
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return next();
|
|
222
|
+
});
|
|
223
|
+
return () => {
|
|
224
|
+
disposeHook?.();
|
|
225
|
+
for (const key of [...narrowedTurns.keys()]) releaseNarrowing(key);
|
|
226
|
+
};
|
|
227
|
+
};
|
|
228
|
+
if (typeof ctx.effect === "function") ctx.effect(installNarrowingHook);
|
|
229
|
+
else installNarrowingHook();
|
|
230
|
+
const installNarrowingRelease = () => ctx.on("session/event", (subject, event) => {
|
|
231
|
+
if (!isObject(event) || event.type !== "turn/end") return;
|
|
232
|
+
const id = isObject(subject) && typeof subject.id === "string" ? subject.id : void 0;
|
|
233
|
+
if (id !== void 0) releaseNarrowing(id);
|
|
234
|
+
});
|
|
235
|
+
if (typeof ctx.effect === "function") ctx.effect(installNarrowingRelease);
|
|
236
|
+
else installNarrowingRelease();
|
|
237
|
+
}
|
|
238
|
+
const rawSkill = await readFile(new URL("../../skills/decision-layer/SKILL.md", import.meta.url), "utf8");
|
|
239
|
+
const content = rawSkill.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").trim();
|
|
240
|
+
ctx.skills.register({ name: "decision-layer", description: "\u663E\u5F0F\u53D1\u8D77\u7ED3\u6784\u5316\u88C1\u51B3\uFF0C\u8F85\u52A9\u660E\u786E\u8FB9\u754C\u7684\u9009\u62E9\u4E0E\u8BC4\u5206\u3002", content, source: "bundled" });
|
|
241
|
+
const output = { schema: { type: "object", additionalProperties: true }, render: (_args, value) => [{ type: "text", text: JSON.stringify(value) }] };
|
|
242
|
+
ctx.tools.register({
|
|
243
|
+
name: "decision_evaluate",
|
|
244
|
+
description: "\u5BF9 state \u548C questions \u6267\u884C\u4E00\u6B21\u663E\u5F0F\u7ED3\u6784\u5316\u88C1\u51B3\uFF0C\u4E0D\u4FEE\u6539 agent \u884C\u4E3A\u3002",
|
|
245
|
+
parameters: { type: "object", required: ["state", "questions"], properties: {
|
|
246
|
+
state: { oneOf: [{ type: "string" }, { type: "object", additionalProperties: true }, { type: "array", items: {} }] },
|
|
247
|
+
questions: { type: "object", additionalProperties: { type: "object", additionalProperties: true } }
|
|
248
|
+
}, additionalProperties: false },
|
|
249
|
+
output,
|
|
250
|
+
timeoutMs: 25e3,
|
|
251
|
+
execute: (args, exec) => backend.evaluate(args, { signal: exec.signal })
|
|
252
|
+
});
|
|
253
|
+
ctx.tools.register({
|
|
254
|
+
name: "decision_check_connection",
|
|
255
|
+
description: "\u5411\u88C1\u51B3\u7AEF\u70B9\u53D1\u9001\u4E00\u6B21\u8F7B\u91CF\u8FDE\u901A\u6D4B\u8BD5\uFF0C\u4E0D\u589E\u52A0\u81EA\u52A8\u88C1\u51B3\u6307\u6807\u3002",
|
|
256
|
+
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
257
|
+
output,
|
|
258
|
+
timeoutMs: 25e3,
|
|
259
|
+
execute: async (_args, exec) => {
|
|
260
|
+
const result = await backend.evaluate({ state: "Connection test", questions: {
|
|
261
|
+
connected: { type: "noul", instructions: "Is this a connection test?" }
|
|
262
|
+
} }, { signal: exec.signal });
|
|
263
|
+
return { connected: true, model: result.model, usage: result.usage };
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
ctx.inject(["webServer"], (web) => {
|
|
267
|
+
logStore = createLogStore({ port: web.webServer.port });
|
|
268
|
+
const port = web.webServer.port;
|
|
269
|
+
const analyze = async (sessionId) => analyzeSessionLogs(sessionId, {
|
|
270
|
+
port,
|
|
271
|
+
trendConfig: (await resolveConfig({ path })).checkSettings
|
|
272
|
+
});
|
|
273
|
+
const listRows = (sessionId, { page, pageSize } = {}) => listSessionDecisions(sessionId, { port, page, pageSize });
|
|
274
|
+
const unregister = createManagerRoutes({ path, sessions, backend, logStore, analyze, listRows, bindHost: web.webServer.host }).map((route) => web.webServer.register(route));
|
|
275
|
+
return () => {
|
|
276
|
+
unregister.forEach((dispose) => dispose());
|
|
277
|
+
const store = logStore;
|
|
278
|
+
logStore = void 0;
|
|
279
|
+
void store?.dispose();
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
export {
|
|
284
|
+
apply,
|
|
285
|
+
inject,
|
|
286
|
+
name
|
|
287
|
+
};
|