dsh-decision-layer 0.7.2 → 0.7.4
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 +11 -11
- package/README.md +11 -11
- package/docs/en/DETAIL.md +2 -2
- package/docs/zh/DETAIL.md +2 -2
- package/lib/client.js +88 -25
- package/lib/host/decision-context.d.ts +21 -0
- package/lib/host/decision-context.js +206 -0
- package/lib/host/excerpt.d.ts +1 -0
- package/lib/host/excerpt.js +17 -3
- package/lib/host/index.js +88 -91
- package/lib/host/log-analyze.d.ts +2 -1
- package/lib/host/log-analyze.js +3 -2
- package/lib/host/manager-api.d.ts +1 -0
- package/lib/host/manager-api.js +5 -2
- package/lib/host/narrowing.d.ts +3 -0
- package/lib/host/narrowing.js +3 -2
- package/lib/host/self-check.d.ts +3 -2
- package/lib/host/self-check.js +23 -4
- package/lib/host/session-state.js +1 -0
- package/lib/host/task-completion.d.ts +3 -2
- package/lib/host/task-completion.js +26 -8
- package/lib/host/types.d.ts +22 -0
- package/package.json +1 -1
|
@@ -0,0 +1,206 @@
|
|
|
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
|
+
function clipTail(text, maxBytes) {
|
|
20
|
+
let size = 0;
|
|
21
|
+
let result = "";
|
|
22
|
+
const chars = Array.from(text);
|
|
23
|
+
for (let index = chars.length - 1; index >= 0; index--) {
|
|
24
|
+
const char = chars[index];
|
|
25
|
+
size += Buffer.byteLength(char);
|
|
26
|
+
if (size > maxBytes) break;
|
|
27
|
+
result = char + result;
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
function decisionTail(value, maxBytes = 512) {
|
|
32
|
+
if (typeof value !== "string" || maxBytes <= 0) return "";
|
|
33
|
+
const clean = redactCredentials(stripInjected(value.slice(0, 32 * 1024))).trim();
|
|
34
|
+
return clipTail(clean, maxBytes);
|
|
35
|
+
}
|
|
36
|
+
const OPAQUE = /[A-Za-z0-9_+-]{24,}/g;
|
|
37
|
+
const resultExcerpt = (value, maxBytes) => clip(decisionText(value, 32 * 1024).replace(OPAQUE, "[redacted]"), maxBytes);
|
|
38
|
+
const messagesOf = (value) => Array.isArray(value) ? value.filter(isObject) : [];
|
|
39
|
+
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") : "";
|
|
40
|
+
const human = (message) => message.role === "user" && (!message.source || isObject(message.source) && message.source.kind === "user");
|
|
41
|
+
function historyOf(agent) {
|
|
42
|
+
try {
|
|
43
|
+
const derived = agent?.session?.deriveMessages?.();
|
|
44
|
+
return Array.isArray(derived) ? messagesOf(derived) : void 0;
|
|
45
|
+
} catch {
|
|
46
|
+
return void 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function backgroundOf(messages, isHuman) {
|
|
50
|
+
const lastUser = messages.findLastIndex(isHuman);
|
|
51
|
+
const lastAssistant = messages.findLastIndex((message) => message.role === "assistant");
|
|
52
|
+
return [lastUser, lastAssistant].filter((index) => index >= 0).sort((a, b) => a - b).map((index) => {
|
|
53
|
+
const message = messages[index];
|
|
54
|
+
if (!message) return void 0;
|
|
55
|
+
const role = isHuman(message) ? "user" : "assistant";
|
|
56
|
+
const raw = textOf(message);
|
|
57
|
+
const excerpt = role === "user" ? decisionText(raw, 512) : decisionTail(raw, 512);
|
|
58
|
+
return { role, text: excerpt };
|
|
59
|
+
}).filter((item) => Boolean(item?.text));
|
|
60
|
+
}
|
|
61
|
+
function evidenceOf(messages) {
|
|
62
|
+
const calls = /* @__PURE__ */ new Map();
|
|
63
|
+
const evidence = [];
|
|
64
|
+
for (const message of messages) {
|
|
65
|
+
if (message.role === "assistant" && Array.isArray(message.content)) {
|
|
66
|
+
for (const block of message.content.filter(isObject)) {
|
|
67
|
+
if (block.type !== "tool-call" || typeof block.id !== "string" || typeof block.name !== "string") continue;
|
|
68
|
+
let args = block.arguments;
|
|
69
|
+
if (typeof args === "string") {
|
|
70
|
+
try {
|
|
71
|
+
args = JSON.parse(args);
|
|
72
|
+
} catch {
|
|
73
|
+
args = void 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
calls.set(block.id, { name: block.name, arguments: args });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (message.role !== "tool") continue;
|
|
80
|
+
const source = isObject(message.source) ? message.source : {};
|
|
81
|
+
const id = typeof message.toolCallId === "string" ? message.toolCallId : source.callId;
|
|
82
|
+
const call = typeof id === "string" ? calls.get(id) : void 0;
|
|
83
|
+
const name = call?.name ?? (typeof message.name === "string" ? message.name : typeof message.toolName === "string" ? message.toolName : "");
|
|
84
|
+
if (!name) continue;
|
|
85
|
+
const item = { tool: decisionText(name, 96), outcome: message.isError === true ? "error" : "ok" };
|
|
86
|
+
if (call) {
|
|
87
|
+
const summary = summarizeExecution(call);
|
|
88
|
+
if (summary.command) item.command = decisionText(summary.command, 160);
|
|
89
|
+
if (summary.path) item.path = decisionText(summary.path, 160);
|
|
90
|
+
}
|
|
91
|
+
if (["bash", "pwsh", "shell", "exec", "terminal", "run_command"].includes(name)) {
|
|
92
|
+
const result = resultExcerpt(textOf(message), 256);
|
|
93
|
+
if (result) item.result = result;
|
|
94
|
+
}
|
|
95
|
+
evidence.push(item);
|
|
96
|
+
}
|
|
97
|
+
const bounded = evidence.length > 12 ? [...evidence.slice(0, 4), ...evidence.slice(-8)] : [...evidence];
|
|
98
|
+
while (Buffer.byteLength(JSON.stringify(bounded)) > 4096) bounded.shift();
|
|
99
|
+
return bounded;
|
|
100
|
+
}
|
|
101
|
+
const REPLACEMENTS = /* @__PURE__ */ new Set(["compact-checkpoint", "dsh-rewind"]);
|
|
102
|
+
function afterLastReplacement(messages) {
|
|
103
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
104
|
+
const message = messages[i];
|
|
105
|
+
if (!message) continue;
|
|
106
|
+
const source = isObject(message.source) ? message.source : void 0;
|
|
107
|
+
if (source && typeof source.kind === "string" && REPLACEMENTS.has(source.kind)) return messages.slice(i + 1);
|
|
108
|
+
}
|
|
109
|
+
return messages;
|
|
110
|
+
}
|
|
111
|
+
function boundaryHolds(history, boundary) {
|
|
112
|
+
if (boundary.start <= 0) return true;
|
|
113
|
+
const previous = history[boundary.start - 1];
|
|
114
|
+
if (!previous) return false;
|
|
115
|
+
return previous === boundary.anchor || boundary.anchorId !== void 0 && previous.id === boundary.anchorId;
|
|
116
|
+
}
|
|
117
|
+
function snapshotOf(history, boundary, isHuman) {
|
|
118
|
+
const messages = history ?? [];
|
|
119
|
+
const lastHuman = messages.findLastIndex(isHuman);
|
|
120
|
+
const window = boundary === void 0 ? messages.slice(lastHuman >= 0 ? lastHuman : messages.length) : boundary.broken ? [] : boundaryHolds(messages, boundary) ? messages.slice(boundary.start) : [];
|
|
121
|
+
const current = afterLastReplacement(window);
|
|
122
|
+
const requests = boundary?.requests ?? current.filter(isHuman).map((message) => decisionText(textOf(message), 8192));
|
|
123
|
+
const request = decisionText(requests.at(-1) ?? "", 1536);
|
|
124
|
+
const evidence = evidenceOf(current);
|
|
125
|
+
const context = {
|
|
126
|
+
request,
|
|
127
|
+
// Earlier real requests in this turn stay visible as background: with only the
|
|
128
|
+
// last one, a short follow-up ("继续") would hide the main task. Pre-turn
|
|
129
|
+
// context is kept too, but the size cap below drops it first.
|
|
130
|
+
background: [
|
|
131
|
+
...boundary?.background ?? backgroundOf(messages.slice(0, Math.max(lastHuman, 0)), isHuman),
|
|
132
|
+
...requests.slice(0, -1).slice(-2).map((text) => ({ role: "user", text: decisionText(text, 1024) }))
|
|
133
|
+
].slice(-4),
|
|
134
|
+
progress: evidence.slice(-3)
|
|
135
|
+
};
|
|
136
|
+
while (Buffer.byteLength(JSON.stringify(context.progress)) > 1024 && context.progress.length > 1) context.progress.shift();
|
|
137
|
+
while (Buffer.byteLength(JSON.stringify(context)) > 4096 && (context.background.length || context.progress.length > 1)) {
|
|
138
|
+
if (context.background.length) context.background.shift();
|
|
139
|
+
else context.progress.shift();
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
context,
|
|
143
|
+
evidence,
|
|
144
|
+
// Completion conditions come from the latest real request only. Joining a
|
|
145
|
+
// turn's requests would resurrect work the user just cancelled ("drop the
|
|
146
|
+
// earlier list, only review"); when the latest request has no explicit
|
|
147
|
+
// conditions the check skips rather than infer from an older revision.
|
|
148
|
+
conditionsText: requests.at(-1) ?? ""
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function createDecisionContext({ ignored } = {}) {
|
|
152
|
+
const boundaries = /* @__PURE__ */ new WeakMap();
|
|
153
|
+
const isHuman = (message) => human(message) && !(typeof message.id === "string" && ignored?.(message.id) === true);
|
|
154
|
+
const enter = (agent, position) => {
|
|
155
|
+
const known = agent ? boundaries.get(agent) : void 0;
|
|
156
|
+
const first = !known || known.turn !== position.turn;
|
|
157
|
+
const inputs = messagesOf(position.messages).filter(isHuman).map((message) => decisionText(textOf(message), 8192)).filter(Boolean);
|
|
158
|
+
if (!first && inputs.length === 0) return { changed: false };
|
|
159
|
+
const history = historyOf(agent);
|
|
160
|
+
let boundary = known;
|
|
161
|
+
const anchor = history?.at(-1);
|
|
162
|
+
if (first) {
|
|
163
|
+
boundary = {
|
|
164
|
+
turn: position.turn,
|
|
165
|
+
start: history?.length ?? 0,
|
|
166
|
+
anchor,
|
|
167
|
+
broken: history === void 0,
|
|
168
|
+
...isObject(anchor) && typeof anchor.id === "string" ? { anchorId: anchor.id } : {},
|
|
169
|
+
requests: [],
|
|
170
|
+
background: history ? backgroundOf(history, isHuman) : []
|
|
171
|
+
};
|
|
172
|
+
if (agent) boundaries.set(agent, boundary);
|
|
173
|
+
} else if (boundary?.broken) {
|
|
174
|
+
boundary.broken = history === void 0;
|
|
175
|
+
boundary.start = history?.length ?? 0;
|
|
176
|
+
boundary.anchor = anchor;
|
|
177
|
+
if (isObject(anchor) && typeof anchor.id === "string") boundary.anchorId = anchor.id;
|
|
178
|
+
else delete boundary.anchorId;
|
|
179
|
+
boundary.background = history ? backgroundOf(history, isHuman) : [];
|
|
180
|
+
}
|
|
181
|
+
const active = boundary;
|
|
182
|
+
active.requests.push(...inputs);
|
|
183
|
+
active.requests = active.requests.slice(-8);
|
|
184
|
+
return { changed: true, snapshot: snapshotOf(history, active, isHuman) };
|
|
185
|
+
};
|
|
186
|
+
return {
|
|
187
|
+
enter(agent, position) {
|
|
188
|
+
try {
|
|
189
|
+
return enter(agent, position);
|
|
190
|
+
} catch {
|
|
191
|
+
return { changed: false };
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
snapshot(agent) {
|
|
195
|
+
try {
|
|
196
|
+
return snapshotOf(historyOf(agent), agent ? boundaries.get(agent) : void 0, isHuman);
|
|
197
|
+
} catch {
|
|
198
|
+
return { context: { request: "", background: [], progress: [] }, conditionsText: "", evidence: [] };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
export {
|
|
204
|
+
createDecisionContext,
|
|
205
|
+
decisionText
|
|
206
|
+
};
|
package/lib/host/excerpt.d.ts
CHANGED
|
@@ -22,4 +22,5 @@ export declare const TASK_EXCERPT_LIMIT = 512;
|
|
|
22
22
|
* a secret to the log.
|
|
23
23
|
*/
|
|
24
24
|
export declare function redactCredentials(input: string): string;
|
|
25
|
+
export declare function stripInjected(input: string): string;
|
|
25
26
|
export declare function taskExcerpt(state: unknown, limit?: number): string | undefined;
|
package/lib/host/excerpt.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
const MAX_INPUT = 8 * 1024;
|
|
2
2
|
const TASK_EXCERPT_LIMIT = 512;
|
|
3
3
|
const TOKEN_KEYS = String.raw`token|api[ _-]*key`;
|
|
4
|
-
const PHRASE_KEYS = String.raw`password|
|
|
4
|
+
const PHRASE_KEYS = String.raw`password|passw(?:or)?d|passphrase|secret[ _-]?key|access[ _-]?key|private[ _-]?key|secret`;
|
|
5
5
|
const CN_KEYS = String.raw`密码|口令|密码短语|密钥|令牌|凭证|私钥`;
|
|
6
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`;
|
|
7
9
|
const SEPARATOR = String.raw`[:=:=]`;
|
|
8
10
|
const VALUE = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;},;、]+)`;
|
|
9
11
|
const CLAUSE = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\r\n,;,;、]*)`;
|
|
@@ -30,6 +32,13 @@ const RULE = {
|
|
|
30
32
|
asciiCopula: new RegExp(String.raw`(\b(?:${COMBINED_KEYS})\b\s*(?:${COPULA})\s*)${VALUE_LIKE}${VALUE}`, "gi"),
|
|
31
33
|
cnCopula: new RegExp(String.raw`((?:${CN_KEYS})\s*(?:是|为)\s*)${VALUE_LIKE}${VALUE}`, "g"),
|
|
32
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"),
|
|
33
42
|
// Legacy space form (`token abc`), but never swallow a copula: the rule above
|
|
34
43
|
// masks the value that follows it.
|
|
35
44
|
loose: new RegExp(String.raw`(\b(?:${COMBINED_KEYS})\b\s+)(?!(?:${COPULA})(?:\s|$)|${SEPARATOR})${VALUE}`, "gi")
|
|
@@ -45,18 +54,23 @@ function redactCredentials(input) {
|
|
|
45
54
|
text = text.replace(RULE.asciiCopula, "$1[redacted]");
|
|
46
55
|
text = text.replace(RULE.cnCopula, "$1[redacted]");
|
|
47
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]");
|
|
48
59
|
text = text.replace(RULE.loose, "$1[redacted]");
|
|
49
60
|
for (const [pattern, replacement] of TOKEN_SHAPES) text = text.replace(pattern, replacement);
|
|
50
61
|
return text;
|
|
51
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
|
+
}
|
|
52
66
|
function taskExcerpt(state, limit = TASK_EXCERPT_LIMIT) {
|
|
53
67
|
if (typeof state !== "string") return void 0;
|
|
54
|
-
const
|
|
55
|
-
const text = redactCredentials(stripped).replace(/\s+/g, " ").trim();
|
|
68
|
+
const text = redactCredentials(stripInjected(state.slice(0, MAX_INPUT))).replace(/\s+/g, " ").trim();
|
|
56
69
|
return text ? text.slice(0, limit) : void 0;
|
|
57
70
|
}
|
|
58
71
|
export {
|
|
59
72
|
TASK_EXCERPT_LIMIT,
|
|
60
73
|
redactCredentials,
|
|
74
|
+
stripInjected,
|
|
61
75
|
taskExcerpt
|
|
62
76
|
};
|
package/lib/host/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { createTaskCompletion } from "./task-completion.js";
|
|
|
11
11
|
import { createLoopGuard } from "./loop-guard.js";
|
|
12
12
|
import { createLogStore } from "./log-store.js";
|
|
13
13
|
import { createStateStore } from "./state-store.js";
|
|
14
|
+
import { createDecisionContext } from "./decision-context.js";
|
|
14
15
|
import { analyzeSessionLogs, reconstructSessionSeed, listSessionDecisions } from "./log-analyze.js";
|
|
15
16
|
import { isObject } from "./types.js";
|
|
16
17
|
const name = "dsh-decision-layer";
|
|
@@ -35,50 +36,6 @@ function lastAssistantText(agent) {
|
|
|
35
36
|
return "";
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
|
-
function stepTaskText(messages) {
|
|
39
|
-
try {
|
|
40
|
-
if (!Array.isArray(messages)) return "";
|
|
41
|
-
return messages.map(asMessage).filter((message) => message?.role === "user" && Array.isArray(message.content)).flatMap((message) => message.content.filter((block) => isObject(block) && block.type === "text" && typeof block.text === "string").map((block) => block.text)).join("\n").slice(0, 8 * 1024);
|
|
42
|
-
} catch {
|
|
43
|
-
return "";
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
function turnUserText(agent) {
|
|
47
|
-
try {
|
|
48
|
-
const messages = derivedMessages(agent);
|
|
49
|
-
if (!messages) return "";
|
|
50
|
-
const texts = [];
|
|
51
|
-
let collecting = false;
|
|
52
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
53
|
-
const message = asMessage(messages[i]);
|
|
54
|
-
if (message?.role === "user" && Array.isArray(message.content)) {
|
|
55
|
-
collecting = true;
|
|
56
|
-
texts.unshift(message.content.filter((block) => isObject(block) && block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n"));
|
|
57
|
-
} else if (collecting) break;
|
|
58
|
-
}
|
|
59
|
-
return texts.join("\n").slice(0, 8 * 1024);
|
|
60
|
-
} catch {
|
|
61
|
-
return "";
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
function turnToolEvidence(agent) {
|
|
65
|
-
try {
|
|
66
|
-
const messages = derivedMessages(agent);
|
|
67
|
-
if (!messages) return [];
|
|
68
|
-
const evidence = [];
|
|
69
|
-
for (const item of messages) {
|
|
70
|
-
const message = asMessage(item);
|
|
71
|
-
if (message?.role !== "tool" || !Array.isArray(message.content)) continue;
|
|
72
|
-
const tool = typeof message.name === "string" ? message.name : typeof message.toolName === "string" ? message.toolName : "";
|
|
73
|
-
if (!tool) continue;
|
|
74
|
-
evidence.push({ tool: tool.slice(0, 128), outcome: message.isError === true ? "error" : "ok" });
|
|
75
|
-
if (evidence.length >= 40) break;
|
|
76
|
-
}
|
|
77
|
-
return evidence;
|
|
78
|
-
} catch {
|
|
79
|
-
return [];
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
39
|
function agentTools(ctx, agent) {
|
|
83
40
|
try {
|
|
84
41
|
const schemas = typeof ctx.tools?.schemas === "function" ? ctx.tools.schemas(agent) : void 0;
|
|
@@ -108,6 +65,20 @@ async function apply(ctx, options = {}) {
|
|
|
108
65
|
if (typeof ctx.effect === "function") ctx.effect(() => () => {
|
|
109
66
|
void stateStore.dispose();
|
|
110
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) });
|
|
111
82
|
const gate = createDangerGate({
|
|
112
83
|
evaluate: (input, request) => backend.evaluate(input, request),
|
|
113
84
|
sessions,
|
|
@@ -116,8 +87,10 @@ async function apply(ctx, options = {}) {
|
|
|
116
87
|
// is at least this confident. Read per call so an edit takes effect at once.
|
|
117
88
|
minConfidence: async () => (await resolveConfig({ path })).gateSettings?.minConfidence,
|
|
118
89
|
// Read lazily (only when a dangerous call is recorded) so ordinary tool calls
|
|
119
|
-
// never pay for deriveMessages().
|
|
120
|
-
|
|
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
|
|
121
94
|
});
|
|
122
95
|
const loopGuard = createLoopGuard({ sessions });
|
|
123
96
|
const featureOn = async (key) => {
|
|
@@ -152,19 +125,17 @@ async function apply(ctx, options = {}) {
|
|
|
152
125
|
evaluate: (input, request) => backend.evaluate(input, request),
|
|
153
126
|
sessions,
|
|
154
127
|
settings: async () => (await resolveConfig({ path })).checkSettings,
|
|
155
|
-
steer:
|
|
156
|
-
if (typeof agent.steer !== "function") throw new Error("Agent cannot be steered");
|
|
157
|
-
return agent.steer(createUserMessage({ content: [{ type: "text", text }], source: { kind: "user" } }));
|
|
158
|
-
}
|
|
128
|
+
steer: steerAgent
|
|
159
129
|
});
|
|
160
130
|
const installCheck = () => ctx.on("agent/turn-stopping", async (payload) => {
|
|
161
131
|
if (!await featureOn("check")) return;
|
|
132
|
+
const snapshot = decisionContext.snapshot(payload.agent);
|
|
162
133
|
return selfCheck.review({
|
|
163
134
|
agent: payload.agent,
|
|
164
135
|
turn: payload.turn,
|
|
165
136
|
signal: payload.signal,
|
|
166
137
|
output: lastAssistantText(payload.agent),
|
|
167
|
-
|
|
138
|
+
context: snapshot.context
|
|
168
139
|
});
|
|
169
140
|
});
|
|
170
141
|
if (typeof ctx.effect === "function") ctx.effect(installCheck);
|
|
@@ -173,20 +144,21 @@ async function apply(ctx, options = {}) {
|
|
|
173
144
|
evaluate: (input, request) => backend.evaluate(input, request),
|
|
174
145
|
sessions,
|
|
175
146
|
settings: async () => (await resolveConfig({ path })).completeSettings,
|
|
176
|
-
steer:
|
|
177
|
-
if (typeof agent.steer !== "function") throw new Error("Agent cannot be steered");
|
|
178
|
-
return agent.steer(createUserMessage({ content: [{ type: "text", text }], source: { kind: "user" } }));
|
|
179
|
-
}
|
|
147
|
+
steer: steerAgent
|
|
180
148
|
});
|
|
181
149
|
const installCompletion = () => ctx.on("agent/turn-stopping", async (payload) => {
|
|
182
150
|
if (!await featureOn("complete")) return;
|
|
151
|
+
const snapshot = decisionContext.snapshot(payload.agent);
|
|
183
152
|
return taskCompletion.review({
|
|
184
153
|
agent: payload.agent,
|
|
185
154
|
turn: payload.turn,
|
|
186
155
|
signal: payload.signal,
|
|
187
156
|
output: lastAssistantText(payload.agent),
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
190
162
|
});
|
|
191
163
|
});
|
|
192
164
|
if (typeof ctx.effect === "function") ctx.effect(installCompletion);
|
|
@@ -197,46 +169,71 @@ async function apply(ctx, options = {}) {
|
|
|
197
169
|
settings: async () => (await resolveConfig({ path })).narrowSettings
|
|
198
170
|
});
|
|
199
171
|
const narrowedTurns = /* @__PURE__ */ new Map();
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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);
|
|
218
199
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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 {
|
|
230
217
|
}
|
|
231
|
-
} catch {
|
|
232
218
|
}
|
|
233
219
|
}
|
|
234
220
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
221
|
+
return next();
|
|
222
|
+
});
|
|
223
|
+
return () => {
|
|
224
|
+
disposeHook?.();
|
|
225
|
+
for (const key of [...narrowedTurns.keys()]) releaseNarrowing(key);
|
|
226
|
+
};
|
|
227
|
+
};
|
|
238
228
|
if (typeof ctx.effect === "function") ctx.effect(installNarrowingHook);
|
|
239
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();
|
|
240
237
|
}
|
|
241
238
|
const rawSkill = await readFile(new URL("../../skills/decision-layer/SKILL.md", import.meta.url), "utf8");
|
|
242
239
|
const content = rawSkill.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").trim();
|
|
@@ -273,7 +270,7 @@ async function apply(ctx, options = {}) {
|
|
|
273
270
|
port,
|
|
274
271
|
trendConfig: (await resolveConfig({ path })).checkSettings
|
|
275
272
|
});
|
|
276
|
-
const listRows = (sessionId, { page, pageSize } = {}) => listSessionDecisions(sessionId, { port, page, pageSize });
|
|
273
|
+
const listRows = (sessionId, { page, pageSize, lowOnly } = {}) => listSessionDecisions(sessionId, { port, page, pageSize, lowOnly });
|
|
277
274
|
const unregister = createManagerRoutes({ path, sessions, backend, logStore, analyze, listRows, bindHost: web.webServer.host }).map((route) => web.webServer.register(route));
|
|
278
275
|
return () => {
|
|
279
276
|
unregister.forEach((dispose) => dispose());
|
|
@@ -106,11 +106,12 @@ export declare function reconstructSessionSeed(sessionId: unknown, { dir, port,
|
|
|
106
106
|
maxLog?: number;
|
|
107
107
|
readStream?: ReadStream;
|
|
108
108
|
}): Promise<SessionSeed | undefined>;
|
|
109
|
-
export declare function listSessionDecisions(sessionId: unknown, { dir, port, page, pageSize, readStream }?: {
|
|
109
|
+
export declare function listSessionDecisions(sessionId: unknown, { dir, port, page, pageSize, lowOnly, readStream }?: {
|
|
110
110
|
dir?: string;
|
|
111
111
|
port?: number;
|
|
112
112
|
page?: number;
|
|
113
113
|
pageSize?: number;
|
|
114
|
+
lowOnly?: boolean;
|
|
114
115
|
readStream?: ReadStream;
|
|
115
116
|
}): Promise<{
|
|
116
117
|
entries: LogRow[];
|
package/lib/host/log-analyze.js
CHANGED
|
@@ -220,7 +220,7 @@ async function reconstructSessionSeed(sessionId, { dir, port, trendConfig, maxLo
|
|
|
220
220
|
if (decisions.size === 0) return void 0;
|
|
221
221
|
return buildSeed(decisions, { maxLog, trendConfig });
|
|
222
222
|
}
|
|
223
|
-
async function listSessionDecisions(sessionId, { dir, port, page = 1, pageSize = 100, readStream = createReadableStream } = {}) {
|
|
223
|
+
async function listSessionDecisions(sessionId, { dir, port, page = 1, pageSize = 100, lowOnly = false, readStream = createReadableStream } = {}) {
|
|
224
224
|
if (typeof sessionId !== "string" || !sessionId.trim()) throw new Error("Invalid session id");
|
|
225
225
|
const size = Number.isSafeInteger(pageSize) && pageSize > 0 && pageSize <= 500 ? pageSize : 100;
|
|
226
226
|
const records = [];
|
|
@@ -229,7 +229,8 @@ async function listSessionDecisions(sessionId, { dir, port, page = 1, pageSize =
|
|
|
229
229
|
if (record.sessionId === sessionId) records.push(record);
|
|
230
230
|
}
|
|
231
231
|
const { decisions } = foldSession(records);
|
|
232
|
-
const
|
|
232
|
+
const all = [...decisions.values()].sort((a, b) => atOf(b) - atOf(a));
|
|
233
|
+
const rows = lowOnly ? all.filter((record) => record.kind === "check" && record.outcome === "low") : all;
|
|
233
234
|
const total = rows.length;
|
|
234
235
|
const pages = Math.max(1, Math.ceil(total / size));
|
|
235
236
|
const current = Math.min(Math.max(1, Number.isSafeInteger(page) ? page : 1), pages);
|
package/lib/host/manager-api.js
CHANGED
|
@@ -111,7 +111,9 @@ function createManagerRoutes({ path, sessions, backend, logStore, analyze, listR
|
|
|
111
111
|
}
|
|
112
112
|
}),
|
|
113
113
|
// One newest-first page of this session's full persisted decision rows, for
|
|
114
|
-
// the analysis tab's paginated log.
|
|
114
|
+
// the analysis tab's paginated log. `lowOnly=1` filters to low self-check
|
|
115
|
+
// scores before paging, so a single low score in a long session is one click
|
|
116
|
+
// away instead of a manual scan. Rows carry the same panel fields as /log,
|
|
115
117
|
// including the gate's already-redacted and length-bounded command/path, so
|
|
116
118
|
// this exposes nothing the recent-rows route does not already.
|
|
117
119
|
route("logrows", {
|
|
@@ -121,8 +123,9 @@ function createManagerRoutes({ path, sessions, backend, logStore, analyze, listR
|
|
|
121
123
|
const params = new URL(req.url ?? "/", "http://localhost").searchParams;
|
|
122
124
|
const page = Number.parseInt(params.get("page") ?? "1", 10);
|
|
123
125
|
const pageSize = Number.parseInt(params.get("pageSize") ?? "100", 10);
|
|
126
|
+
const lowOnly = params.get("lowOnly") === "1";
|
|
124
127
|
try {
|
|
125
|
-
return await listRows(id, { page: Number.isNaN(page) ? 1 : page, pageSize: Number.isNaN(pageSize) ? 100 : pageSize });
|
|
128
|
+
return await listRows(id, { page: Number.isNaN(page) ? 1 : page, pageSize: Number.isNaN(pageSize) ? 100 : pageSize, ...lowOnly ? { lowOnly } : {} });
|
|
126
129
|
} catch (error) {
|
|
127
130
|
if (error?.code === "BAD_REQUEST") throw error;
|
|
128
131
|
throw codedError("log rows failed", "INTERNAL");
|
package/lib/host/narrowing.d.ts
CHANGED
|
@@ -19,8 +19,11 @@ export declare function relevanceQuestions(state: unknown, tools: readonly ToolC
|
|
|
19
19
|
export type NarrowTurn = {
|
|
20
20
|
agent?: AgentLike;
|
|
21
21
|
turn?: number;
|
|
22
|
+
step?: number;
|
|
22
23
|
signal?: AbortSignal;
|
|
23
24
|
state?: unknown;
|
|
25
|
+
/** Raw request text for the log row; `state` may now be a structured object. */
|
|
26
|
+
taskText?: unknown;
|
|
24
27
|
tools?: unknown;
|
|
25
28
|
};
|
|
26
29
|
export type NarrowResult = {
|
package/lib/host/narrowing.js
CHANGED
|
@@ -106,9 +106,10 @@ function createNarrowing({
|
|
|
106
106
|
}
|
|
107
107
|
if (typeof sessions.log !== "function") return;
|
|
108
108
|
try {
|
|
109
|
-
const task = taskExcerpt(turn?.state);
|
|
109
|
+
const task = taskExcerpt(turn?.taskText ?? turn?.state);
|
|
110
110
|
const context = task ? { task } : {};
|
|
111
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;
|
|
112
113
|
sessions.log(id, { kind: "narrow", outcome, ...context, ...detail });
|
|
113
114
|
} catch {
|
|
114
115
|
}
|
|
@@ -146,7 +147,7 @@ function createNarrowing({
|
|
|
146
147
|
const keptNames = names.filter(alwaysKeep);
|
|
147
148
|
const optional = tools.filter((tool) => !alwaysKeep(tool.name));
|
|
148
149
|
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
|
+
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;
|
|
150
151
|
if (!hasState) return { ...skip, keep: names };
|
|
151
152
|
const id = turn?.agent?.session?.id;
|
|
152
153
|
if (sessions && typeof id === "string") {
|
package/lib/host/self-check.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { AgentLike, CheckSettings, EvaluateInput, Evaluator, SessionsFace } from './types.js';
|
|
1
|
+
import type { AgentLike, CheckSettings, DecisionContext, EvaluateInput, Evaluator, SessionsFace } from './types.js';
|
|
2
2
|
export declare const DEFAULT_RUBRIC: readonly string[];
|
|
3
|
-
export declare function scoreQuestion(output: unknown, rubric?: readonly string[]): EvaluateInput;
|
|
3
|
+
export declare function scoreQuestion(output: unknown, rubric?: readonly string[], context?: DecisionContext): EvaluateInput;
|
|
4
4
|
export declare const DEFAULT_MIN_CONFIDENCE = 0.4;
|
|
5
5
|
export type CheckTurn = {
|
|
6
6
|
agent?: AgentLike;
|
|
@@ -8,6 +8,7 @@ export type CheckTurn = {
|
|
|
8
8
|
signal?: AbortSignal;
|
|
9
9
|
output?: unknown;
|
|
10
10
|
userText?: unknown;
|
|
11
|
+
context?: DecisionContext;
|
|
11
12
|
};
|
|
12
13
|
export type CheckResult = {
|
|
13
14
|
evaluated: boolean;
|