dsh-subagent-profile 0.3.2 → 0.3.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.md +77 -40
- package/README.zh.md +111 -74
- package/docs/screenshots/dispatch-card.png +0 -0
- package/docs/screenshots/settings-page1.png +0 -0
- package/docs/screenshots/settings-page2.png +0 -0
- package/index.mjs +276 -81
- package/lib/client.js +3218 -166
- package/lib/core/adoption-reminder.mjs +48 -0
- package/lib/core/adoption-tracker.mjs +430 -0
- package/lib/core/background-ledger.mjs +71 -0
- package/lib/core/catalog-cache.mjs +45 -7
- package/lib/core/catalog.mjs +6 -6
- package/lib/core/cost-evidence.mjs +145 -0
- package/lib/core/cost-guard.mjs +71 -44
- package/lib/core/decision-trace.mjs +413 -0
- package/lib/core/delegation.mjs +111 -50
- package/lib/core/dispatch-gates.mjs +153 -0
- package/lib/core/dispatch-guard.mjs +156 -0
- package/lib/core/dispatch-schema.mjs +103 -14
- package/lib/core/dispatch-tool.mjs +220 -204
- package/lib/core/draft-gates.mjs +45 -0
- package/lib/core/drafts-store.mjs +45 -0
- package/lib/core/escape.mjs +130 -0
- package/lib/core/evolution-advice.mjs +224 -0
- package/lib/core/evolution-ledger.mjs +300 -0
- package/lib/core/evolution-summary.mjs +255 -0
- package/lib/core/http-routes.mjs +256 -72
- package/lib/core/intersection.mjs +6 -9
- package/lib/core/presets-sync.mjs +161 -43
- package/lib/core/prices.mjs +46 -0
- package/lib/core/profile-directory.mjs +139 -0
- package/lib/core/profile-provider.mjs +42 -39
- package/lib/core/profiles-store.mjs +103 -76
- package/lib/core/pure.mjs +110 -66
- package/lib/core/reminder-store.mjs +172 -0
- package/lib/core/shims.mjs +67 -76
- package/lib/core/whitelist.mjs +23 -17
- package/package.json +82 -83
- package/presets/orchestrator/agent.cordis.yml +59 -87
- package/presets/orchestrator/NOTICE +0 -3
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { DISPATCH_PARAMETER_KEYS } from './dispatch-schema.mjs';
|
|
3
|
+
export { profileSnapshotOf } from './profile-directory.mjs';
|
|
4
|
+
|
|
5
|
+
// lib/core/decision-trace.mjs — dispatch 决策轨迹(decisionTrace)的纯组装与
|
|
6
|
+
// 失败台账。纯记录层:只「记录」不参与任何派发行为;轨迹经
|
|
7
|
+
// output.presentationMeta 投影进会话块 meta 供客户端台账展示,绝不进模型可见面
|
|
8
|
+
// (render 行 / 结果文本)。
|
|
9
|
+
//
|
|
10
|
+
// 边界(后来开发者须知):
|
|
11
|
+
// * 本模块无 @deepseek-ai 依赖(仅 node 内置 fs),可被 bare-CI 单测直接 import。
|
|
12
|
+
// * 台账默认是会话级内存结构(不传 stateFile 时进程重启即失);传入 stateFile
|
|
13
|
+
// 时每次 record/take 后全量同步原子落盘(JSON {version:1,sessions}),构造时
|
|
14
|
+
// 加载(损坏/版本或形状不符 fail-soft 从空台账起步),clear 只清内存不删文件。
|
|
15
|
+
// * 体积护栏分两级:recordGate 内工具/预设名单类数组截断前 8 项并打
|
|
16
|
+
// truncated:true 标记;assertTraceSize 保证单条 trace JSON ≤ MAX_TRACE_BYTES,
|
|
17
|
+
// 超出时逐级截断(detail/reason 长文本 → checks 数组 → settled.calls 数组 →
|
|
18
|
+
// 丢弃超长 detail → 全树字符串截断 → 丢 settled → 逐闸丢弃,恒收敛)。
|
|
19
|
+
// * 截断阈值经实测校准:调用级明细 6 条五段 JSON 约 0.7KB + trace 骨架约 1.5KB;
|
|
20
|
+
// 2026-08 评审第二轮加入决策输入快照(≤6 条 × ≤200B ≈ 1KB)后预算上调至
|
|
21
|
+
// 4096,calls 超预算时截到前 4 条。
|
|
22
|
+
// * 其余阈值(200 字符 / 6 条 checks / 8 项名单 / 快照 6 条)为建议值,待实测校准。
|
|
23
|
+
|
|
24
|
+
const MAX_LIST_ITEMS = 8;
|
|
25
|
+
const MAX_TRACE_BYTES = 4096;
|
|
26
|
+
const MAX_TEXT_CHARS = 200;
|
|
27
|
+
const MAX_CHECKS = 6;
|
|
28
|
+
const MAX_CALLS = 4;
|
|
29
|
+
|
|
30
|
+
// 名单类数组截断:长度超过 MAX_LIST_ITEMS 且元素全为字符串的数组(工具/预设名
|
|
31
|
+
// 单)替换为 { values: 前 8 项, truncated: true }。数值类数组(如 usage)元素非
|
|
32
|
+
// 字符串,不截断,避免误伤。
|
|
33
|
+
function truncateLists(value) {
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
const isNameList = value.length > MAX_LIST_ITEMS && value.every((item) => typeof item === 'string');
|
|
36
|
+
return isNameList ? { values: value.slice(0, MAX_LIST_ITEMS), truncated: true } : value;
|
|
37
|
+
}
|
|
38
|
+
if (value !== null && typeof value === 'object') {
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const key of Object.keys(value)) out[key] = truncateLists(value[key]);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 单条 trace 骨架。parentContext / requested 由调用方组装后传入(调用方能拿到
|
|
47
|
+
// 运行时的父会话快照与请求参数)。gates 初始为空,逐道闸经 recordGate 追加。
|
|
48
|
+
export function createDecisionTrace(parentContext, requested, startedAt = Date.now()) {
|
|
49
|
+
return {
|
|
50
|
+
version: 1,
|
|
51
|
+
startedAt,
|
|
52
|
+
parentContext: parentContext ?? {},
|
|
53
|
+
requested: requested ?? {},
|
|
54
|
+
gates: [],
|
|
55
|
+
effective: undefined,
|
|
56
|
+
execution: undefined,
|
|
57
|
+
settled: undefined,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 记录一道闸。gate = { name, input, output, verdict, reason };input/output 内的
|
|
62
|
+
// 名单类数组在此截断(见 truncateLists)。reason 缺省时不写入(保持体积)。
|
|
63
|
+
export function recordGate(trace, gate) {
|
|
64
|
+
trace.gates.push({
|
|
65
|
+
name: gate.name,
|
|
66
|
+
input: truncateLists(gate.input ?? {}),
|
|
67
|
+
output: truncateLists(gate.output ?? {}),
|
|
68
|
+
verdict: gate.verdict,
|
|
69
|
+
...(gate.reason !== undefined ? { reason: gate.reason } : {}),
|
|
70
|
+
});
|
|
71
|
+
return trace;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 定稿:写入 effective / execution / settled(三者均缺省时不写)。后台/continuable
|
|
75
|
+
// 无 settled,早期拒绝无 effective——缺省跳过,保证三分支轨迹结构一致且可空。
|
|
76
|
+
export function finalizeTrace(trace, parts = {}) {
|
|
77
|
+
if (parts.effective !== undefined) trace.effective = parts.effective;
|
|
78
|
+
if (parts.execution !== undefined) trace.execution = parts.execution;
|
|
79
|
+
if (parts.settled !== undefined) trace.settled = parts.settled;
|
|
80
|
+
return trace;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 父会话上下文快照(判定依据的输入部分)。parentPreset 拿不到记 undefined。
|
|
84
|
+
export function parentContextOf({ parentPreset, parentProvider, parentModel, parentToolCount, allowFailOpen }) {
|
|
85
|
+
return { parentPreset, parentProvider, parentModel, parentToolCount, allowFailOpen };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 请求参数快照。requested.prompt 原文绝不进 trace(体积 + 隐私),只记
|
|
89
|
+
// persona / toolFilter 的存在性布尔与 prompt 摘要(前 200 字符 + 截断标记,
|
|
90
|
+
// 供台账展示「这次派发让子 Agent 干什么」,不泄漏全文)。
|
|
91
|
+
// opts.profiles:模型可见的 profile 目录快照(调用方按 dispatch:profiles section
|
|
92
|
+
// 同源同序传入,见 profileSnapshotOf)——「模型为什么选这个方案」的决策输入记录面。
|
|
93
|
+
export function requestedOf(args, opts = {}) {
|
|
94
|
+
const rawPrompt = typeof args.prompt === 'string' ? args.prompt : '';
|
|
95
|
+
const truncated = rawPrompt.length > 200;
|
|
96
|
+
const profiles = Array.isArray(opts.profiles) ? opts.profiles : [];
|
|
97
|
+
// 未知 per-call 字段不再静默吞——与 DISPATCH_PARAMETER_KEYS(单一事实来源)
|
|
98
|
+
// 对照后记入 requested 快照,台账「请求值」区可见(fail-visible,不改派发行为)。
|
|
99
|
+
const unknownKeys = args !== null && typeof args === 'object' && !Array.isArray(args)
|
|
100
|
+
? Object.keys(args).filter((key) => args[key] !== undefined && !DISPATCH_PARAMETER_KEYS.includes(key))
|
|
101
|
+
: [];
|
|
102
|
+
return {
|
|
103
|
+
profile: args.profile,
|
|
104
|
+
preset: args.preset,
|
|
105
|
+
provider: args.provider,
|
|
106
|
+
model: args.model,
|
|
107
|
+
reasoningEffort: args.reasoningEffort,
|
|
108
|
+
tokenTier: args.tokenTier,
|
|
109
|
+
persona_present: typeof args.persona === 'string' && args.persona.length > 0,
|
|
110
|
+
toolFilter_present: args.toolFilter !== undefined,
|
|
111
|
+
maxTokens: args.maxTokens,
|
|
112
|
+
maxDepth: args.maxDepth,
|
|
113
|
+
envelope: args.envelope === true,
|
|
114
|
+
advice_present: opts.advicePresent === true,
|
|
115
|
+
prompt_excerpt: rawPrompt.slice(0, 200),
|
|
116
|
+
...(truncated ? { prompt_truncated: true } : {}),
|
|
117
|
+
...(profiles.length > 0 ? { profiles_snapshot: profiles } : {}),
|
|
118
|
+
...(unknownKeys.length > 0 ? { unknown_keys: unknownKeys } : {}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 生效值(与 buildMeta 同源字段 + ignored 列表)。ignored 与结果块的 ignored
|
|
123
|
+
// 列表同源:continuable 为 ['preset','reasoningEffort'],前/后台为空数组。
|
|
124
|
+
export function effectiveMeta(meta, ignored = []) {
|
|
125
|
+
return {
|
|
126
|
+
profile: meta.profile,
|
|
127
|
+
preset: meta.preset,
|
|
128
|
+
provider: meta.provider,
|
|
129
|
+
model: meta.model,
|
|
130
|
+
reasoningEffort: meta.reasoningEffort,
|
|
131
|
+
tokenTier: meta.tokenTier,
|
|
132
|
+
ignored,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 成本闸 ① 硬上限(maxTokens / maxDepth)的预判条目。cap 由调用方注入(自
|
|
137
|
+
// pure.mjs 的 MAX_TOKENS / MAX_DEPTH 读取);这里只判、不 throw——真正的硬上限
|
|
138
|
+
// throw 仍在 assertCostGuard 内的 assertHardLimits 完成。
|
|
139
|
+
export function hardLimitChecks(args, maxTokensCap, maxDepthCap) {
|
|
140
|
+
const verdict = (value, cap) => (typeof value === 'number' && value > cap ? 'fail' : 'pass');
|
|
141
|
+
return [
|
|
142
|
+
{ field: 'maxTokens', checkedAgainst: 'hardCap', input: args.maxTokens, cap: maxTokensCap, verdict: verdict(args.maxTokens, maxTokensCap) },
|
|
143
|
+
{ field: 'maxDepth', checkedAgainst: 'hardCap', input: args.maxDepth, cap: maxDepthCap, verdict: verdict(args.maxDepth, maxDepthCap) },
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 审批闸(声明性不变量,非运行时判定):宿主未暴露可读的会话审批策略值,按
|
|
148
|
+
// 子 Agent 权限范围在启动时固定(委派不豁免审批)记 'never'。
|
|
149
|
+
export function recordApprovalGate(trace) {
|
|
150
|
+
recordGate(trace, {
|
|
151
|
+
name: 'approval',
|
|
152
|
+
input: { policy: 'never' },
|
|
153
|
+
output: { applied: 'never' },
|
|
154
|
+
verdict: 'pass',
|
|
155
|
+
reason: '派发不豁免审批:子 Agent 权限范围在启动时固定(DELEGATION_CONTEXT 语义)',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 失败路径定稿 + 记账:拿不到 sessionId 时跳过(createFailureLedger.record 内部
|
|
160
|
+
// 也拒绝空 id)。调用方随后 rethrow 原错误对象(文案逐字不变)。
|
|
161
|
+
export function recordFailure(ledger, parent, trace) {
|
|
162
|
+
ledger.record(parent?.session?.header?.id, assertTraceSize(trace));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- 体积护栏:单条 trace JSON ≤ MAX_TRACE_BYTES --------------------------------
|
|
166
|
+
|
|
167
|
+
// 深度清理 undefined:对象删除 undefined 值属性,数组 undefined 元素置 null
|
|
168
|
+
// (JSON 语义)。宿主对工具结果做 lossless JSON 校验——undefined 属性会在
|
|
169
|
+
// JSON.stringify 时被静默丢弃、数组 undefined 元素变 null,均破坏无损往返。
|
|
170
|
+
// trace 的可选字段(settled 占位、execution 可选 id、未请求的参数)常态携带
|
|
171
|
+
// undefined,故在体积护栏入口统一清理,保证 trace 恒为 lossless JSON。
|
|
172
|
+
function stripUndefined(value) {
|
|
173
|
+
if (Array.isArray(value)) {
|
|
174
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
175
|
+
if (value[i] === undefined) value[i] = null;
|
|
176
|
+
else if (value[i] !== null && typeof value[i] === 'object') stripUndefined(value[i]);
|
|
177
|
+
}
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
if (value !== null && typeof value === 'object') {
|
|
181
|
+
for (const key of Object.keys(value)) {
|
|
182
|
+
if (value[key] === undefined) delete value[key];
|
|
183
|
+
else if (value[key] !== null && typeof value[key] === 'object') stripUndefined(value[key]);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 逐级截断 detail/reason 长文本到 MAX_TEXT_CHARS(仅命中键名为 detail/reason 的
|
|
190
|
+
// 字符串字段)。
|
|
191
|
+
function truncateLongTexts(node, max) {
|
|
192
|
+
if (Array.isArray(node)) {
|
|
193
|
+
for (const item of node) truncateLongTexts(item, max);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (node !== null && typeof node === 'object') {
|
|
197
|
+
for (const key of Object.keys(node)) {
|
|
198
|
+
const value = node[key];
|
|
199
|
+
if (typeof value === 'string' && (key === 'detail' || key === 'reason') && value.length > max) {
|
|
200
|
+
node[key] = value.slice(0, max);
|
|
201
|
+
} else {
|
|
202
|
+
truncateLongTexts(value, max);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 截断 cost 闸的 checks 数组到前 MAX_CHECKS 条。
|
|
209
|
+
function truncateChecks(gates, max) {
|
|
210
|
+
for (const gate of gates) {
|
|
211
|
+
const checks = gate.output?.checks;
|
|
212
|
+
if (Array.isArray(checks) && checks.length > max) gate.output.checks = checks.slice(0, max);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 丢弃 detail 字段(最后一级兜底):detail 是冗长自由文本,field/checkedAgainst/
|
|
217
|
+
// verdict 才是结构化摘要——仍超限时整体移除 detail,保留结构化键。
|
|
218
|
+
function dropDetails(node) {
|
|
219
|
+
if (Array.isArray(node)) {
|
|
220
|
+
for (const item of node) dropDetails(item);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (node !== null && typeof node === 'object') {
|
|
224
|
+
for (const key of Object.keys(node)) {
|
|
225
|
+
if (key === 'detail') delete node[key];
|
|
226
|
+
else dropDetails(node[key]);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 截任意超长字符串到 max(H3 硬保证):provider/model/preset/profile 等长标识符此前
|
|
232
|
+
// 无 maxLength 约束(truncateLongTexts 只命中 detail/reason 键),恶意/异常输入可让
|
|
233
|
+
// trace 仍超护栏预算。这里递归截全树字符串,保证单一字符串体积有界。
|
|
234
|
+
function truncateAllStrings(node, max) {
|
|
235
|
+
if (Array.isArray(node)) {
|
|
236
|
+
for (const item of node) truncateAllStrings(item, max);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (node !== null && typeof node === 'object') {
|
|
240
|
+
for (const key of Object.keys(node)) {
|
|
241
|
+
const value = node[key];
|
|
242
|
+
if (typeof value === 'string' && value.length > max) node[key] = value.slice(0, max);
|
|
243
|
+
else truncateAllStrings(value, max);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 截 settled.calls 的调用级成本明细到前 MAX_CALLS 条并补截断标记:calls 是纯
|
|
249
|
+
// 数字对象无 detail/reason 可截,前两级护栏不命中它,只能整条丢弃。settled.calls
|
|
250
|
+
// 是包装结构 {calls, truncated, totalCalls}(collectChildCalls 返回值)——此处截
|
|
251
|
+
// 内层数组并把 truncated 置 true,与第一级截断(maxCalls=6)同口径,client 读取
|
|
252
|
+
// raw.truncated 即可渲染截断提示。
|
|
253
|
+
function truncateSettledCalls(trace, max) {
|
|
254
|
+
const wrapped = trace.settled?.calls;
|
|
255
|
+
if (wrapped === null || typeof wrapped !== 'object' || !Array.isArray(wrapped.calls)) return;
|
|
256
|
+
if (wrapped.calls.length <= max) return;
|
|
257
|
+
wrapped.calls = wrapped.calls.slice(0, max);
|
|
258
|
+
wrapped.truncated = true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function byteLength(value) {
|
|
262
|
+
return Buffer.byteLength(JSON.stringify(value), 'utf8');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 单条 trace 体积护栏:先深度清理 undefined(lossless JSON 前提),再保证 JSON
|
|
266
|
+
// 序列化后 ≤ MAX_TRACE_BYTES,超出时逐级收敛——
|
|
267
|
+
// ① 截 gates 的 detail/reason 长文本到 MAX_TEXT_CHARS;
|
|
268
|
+
// ② 截 cost 闸 checks 数组到前 MAX_CHECKS 条;
|
|
269
|
+
// ③ 截 settled.calls 调用级明细到前 MAX_CALLS 条;
|
|
270
|
+
// ④ 仍超则整体移除 detail 字段(保留 field/checkedAgainst/verdict 结构化摘要);
|
|
271
|
+
// ⑤ 硬保证兜底(H3):截全树任意超长字符串到 MAX_TEXT_CHARS,再丢弃 settled、
|
|
272
|
+
// 逐道丢弃最旧闸,恒收敛到 ≤ MAX_TRACE_BYTES,绝不 return 超限 trace。
|
|
273
|
+
// 各级均不改派发行为,只丢展示冗余;护栏不 throw(fail-soft),trace 始终可写。
|
|
274
|
+
export function assertTraceSize(trace) {
|
|
275
|
+
stripUndefined(trace);
|
|
276
|
+
if (byteLength(trace) <= MAX_TRACE_BYTES) return trace;
|
|
277
|
+
truncateLongTexts(trace.gates, MAX_TEXT_CHARS);
|
|
278
|
+
if (byteLength(trace) <= MAX_TRACE_BYTES) return trace;
|
|
279
|
+
truncateChecks(trace.gates, MAX_CHECKS);
|
|
280
|
+
if (byteLength(trace) <= MAX_TRACE_BYTES) return trace;
|
|
281
|
+
truncateSettledCalls(trace, MAX_CALLS);
|
|
282
|
+
if (byteLength(trace) <= MAX_TRACE_BYTES) return trace;
|
|
283
|
+
dropDetails(trace.gates);
|
|
284
|
+
if (byteLength(trace) <= MAX_TRACE_BYTES) return trace;
|
|
285
|
+
// H3 硬保证:长 provider/model/preset 等标识符无 maxLength,截全树字符串后若仍
|
|
286
|
+
// 超,按体积优先级丢弃可选大段(settled → 最旧闸),直至必然 ≤ MAX_TRACE_BYTES。
|
|
287
|
+
truncateAllStrings(trace, MAX_TEXT_CHARS);
|
|
288
|
+
if (byteLength(trace) <= MAX_TRACE_BYTES) return trace;
|
|
289
|
+
if (trace.settled !== undefined) {
|
|
290
|
+
delete trace.settled;
|
|
291
|
+
stripUndefined(trace);
|
|
292
|
+
if (byteLength(trace) <= MAX_TRACE_BYTES) return trace;
|
|
293
|
+
}
|
|
294
|
+
while (byteLength(trace) > MAX_TRACE_BYTES && Array.isArray(trace.gates) && trace.gates.length > 0) {
|
|
295
|
+
trace.gates.shift();
|
|
296
|
+
}
|
|
297
|
+
return trace;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// --- 失败台账 -------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
// 形状判定:落盘 JSON 必须 {version:1, sessions:{<sessionId>:[trace...]}},任一
|
|
303
|
+
// 不符返回 false(fail-soft 用,不 throw)。
|
|
304
|
+
function isFailureLedgerShape(parsed) {
|
|
305
|
+
return parsed !== null && typeof parsed === 'object'
|
|
306
|
+
&& parsed.version === 1
|
|
307
|
+
&& parsed.sessions !== null && typeof parsed.sessions === 'object' && !Array.isArray(parsed.sessions)
|
|
308
|
+
&& Object.values(parsed.sessions).every((list) => Array.isArray(list));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 构造时加载:文件缺失 / 解析失败 / 版本或形状不符一律 warn + 空台账起步,绝不
|
|
312
|
+
// throw(fail-soft)。仅在传入 stateFile 时调用。
|
|
313
|
+
function loadFailureLedgerFile(stateFile, sessions, warn) {
|
|
314
|
+
if (stateFile === undefined) return;
|
|
315
|
+
let parsed;
|
|
316
|
+
try {
|
|
317
|
+
if (!existsSync(stateFile)) return;
|
|
318
|
+
parsed = JSON.parse(readFileSync(stateFile, 'utf8'));
|
|
319
|
+
} catch (error) {
|
|
320
|
+
warn(`dispatch ledger: 失败台账落盘文件加载失败,从空台账起步(${error instanceof Error ? error.message : String(error)})`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (!isFailureLedgerShape(parsed)) {
|
|
324
|
+
warn('dispatch ledger: 失败台账落盘文件版本或形状不符,从空台账起步');
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
// 元素级守卫:条目非对象(含数组/null/原始值)一律丢弃——不把畸形数据注入内存
|
|
328
|
+
// 台账(/ledger/failures 会原样输出给客户端,脏数据只配在加载边界被拦下)。
|
|
329
|
+
for (const [sessionId, list] of Object.entries(parsed.sessions)) {
|
|
330
|
+
const clean = list.filter((item) => item !== null && typeof item === 'object' && !Array.isArray(item));
|
|
331
|
+
if (clean.length > 0) sessions.set(sessionId, clean);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 全量同步落盘:写 <stateFile>.tmp 后 rename 覆盖(崩溃不产生截断文件);写失败
|
|
336
|
+
// warn 不阻断(fail-soft),内存台账照常驱动本进程——与「写失败仅 warn」纪律一致。
|
|
337
|
+
// 仅在传入 stateFile 时调用。
|
|
338
|
+
function persistFailureLedgerFile(stateFile, sessions, warn) {
|
|
339
|
+
if (stateFile === undefined) return;
|
|
340
|
+
const payload = { version: 1, sessions: Object.fromEntries(sessions) };
|
|
341
|
+
const tmp = `${stateFile}.tmp`;
|
|
342
|
+
try {
|
|
343
|
+
writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
|
344
|
+
renameSync(tmp, stateFile);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
347
|
+
warn(`dispatch ledger: 失败台账落盘写入失败(${error instanceof Error ? error.message : String(error)})`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// --- 台账条目操作(模块级纯操作:sessions/limits/warn 参数注入,守函数行门)----
|
|
352
|
+
|
|
353
|
+
function countFailureEntries(sessions) {
|
|
354
|
+
let total = 0;
|
|
355
|
+
for (const list of sessions.values()) total += list.length;
|
|
356
|
+
return total;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function dropOldestOverallEntry(sessions, maxTotal, warn) {
|
|
360
|
+
const firstKey = sessions.keys().next();
|
|
361
|
+
if (firstKey.done) return;
|
|
362
|
+
const list = sessions.get(firstKey.value);
|
|
363
|
+
list.shift();
|
|
364
|
+
if (list.length === 0) sessions.delete(firstKey.value);
|
|
365
|
+
warn(`dispatch ledger: 失败台账总量超过上限 ${maxTotal},已丢弃最旧一条`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function recordFailureEntry(sessions, sessionId, trace, maxPerSession, maxTotal, warn, stateFile) {
|
|
369
|
+
if (sessionId === undefined || sessionId === null || sessionId === '') return;
|
|
370
|
+
let list = sessions.get(sessionId);
|
|
371
|
+
if (list === undefined) {
|
|
372
|
+
list = [];
|
|
373
|
+
sessions.set(sessionId, list);
|
|
374
|
+
}
|
|
375
|
+
list.push(trace);
|
|
376
|
+
if (list.length > maxPerSession) {
|
|
377
|
+
list.shift();
|
|
378
|
+
warn(`dispatch ledger: 会话 ${sessionId} 失败记录超过每会话上限 ${maxPerSession},已丢弃最旧一条`);
|
|
379
|
+
}
|
|
380
|
+
while (countFailureEntries(sessions) > maxTotal) {
|
|
381
|
+
dropOldestOverallEntry(sessions, maxTotal, warn);
|
|
382
|
+
}
|
|
383
|
+
persistFailureLedgerFile(stateFile, sessions, warn);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function getFailureEntries(sessions, sessionId) {
|
|
387
|
+
const list = sessions.get(sessionId);
|
|
388
|
+
return list === undefined ? [] : [...list];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function takeFailureEntries(sessions, sessionId, warn, stateFile) {
|
|
392
|
+
const list = sessions.get(sessionId);
|
|
393
|
+
if (list === undefined) return [];
|
|
394
|
+
sessions.delete(sessionId);
|
|
395
|
+
persistFailureLedgerFile(stateFile, sessions, warn);
|
|
396
|
+
return [...list];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// 会话级失败台账(Map<sessionId, Array<trace>>)。参数注入、弃单例;每会话
|
|
400
|
+
// 上限 maxPerSession、总量上限 maxTotal,超限丢弃最旧并 warn(fail-soft,绝不
|
|
401
|
+
// 阻断派发失败路径)。warn 由调用方注入(宿主 logger 或测试捕获器)。
|
|
402
|
+
// 可选 stateFile:传入时构造加载 + record/take 后落盘;不传 = 纯内存。
|
|
403
|
+
export function createFailureLedger({ maxPerSession = 50, maxTotal = 500, warn = () => {}, stateFile } = {}) {
|
|
404
|
+
const sessions = new Map();
|
|
405
|
+
loadFailureLedgerFile(stateFile, sessions, warn);
|
|
406
|
+
return {
|
|
407
|
+
record: (sessionId, trace) => recordFailureEntry(sessions, sessionId, trace, maxPerSession, maxTotal, warn, stateFile),
|
|
408
|
+
get: (sessionId) => getFailureEntries(sessions, sessionId),
|
|
409
|
+
take: (sessionId) => takeFailureEntries(sessions, sessionId, warn, stateFile),
|
|
410
|
+
clear: () => sessions.clear(),
|
|
411
|
+
count: () => countFailureEntries(sessions),
|
|
412
|
+
};
|
|
413
|
+
}
|
package/lib/core/delegation.mjs
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
// lib/core/delegation.mjs —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// foreground result closure in lib/core/profile-provider.mjs); no @deepseek-ai
|
|
6
|
-
// dependency.
|
|
1
|
+
// lib/core/delegation.mjs — 后台 one-shot 结算与委派元数据组装,从 index.mjs
|
|
2
|
+
// 拆出。仅引用本地 lib:从 lib/core/pure.mjs 引入 stopReasonError /
|
|
3
|
+
// withPartialText / textFrom(shims.readResult 不被 settleStart 使用——它只被
|
|
4
|
+
// lib/core/profile-provider.mjs 的前台结果闭包使用);无 @deepseek-ai 依赖。
|
|
7
5
|
|
|
8
|
-
import { stopReasonError, withPartialText, textFrom } from './pure.mjs';
|
|
6
|
+
import { stopReasonError, withPartialText, textFrom, trustLabel, trustAudit } from './pure.mjs';
|
|
9
7
|
|
|
10
8
|
// 子 Agent 会话的真实计费 token 五段分解:累加 session.events 里每条
|
|
11
9
|
// assistant/message 事件携带的 provider usage(inputTokens / outputTokens /
|
|
@@ -53,59 +51,122 @@ export function collectChildUsage(session) {
|
|
|
53
51
|
return out;
|
|
54
52
|
}
|
|
55
53
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
54
|
+
// 逐次调用明细:按与 collectChildUsage 同口径遍历 assistant/message 事件,逐条
|
|
55
|
+
// 产出单步五段 usage(inputTokens/outputTokens 恒在、缺报按 0;可选字段缓存读/写/
|
|
56
|
+
// 推理仅该条报告时携带)。返回前 maxCalls 条 + truncated(总数 > maxCalls)+
|
|
57
|
+
// totalCalls(总数);无任何 usage 事件返回 undefined。与 collectChildUsage 并存:
|
|
58
|
+
// 前者是逐次明细(台账展开区),后者是会话总量(结算 meta),互不替代。非 object /
|
|
59
|
+
// 非 number 一律忽略(fail-soft),使逐条收集绝不阻断派发结算。
|
|
60
|
+
//
|
|
61
|
+
// maxCalls 默认 6(非 12):单条 calls 的五段 JSON 键名主导体积(约 100 字节/条),
|
|
62
|
+
// 6 条 ≈ 700 字节,叠加决策轨迹骨架(gates/effective/execution/requested 约 1.5KB)
|
|
63
|
+
// 合计约 2.2KB——故 assertTraceSize 的护栏预算为 3072 字节,且超预算时还有
|
|
64
|
+
// calls 截断级(截到前 4 条,见 decision-trace.mjs 的 truncateSettledCalls)。
|
|
65
|
+
// 若需更严可继续下调 maxCalls。
|
|
66
|
+
export function collectChildCalls(session, maxCalls = 6) {
|
|
67
|
+
if (session === undefined || session === null) return undefined;
|
|
68
|
+
const events = session.events;
|
|
69
|
+
if (!Array.isArray(events)) return undefined;
|
|
70
|
+
const calls = [];
|
|
71
|
+
let totalCalls = 0;
|
|
72
|
+
for (const event of events) {
|
|
73
|
+
if (event === null || typeof event !== 'object' || event.type !== 'assistant/message') continue;
|
|
74
|
+
const usage = event.data?.usage;
|
|
75
|
+
if (usage === null || typeof usage !== 'object' || Array.isArray(usage)) continue;
|
|
76
|
+
totalCalls += 1;
|
|
77
|
+
if (calls.length >= maxCalls) continue;
|
|
78
|
+
const call = { inputTokens: 0, outputTokens: 0 };
|
|
79
|
+
for (const key of ['inputTokens', 'outputTokens']) {
|
|
80
|
+
const value = usage[key];
|
|
81
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) call[key] = value;
|
|
81
82
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
for (const key of ['cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens']) {
|
|
84
|
+
const value = usage[key];
|
|
85
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) call[key] = value;
|
|
86
|
+
}
|
|
87
|
+
calls.push(call);
|
|
88
|
+
}
|
|
89
|
+
if (totalCalls === 0) return undefined;
|
|
90
|
+
return { calls, truncated: totalCalls > maxCalls, totalCalls };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 把一个后台 one-shot run 结算为带前台同等可观测元数据的任务结果。非 completed
|
|
94
|
+
// 停因转 failed(aborted => killed,官方词汇表)并附部分输出;硬失败绝不 reject
|
|
95
|
+
// 任务。
|
|
96
|
+
// `prune` 是结果回收预剪器:调用方(dispatch execute)注入一个闭包,在 textFrom
|
|
97
|
+
// 之前调宿主 toolResultPruner.pruneContent;缺省为恒等函数,保证无 pruner 时后台
|
|
98
|
+
// 路径安全。`t0` 是 dispatch execute 入口时间戳;结算结果带 `elapsedMs = now - t0`
|
|
99
|
+
// 与底层 `stopReason`(官方终态词汇表)。t0 缺省为 now,使直接调用方(测试)
|
|
100
|
+
// 无需穿 timestamp。
|
|
101
|
+
// `logger`(可选)在 completed run 重新进入父上下文时接收信任输出审计行;绝不
|
|
102
|
+
// 反映进结果字符串。
|
|
103
|
+
// 结算一个已 resolve 的 one-shot run:非 completed 停因转 failed(aborted => killed),
|
|
104
|
+
// completed 时测量 childTotalTokens/childUsage/childCalls 并做信任标注。result 抛错
|
|
105
|
+
// 不外抛(交给 settleStart 的 catch 统一 settle)。
|
|
106
|
+
async function settleRun(run, meta, prune, measureChild, t0, logger) {
|
|
107
|
+
const result = await run.result;
|
|
108
|
+
const failure = stopReasonError(result);
|
|
109
|
+
if (failure !== undefined) {
|
|
110
|
+
return {
|
|
111
|
+
status: result.stopReason === 'aborted' ? 'killed' : 'failed',
|
|
112
|
+
detail: withPartialText(failure, result.output),
|
|
89
113
|
...meta,
|
|
90
|
-
...(childTotalTokens !== undefined ? { childTotalTokens } : {}),
|
|
91
|
-
...(childUsage !== undefined ? { childUsage } : {}),
|
|
92
114
|
elapsedMs: Date.now() - t0,
|
|
93
|
-
stopReason:
|
|
115
|
+
stopReason: result.stopReason,
|
|
116
|
+
childSessionId: run.id
|
|
94
117
|
};
|
|
95
|
-
|
|
118
|
+
}
|
|
119
|
+
// 仅 completed 结算时测量(非 completed 走上方失败分支,不测);在 dispose
|
|
120
|
+
// 之前读子 session,保证测量拿到完整事件流。measureChild 缺失/失败返回
|
|
121
|
+
// undefined → 省略字段(fail-soft);childUsage 同样只在可累加时携带。
|
|
122
|
+
const childSession = run.localAgent?.session;
|
|
123
|
+
const childTotalTokens = measureChild(childSession);
|
|
124
|
+
const childUsage = collectChildUsage(childSession);
|
|
125
|
+
const childCalls = collectChildCalls(childSession);
|
|
126
|
+
const metaOut = {
|
|
127
|
+
...meta,
|
|
128
|
+
...(childTotalTokens !== undefined ? { childTotalTokens } : {}),
|
|
129
|
+
...(childUsage !== undefined ? { childUsage } : {}),
|
|
130
|
+
...(childCalls !== undefined ? { calls: childCalls } : {}),
|
|
131
|
+
elapsedMs: Date.now() - t0,
|
|
132
|
+
stopReason: 'completed'
|
|
133
|
+
};
|
|
134
|
+
// 信任标注:completed 子结果回灌父上下文前加结构化前缀(profile/preset 元数据、
|
|
135
|
+
// 无 prompt 原文);审计同字段 JSON 只进 logger(缺 logger 时跳过),不进结果
|
|
136
|
+
// 字符串。render 行首部格式不变——前缀只进 output 文本。
|
|
137
|
+
const output = `${trustLabel(metaOut)}${textFrom(prune(result.output))}`;
|
|
138
|
+
if (logger !== undefined && typeof logger.info === 'function') {
|
|
139
|
+
logger.info('[dsh-subagent-profile] trusted-output: ' + trustAudit(metaOut));
|
|
140
|
+
}
|
|
141
|
+
return { status: 'completed', output, childSessionId: run.id, ...metaOut };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function settleStart(start, signal, meta, prune = (blocks) => blocks, measureChild = () => undefined, t0 = Date.now(), logger = undefined, onSettled = undefined) {
|
|
145
|
+
let run;
|
|
146
|
+
let settled;
|
|
147
|
+
try {
|
|
148
|
+
run = await start;
|
|
149
|
+
settled = await settleRun(run, meta, prune, measureChild, t0, logger);
|
|
150
|
+
return settled;
|
|
96
151
|
} catch (error) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
152
|
+
// run 可能已创建(start resolve 后 result reject),此时仍可回填子会话 id;
|
|
153
|
+
// start 自身 reject 时 run 为 undefined,childSessionId 缺省(调用方降级处理)。
|
|
154
|
+
settled = signal.aborted
|
|
155
|
+
? { status: 'killed', ...meta, elapsedMs: Date.now() - t0, stopReason: 'aborted', ...(run !== undefined && typeof run.id === 'string' ? { childSessionId: run.id } : {}) }
|
|
156
|
+
: { status: 'failed', detail: String(error), ...meta, elapsedMs: Date.now() - t0, stopReason: 'error', ...(run !== undefined && typeof run.id === 'string' ? { childSessionId: run.id } : {}) };
|
|
157
|
+
return settled;
|
|
100
158
|
} finally {
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
// try/finally).
|
|
159
|
+
// 无论结果如何结算都要释放子句柄——run.result reject 不得泄漏子 agent
|
|
160
|
+
// (与前台 try/finally 同一纪律)。
|
|
104
161
|
if (run !== undefined) await run.dispose().catch(() => {});
|
|
162
|
+
// 结算钩子(同步):后台派发的 untrack + 并发 release + token 记账共用此点,
|
|
163
|
+
// 保证无论 completed/killed/failed/aborted 都恰好触发一次。settled 携带
|
|
164
|
+
// childTotalTokens(仅 completed 且可测量时),调用方据此记账。
|
|
165
|
+
if (onSettled !== undefined) onSettled(settled);
|
|
105
166
|
}
|
|
106
167
|
}
|
|
107
168
|
|
|
108
|
-
//
|
|
169
|
+
// 逐字取自官方 SUBAGENT_DELEGATION_CONTEXT。
|
|
109
170
|
export const DELEGATION_CONTEXT = 'You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it.';
|
|
110
171
|
|
|
111
172
|
// Provider start 内 CUSTOM childSessionMeta 对象的纯组装,从 provider start
|