dsh-subagent-profile 0.2.0 → 0.3.1
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 +20 -8
- package/README.zh.md +20 -8
- package/index.mjs +177 -1386
- package/lib/client.js +871 -695
- package/lib/core/catalog-cache.mjs +235 -0
- package/lib/core/catalog.mjs +83 -0
- package/lib/core/cost-guard.mjs +122 -0
- package/lib/core/delegation.mjs +81 -0
- package/lib/core/dispatch-schema.mjs +71 -0
- package/lib/core/dispatch-tool.mjs +394 -0
- package/lib/core/http-routes.mjs +249 -0
- package/lib/core/intersection.mjs +27 -0
- package/lib/core/presets-sync.mjs +136 -0
- package/lib/core/profile-provider.mjs +242 -0
- package/lib/core/profiles-store.mjs +226 -0
- package/lib/{pure.mjs → core/pure.mjs} +146 -110
- package/lib/{shims.mjs → core/shims.mjs} +95 -9
- package/lib/core/whitelist.mjs +22 -0
- package/package.json +10 -5
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
// lib/pure.mjs — import-free pure helpers extracted from index.mjs
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// this module
|
|
5
|
-
//
|
|
6
|
-
// 12-module split (target state).
|
|
1
|
+
// lib/core/pure.mjs — import-free pure helpers (extracted from index.mjs and grown
|
|
2
|
+
// by later refactors). No @deepseek-ai imports and no external dependencies:
|
|
3
|
+
// @deepseek-ai symbols converge in lib/core/shims.mjs (the only such entry point),
|
|
4
|
+
// and this module is safe to import from bare-CI tests without the junction
|
|
5
|
+
// packages. Every function here is deterministic and free of service/fs access.
|
|
7
6
|
|
|
8
7
|
// Shipped toStopReason: map a turn-end reason to the seam's terminal vocabulary.
|
|
9
8
|
export function toStopReason(reason) {
|
|
@@ -43,7 +42,7 @@ export function textFrom(blocks) {
|
|
|
43
42
|
.join('');
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
// ---
|
|
45
|
+
// --- 统一输入 schema ----------------------------------------------------------
|
|
47
46
|
// sanitizeProfile is the single per-profile sampler shared by loadProfiles
|
|
48
47
|
// (strict=false, migration-tolerant) and the HTTP /add write path (strict=true,
|
|
49
48
|
// write-reject). It normalizes each field with a field-specific sampler and
|
|
@@ -55,7 +54,7 @@ export function textFrom(blocks) {
|
|
|
55
54
|
// dedupe) do NOT produce a warning, so callers can treat a
|
|
56
55
|
// non-empty warnings list as "this write would drop data".
|
|
57
56
|
|
|
58
|
-
// persona length cap. 建议值 2048
|
|
57
|
+
// persona length cap. 建议值 2048,待实测。The cap
|
|
59
58
|
// applies to the INJECTED persona text, i.e. the guidance prefix + the raw text.
|
|
60
59
|
export const PERSONA_MAX_CHARS = 2048;
|
|
61
60
|
|
|
@@ -64,15 +63,14 @@ export const PERSONA_MAX_CHARS = 2048;
|
|
|
64
63
|
export const GUIDANCE_PREFIX = '[guidance, not authority] ';
|
|
65
64
|
|
|
66
65
|
// Shared delegation caps — single source of truth for sanitizeProfile and the
|
|
67
|
-
// cost guard in
|
|
66
|
+
// cost guard in lib/core/cost-guard.mjs (hard caps always-on).
|
|
68
67
|
export const MAX_TOKENS = 65536;
|
|
69
68
|
export const MAX_DEPTH = 3;
|
|
70
69
|
|
|
71
|
-
// ---
|
|
72
|
-
// assertHardLimits is the always-on budget-cap check
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// `llm` service is absent (the old `if (llm === undefined) return` skipped them).
|
|
70
|
+
// --- cost guard 硬上限 --------------------------------------------------------
|
|
71
|
+
// assertHardLimits is the always-on budget-cap check, independent of the `llm`
|
|
72
|
+
// service: maxTokens / maxDepth are hard delegation caps, so they must NOT
|
|
73
|
+
// silently stop applying when the `llm` service is absent.
|
|
76
74
|
// 超限 throw(中文、可操作),与 sanitizeProfile 共用同一组常量。非数字/未设值
|
|
77
75
|
// 不触发(sanitizeProfile 已在写路径拒绝非数字,这里仅兜底运行时竞态)。
|
|
78
76
|
export function assertHardLimits(maxTokens, maxDepth) {
|
|
@@ -84,13 +82,13 @@ export function assertHardLimits(maxTokens, maxDepth) {
|
|
|
84
82
|
}
|
|
85
83
|
}
|
|
86
84
|
|
|
87
|
-
// ---
|
|
85
|
+
// --- continuable 工具门闭集 ---------------------------------------------------
|
|
88
86
|
// computeContinuableAllow pre-computes the CLOSED tool `allow` set for the
|
|
89
87
|
// continuable dispatch path: 父工具集 − run_code − (toolFilter.deny),并在存在
|
|
90
88
|
// toolFilter.allow 时再 ∩ allow。空集 fail-loud(throw)—— 绝不静默派发零工具。
|
|
91
89
|
//
|
|
92
|
-
// 代码编辑者注意:这里写代码注释的「假设 / 失效条件」必须与
|
|
93
|
-
// continuable
|
|
90
|
+
// 代码编辑者注意:这里写代码注释的「假设 / 失效条件」必须与 dispatch-tool.mjs
|
|
91
|
+
// continuable 分支的注释保持一致(要求写入代码注释与 README)。
|
|
94
92
|
export function computeContinuableAllow(parentNames, toolFilter = {}) {
|
|
95
93
|
const parent = new Set(parentNames);
|
|
96
94
|
parent.delete('run_code');
|
|
@@ -120,7 +118,99 @@ function sanitizeShortText(value) {
|
|
|
120
118
|
return value.replace(/\r\n|\r|\n/g, ' ');
|
|
121
119
|
}
|
|
122
120
|
|
|
123
|
-
// ---
|
|
121
|
+
// --- sanitizeProfile 各字段采样器(从 sanitizeProfile 拆出;行为逐字不变,
|
|
122
|
+
// 共用 clean/warnings 两个累加对象)--------------------------------------------
|
|
123
|
+
|
|
124
|
+
// name/description:null / '' 是「清除字段」哨兵(/add merge 层处理),原样透传
|
|
125
|
+
// 不告警;其它非字符串(number/object/array)拒绝。
|
|
126
|
+
function sanitizeTextField(key, value, clean, warnings) {
|
|
127
|
+
if (value === undefined) return;
|
|
128
|
+
if (value === null || value === '') { clean[key] = value; return; }
|
|
129
|
+
if (typeof value !== 'string') {
|
|
130
|
+
warnings.push({ field: key, reason: `${key} 必须为字符串` });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
clean[key] = sanitizeShortText(value);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// persona:计入引导前缀后的长度上限(strict 决定拒绝写入还是保留原值)。
|
|
137
|
+
function sanitizePersonaField(value, clean, warnings, strict) {
|
|
138
|
+
if (value === undefined) return;
|
|
139
|
+
if (typeof value !== 'string') {
|
|
140
|
+
warnings.push({ field: 'persona', reason: 'persona 必须为字符串' });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const wrappedLength = value.length === 0 ? 0 : GUIDANCE_PREFIX.length + value.length;
|
|
144
|
+
if (wrappedLength > PERSONA_MAX_CHARS) {
|
|
145
|
+
if (strict) {
|
|
146
|
+
warnings.push({
|
|
147
|
+
field: 'persona',
|
|
148
|
+
reason: `persona 超长:计入引导前缀 '${GUIDANCE_PREFIX.trim()}' 后 ${wrappedLength} 字符,超过上限 ${PERSONA_MAX_CHARS},拒绝写入`,
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
warnings.push({
|
|
153
|
+
field: 'persona',
|
|
154
|
+
reason: `persona 超长:计入引导前缀 '${GUIDANCE_PREFIX.trim()}' 后 ${wrappedLength} 字符,超过上限 ${PERSONA_MAX_CHARS};保留原值(不截断),建议人工精简`,
|
|
155
|
+
});
|
|
156
|
+
clean.persona = value;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
clean.persona = value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// maxTokens/maxDepth:有限数字 + 硬上限(MAX_TOKENS/MAX_DEPTH 由调用方注入)。
|
|
163
|
+
function sanitizeNumericField(key, value, clean, warnings, max) {
|
|
164
|
+
if (value === undefined) return;
|
|
165
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
166
|
+
warnings.push({ field: key, reason: `${key} 必须为有限数字` });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (value > max) {
|
|
170
|
+
warnings.push({ field: key, reason: `${key} ${value} 超过上限 ${max}` });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
clean[key] = value;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// toolFilter:allow/deny 去重、丢弃空字符串;非法子字段按字段粒度移除。未知键
|
|
177
|
+
// 忽略;空(或全非法)的 toolFilter 直接不写入 clean(与 /add 写路径镜像)。
|
|
178
|
+
function sanitizeToolFilterField(value, clean, warnings) {
|
|
179
|
+
if (value === undefined) return;
|
|
180
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
181
|
+
warnings.push({ field: 'toolFilter', reason: 'toolFilter 必须为对象' });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const tf = {};
|
|
185
|
+
for (const op of ['allow', 'deny']) {
|
|
186
|
+
const sub = value[op];
|
|
187
|
+
if (sub === undefined) continue;
|
|
188
|
+
if (!Array.isArray(sub) || !sub.every((s) => typeof s === 'string')) {
|
|
189
|
+
warnings.push({ field: `toolFilter.${op}`, reason: `toolFilter.${op} 必须为字符串数组` });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const deduped = [...new Set(sub.filter((s) => s.length > 0))];
|
|
193
|
+
if (deduped.length > 0) tf[op] = deduped;
|
|
194
|
+
}
|
|
195
|
+
// Unknown toolFilter keys are ignored; an empty (or fully-invalid)
|
|
196
|
+
// toolFilter is simply omitted from clean (mirrors the /add write path).
|
|
197
|
+
if (Object.keys(tf).length > 0) clean.toolFilter = tf;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// tokenTier:成本/深度分层(cheap/balanced/premium),供目录排序与结果卡
|
|
201
|
+
// 展示分层。合法值透传;非法值剔除 + warn(与 maxTokens 等超限字段同口径——
|
|
202
|
+
// 剔除而非回填缺省值)。缺省 'balanced' 在 sanitizeProfile 末尾按「字段未提供」
|
|
203
|
+
// 单独回填,非法值不受该回填影响。
|
|
204
|
+
const TOKEN_TIERS = new Set(['cheap', 'balanced', 'premium']);
|
|
205
|
+
function sanitizeTokenTierField(value, clean, warnings) {
|
|
206
|
+
if (typeof value !== 'string' || !TOKEN_TIERS.has(value)) {
|
|
207
|
+
warnings.push({ field: 'tokenTier', reason: 'tokenTier 必须为 cheap/balanced/premium 之一' });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
clean.tokenTier = value;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- 结果回收默认剪枝 ----------------------------------------------------------
|
|
124
214
|
// 子结果默认复用宿主 `toolResultPruner.pruneContent` 预剪(纯函数、零 LLM),
|
|
125
215
|
// 在 `textFrom` 之前执行,把回灌进父上下文的体积压到阈值内。
|
|
126
216
|
//
|
|
@@ -128,8 +218,8 @@ function sanitizeShortText(value) {
|
|
|
128
218
|
// 现行 compaction-basic 的 4096/1024 更保守)。**待实测**——宿主
|
|
129
219
|
// toolResultPruner.pruneContent(blocks) 只接收 blocks,自身读取其配置
|
|
130
220
|
// (thresholdChars/headChars/tailChars),因此这三个常量当前**不会**作为实参
|
|
131
|
-
// 传给宿主 pruner
|
|
132
|
-
//
|
|
221
|
+
// 传给宿主 pruner;它们记录本条目的子级口径,并保留给后续「信封 / 精修
|
|
222
|
+
// 剪枝」路径使用。改动前先实测真实分布再校准。
|
|
133
223
|
export const PRUNE_HEAD_CHARS = 2048;
|
|
134
224
|
export const PRUNE_TAIL_CHARS = 1024;
|
|
135
225
|
export const PRUNE_MIN_KEEP = 128;
|
|
@@ -139,8 +229,8 @@ export const PRUNE_MIN_KEEP = 128;
|
|
|
139
229
|
// - `pruner` — the `toolResultPruner` service, or undefined.
|
|
140
230
|
// Returns the (possibly pruned) blocks, or an empty array placeholder. When the
|
|
141
231
|
// pruner is absent (headless deployment without the compaction pruner) OR the
|
|
142
|
-
// content is not an array, it falls back to NO pruning —
|
|
143
|
-
//
|
|
232
|
+
// content is not an array, it falls back to NO pruning — 剪枝是增强,绝非硬依赖。
|
|
233
|
+
// A host pruner that throws on an unusual content shape also
|
|
144
234
|
// falls back to the full output, because automatic pruning must NEVER swallow a
|
|
145
235
|
// legitimate child result.
|
|
146
236
|
export function pruneBlocks(blocks, pruner) {
|
|
@@ -155,9 +245,9 @@ export function pruneBlocks(blocks, pruner) {
|
|
|
155
245
|
return Array.isArray(blocks) ? blocks : [];
|
|
156
246
|
}
|
|
157
247
|
|
|
158
|
-
// ---
|
|
159
|
-
//
|
|
160
|
-
// (background / continuable / foreground)
|
|
248
|
+
// --- continuable 可见性修复 ----------------------------------------------------
|
|
249
|
+
// 共享一致性规则:三个 closed `output.schema.oneOf` 分支
|
|
250
|
+
// (background / continuable / foreground) must carry an IDENTICAL shared meta key
|
|
161
251
|
// set whenever a result-meta field is added — so `ignored`, `reasoningEffort`,
|
|
162
252
|
// `profile/preset/provider/model` must appear in ALL three branches, keeping the
|
|
163
253
|
// model-side schema from rejecting a分支 that "forgot" the field.
|
|
@@ -165,7 +255,7 @@ export function pruneBlocks(blocks, pruner) {
|
|
|
165
255
|
// 判据:元数据集合 = 每个分支 properties 的键集,**剔除**各分支自有的判别键
|
|
166
256
|
// (`kind`/`jobId`/`subagentId`/`output`——background/continuable/foreground 各自
|
|
167
257
|
// 的判别字段不同,不纳入一致性比较)。剩下必须是三者的公共元数据集合,三处
|
|
168
|
-
// 逐一对齐;任一分支缺漏/多余公共元数据键即 throw
|
|
258
|
+
// 逐一对齐;任一分支缺漏/多余公共元数据键即 throw(中文、指明一致性规则)。
|
|
169
259
|
const RESULT_SCHEMA_DISCRIMINATOR_KEYS = new Set(['kind', 'jobId', 'subagentId', 'output']);
|
|
170
260
|
|
|
171
261
|
export function assertResultSchemaConsistency(schema) {
|
|
@@ -204,7 +294,7 @@ export function assertResultSchemaConsistency(schema) {
|
|
|
204
294
|
const KNOWN_PROFILE_FIELDS = new Set([
|
|
205
295
|
'id', 'name', 'description', 'persona', 'preset', 'provider', 'model',
|
|
206
296
|
'reasoningEffort', 'enabled', 'maxTokens', 'maxDepth', 'toolFilter',
|
|
207
|
-
'builtin', 'deleted',
|
|
297
|
+
'tokenTier', 'builtin', 'deleted',
|
|
208
298
|
]);
|
|
209
299
|
|
|
210
300
|
/**
|
|
@@ -217,7 +307,7 @@ const KNOWN_PROFILE_FIELDS = new Set([
|
|
|
217
307
|
* Non-rejection normalizations (description/name flatten, toolFilter dedupe) are
|
|
218
308
|
* silent and never produce a warning.
|
|
219
309
|
*
|
|
220
|
-
* Security
|
|
310
|
+
* Security: `clean` is `Object.create(null)` (no inherited `__proto__`
|
|
221
311
|
* setter) and only whitelisted fields are copied, so a hostile `__proto__` /
|
|
222
312
|
* `constructor` / `prototype` key is ignored rather than polluting the result.
|
|
223
313
|
*/
|
|
@@ -228,6 +318,7 @@ export function sanitizeProfile(profile, options = {}) {
|
|
|
228
318
|
if (profile === null || typeof profile !== 'object' || Array.isArray(profile)) {
|
|
229
319
|
return { clean, warnings: [{ field: '(root)', reason: 'profile 不是对象' }] };
|
|
230
320
|
}
|
|
321
|
+
let tokenTierProvided = false;
|
|
231
322
|
for (const [key, value] of Object.entries(profile)) {
|
|
232
323
|
if (!KNOWN_PROFILE_FIELDS.has(key)) {
|
|
233
324
|
warnings.push({ field: key, reason: '未知字段已忽略' });
|
|
@@ -235,97 +326,42 @@ export function sanitizeProfile(profile, options = {}) {
|
|
|
235
326
|
}
|
|
236
327
|
switch (key) {
|
|
237
328
|
case 'name':
|
|
238
|
-
case 'description':
|
|
239
|
-
|
|
240
|
-
// null / '' are the "clear this field" sentinels the /add merge layer
|
|
241
|
-
// handles; pass them through unchanged rather than flagging null as an
|
|
242
|
-
// illegal type. Any OTHER non-string (number / object / array) is
|
|
243
|
-
// rejected.
|
|
244
|
-
if (value === null || value === '') { clean[key] = value; break; }
|
|
245
|
-
if (typeof value !== 'string') {
|
|
246
|
-
warnings.push({ field: key, reason: `${key} 必须为字符串` });
|
|
247
|
-
break;
|
|
248
|
-
}
|
|
249
|
-
clean[key] = sanitizeShortText(value);
|
|
329
|
+
case 'description':
|
|
330
|
+
sanitizeTextField(key, value, clean, warnings);
|
|
250
331
|
break;
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (value === undefined) break;
|
|
254
|
-
if (typeof value !== 'string') {
|
|
255
|
-
warnings.push({ field: 'persona', reason: 'persona 必须为字符串' });
|
|
256
|
-
break;
|
|
257
|
-
}
|
|
258
|
-
const wrappedLength = value.length === 0 ? 0 : GUIDANCE_PREFIX.length + value.length;
|
|
259
|
-
if (wrappedLength > PERSONA_MAX_CHARS) {
|
|
260
|
-
if (strict) {
|
|
261
|
-
warnings.push({
|
|
262
|
-
field: 'persona',
|
|
263
|
-
reason: `persona 超长:计入引导前缀 '${GUIDANCE_PREFIX.trim()}' 后 ${wrappedLength} 字符,超过上限 ${PERSONA_MAX_CHARS},拒绝写入`,
|
|
264
|
-
});
|
|
265
|
-
break;
|
|
266
|
-
}
|
|
267
|
-
warnings.push({
|
|
268
|
-
field: 'persona',
|
|
269
|
-
reason: `persona 超长:计入引导前缀 '${GUIDANCE_PREFIX.trim()}' 后 ${wrappedLength} 字符,超过上限 ${PERSONA_MAX_CHARS};保留原值(不截断),建议人工精简`,
|
|
270
|
-
});
|
|
271
|
-
clean[key] = value;
|
|
272
|
-
break;
|
|
273
|
-
}
|
|
274
|
-
clean[key] = value;
|
|
332
|
+
case 'persona':
|
|
333
|
+
sanitizePersonaField(value, clean, warnings, strict);
|
|
275
334
|
break;
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
if (value === undefined) break;
|
|
279
|
-
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
280
|
-
warnings.push({ field: 'maxTokens', reason: 'maxTokens 必须为有限数字' });
|
|
281
|
-
break;
|
|
282
|
-
}
|
|
283
|
-
if (value > MAX_TOKENS) {
|
|
284
|
-
warnings.push({ field: 'maxTokens', reason: `maxTokens ${value} 超过上限 ${MAX_TOKENS}` });
|
|
285
|
-
break;
|
|
286
|
-
}
|
|
287
|
-
clean[key] = value;
|
|
335
|
+
case 'maxTokens':
|
|
336
|
+
sanitizeNumericField(key, value, clean, warnings, MAX_TOKENS);
|
|
288
337
|
break;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (value === undefined) break;
|
|
292
|
-
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
293
|
-
warnings.push({ field: 'maxDepth', reason: 'maxDepth 必须为有限数字' });
|
|
294
|
-
break;
|
|
295
|
-
}
|
|
296
|
-
if (value > MAX_DEPTH) {
|
|
297
|
-
warnings.push({ field: 'maxDepth', reason: `maxDepth ${value} 超过上限 ${MAX_DEPTH}` });
|
|
298
|
-
break;
|
|
299
|
-
}
|
|
300
|
-
clean[key] = value;
|
|
338
|
+
case 'maxDepth':
|
|
339
|
+
sanitizeNumericField(key, value, clean, warnings, MAX_DEPTH);
|
|
301
340
|
break;
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
const tf = {};
|
|
310
|
-
for (const op of ['allow', 'deny']) {
|
|
311
|
-
const sub = value[op];
|
|
312
|
-
if (sub === undefined) continue;
|
|
313
|
-
if (!Array.isArray(sub) || !sub.every((s) => typeof s === 'string')) {
|
|
314
|
-
warnings.push({ field: `toolFilter.${op}`, reason: `toolFilter.${op} 必须为字符串数组` });
|
|
315
|
-
continue;
|
|
316
|
-
}
|
|
317
|
-
const deduped = [...new Set(sub.filter((s) => s.length > 0))];
|
|
318
|
-
if (deduped.length > 0) tf[op] = deduped;
|
|
319
|
-
}
|
|
320
|
-
// Unknown toolFilter keys are ignored; an empty (or fully-invalid)
|
|
321
|
-
// toolFilter is simply omitted from clean (mirrors the /add write path).
|
|
322
|
-
if (Object.keys(tf).length > 0) clean.toolFilter = tf;
|
|
341
|
+
case 'toolFilter':
|
|
342
|
+
sanitizeToolFilterField(value, clean, warnings);
|
|
343
|
+
break;
|
|
344
|
+
case 'tokenTier':
|
|
345
|
+
tokenTierProvided = true;
|
|
346
|
+
sanitizeTokenTierField(value, clean, warnings);
|
|
323
347
|
break;
|
|
324
|
-
}
|
|
325
348
|
default:
|
|
326
349
|
clean[key] = value;
|
|
327
350
|
break;
|
|
328
351
|
}
|
|
329
352
|
}
|
|
353
|
+
// tokenTier 缺省 'balanced':仅当字段未被提供时回填;被提供的非法值已在
|
|
354
|
+
// sanitizeTokenTierField 剔除(clean 保持无该键),不会被此回填覆盖。
|
|
355
|
+
if (!tokenTierProvided) clean.tokenTier = 'balanced';
|
|
330
356
|
return { clean, warnings };
|
|
331
357
|
}
|
|
358
|
+
|
|
359
|
+
// --- tokenTier 目录排序 ---------------------------------------------------------
|
|
360
|
+
// tokenTier 排序权重:cheap→balanced→premium。dispatch:profiles 目录行按此
|
|
361
|
+
// 升序排列(省 token 方案在前)。缺省/未知 tier 按 balanced 处理,保证旧数据
|
|
362
|
+
// 与无 tokenTier 字段的 profile 落到中间档而非报错。
|
|
363
|
+
export const TIER_ORDER = { cheap: 0, balanced: 1, premium: 2 };
|
|
364
|
+
export function tierSortKey(tokenTier) {
|
|
365
|
+
const order = TIER_ORDER[tokenTier];
|
|
366
|
+
return order === undefined ? TIER_ORDER.balanced : order;
|
|
367
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// lib/shims.mjs — facade
|
|
1
|
+
// lib/core/shims.mjs — facade. The ONLY module that imports the
|
|
2
2
|
// @deepseek-ai symbols index.mjs relies on, converging the previously
|
|
3
|
-
// top-level-scattered import surface
|
|
3
|
+
// top-level-scattered import surface. Two failure classes:
|
|
4
4
|
//
|
|
5
5
|
// Guard-type (fail-loud, NO fallback): assertSubagentMaxDepth /
|
|
6
6
|
// resolveChildDepth are STATICALLY imported and re-exported. A rename/removal
|
|
@@ -23,14 +23,15 @@
|
|
|
23
23
|
// also verifies the value is a function before accepting it.
|
|
24
24
|
|
|
25
25
|
import { randomUUID } from 'node:crypto';
|
|
26
|
-
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
// Guard-type: static, fail-loud — no fallback. Kept as the only two
|
|
27
28
|
// static @deepseek-ai imports; a missing export aborts module load with a clear
|
|
28
29
|
// error BEFORE apply can run, which is the isolation this class exists for.
|
|
29
30
|
import { assertSubagentMaxDepth, resolveChildDepth } from '@deepseek-ai/dsh-subagent';
|
|
30
31
|
import { toStopReason } from './pure.mjs';
|
|
31
32
|
|
|
32
|
-
// warn: at module top level there is no ctx / logger, so degrade to console.warn
|
|
33
|
-
//
|
|
33
|
+
// warn: at module top level there is no ctx / logger, so degrade to console.warn.
|
|
34
|
+
// The prefix keeps the source recognizable in a shared host log.
|
|
34
35
|
function warn(...parts) {
|
|
35
36
|
console.warn('[dsh-subagent-profile]', ...parts);
|
|
36
37
|
}
|
|
@@ -61,9 +62,9 @@ async function loadSoft(pkg, symbol, fallback, warnMessage, importer = DYNAMIC_I
|
|
|
61
62
|
|
|
62
63
|
// --- local fallbacks (import-free, duck-typed, functionally equivalent) ----
|
|
63
64
|
// Each is the minimal local reimplementation that preserves the observable
|
|
64
|
-
// contract of the shipped helper
|
|
65
|
-
//
|
|
66
|
-
//
|
|
65
|
+
// contract of the shipped helper; these are exported via `__fallbacks` so tests
|
|
66
|
+
// can exercise the degraded path even though the junction packages resolve
|
|
67
|
+
// successfully here.
|
|
67
68
|
|
|
68
69
|
// foldConsumedWork: **近似、非等价**——readResult 只读 `.end`(终止 turn/end 事件)来
|
|
69
70
|
// 推导 stopReason。shipped fold 是精密的 stepped/claimed 状态机;本降级实现取最后一个
|
|
@@ -155,7 +156,7 @@ function resolveChildAgentOptionsFallback(parent, requested, childDepth) {
|
|
|
155
156
|
// defineTool: the dispatch tool cannot exist without dsh-tools. Fail-loud at the
|
|
156
157
|
// point of use with a clear, actionable message — the module still LOADS, and
|
|
157
158
|
// calling this during apply surfaces the exact missing-dependency story instead
|
|
158
|
-
// of a cryptic module-not-found at import time
|
|
159
|
+
// of a cryptic module-not-found at import time(加载不崩溃,调用点才报错)。
|
|
159
160
|
function defineToolFallback() {
|
|
160
161
|
throw new Error('dsh-tools 缺失:dispatch 工具不可用');
|
|
161
162
|
}
|
|
@@ -186,6 +187,89 @@ function readResult(child, boundary, cancelled) {
|
|
|
186
187
|
return { output: finalAssistantOutput(own) ?? [], stopReason };
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
// --- version detection (pure probe) ------------------------------------------
|
|
191
|
+
// 读取 @deepseek-ai 三包(subagent/agent/llm)的 package.json version,与
|
|
192
|
+
// peerDependencies 范围(>=PEER_MIN <PEER_MAX)比对后产出中文 warnings。纯探测:
|
|
193
|
+
// 只读 manifest、不 import 新符号、不触发副作用;每包独立 try/catch,失败记
|
|
194
|
+
// 'unknown'。headless / 宿主裁剪部署下任一包都可能缺失,此时 warnings 非空,
|
|
195
|
+
// 设置页据此在顶部渲染 amber 提示条(不阻断派发)。
|
|
196
|
+
const requirePkg = createRequire(import.meta.url);
|
|
197
|
+
const PEER_MIN = '0.1.0-rc.6';
|
|
198
|
+
const PEER_MAX = '0.2.0';
|
|
199
|
+
const PROBED_PACKAGES = ['dsh-subagent', 'dsh-agent', 'dsh-llm'];
|
|
200
|
+
|
|
201
|
+
// 读单包 version(每包独立 try/catch,失败记 'unknown')。用 createRequire 直接
|
|
202
|
+
// require 包的 package.json(返回解析后的对象),免去 fs 读取与 JSON.parse。
|
|
203
|
+
function readPackageVersion(pkg) {
|
|
204
|
+
try {
|
|
205
|
+
const manifest = requirePkg(`@deepseek-ai/${pkg}/package.json`);
|
|
206
|
+
return typeof manifest.version === 'string' && manifest.version !== '' ? manifest.version : 'unknown';
|
|
207
|
+
} catch {
|
|
208
|
+
return 'unknown';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 极简 semver 比较:major.minor.patch + 可选 `-预发布` 段(覆盖 peerDependencies
|
|
213
|
+
// 范围判断所需)。预发布 < 正式版;预发布段逐段比较,纯数字段按数值、否则按字典序
|
|
214
|
+
// (`0.1.0-rc.10` > `0.1.0-rc.6`)。
|
|
215
|
+
function compareSemver(a, b) {
|
|
216
|
+
const [aCore, aPre = ''] = a.split('-');
|
|
217
|
+
const [bCore, bPre = ''] = b.split('-');
|
|
218
|
+
const aNums = aCore.split('.').map((n) => Number(n));
|
|
219
|
+
const bNums = bCore.split('.').map((n) => Number(n));
|
|
220
|
+
for (let i = 0; i < 3; i++) {
|
|
221
|
+
const x = aNums[i] ?? 0;
|
|
222
|
+
const y = bNums[i] ?? 0;
|
|
223
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
224
|
+
}
|
|
225
|
+
if (aPre === bPre) return 0;
|
|
226
|
+
if (aPre === '') return 1; // 正式版 > 预发布
|
|
227
|
+
if (bPre === '') return -1;
|
|
228
|
+
const aParts = aPre.split('.');
|
|
229
|
+
const bParts = bPre.split('.');
|
|
230
|
+
const len = Math.max(aParts.length, bParts.length);
|
|
231
|
+
for (let i = 0; i < len; i++) {
|
|
232
|
+
const x = aParts[i];
|
|
233
|
+
const y = bParts[i];
|
|
234
|
+
if (x === undefined) return -1;
|
|
235
|
+
if (y === undefined) return 1;
|
|
236
|
+
const xNumeric = /^\d+$/.test(x);
|
|
237
|
+
const yNumeric = /^\d+$/.test(y);
|
|
238
|
+
if (xNumeric && yNumeric) {
|
|
239
|
+
const diff = Number(x) - Number(y);
|
|
240
|
+
if (diff !== 0) return diff;
|
|
241
|
+
} else if (xNumeric !== yNumeric) {
|
|
242
|
+
return xNumeric ? -1 : 1; // 数字标识 < 非数字标识(semver 约定)
|
|
243
|
+
} else if (x !== y) {
|
|
244
|
+
return x < y ? -1 : 1;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 版本是否落在 peerDependencies 范围(>=PEER_MIN 且 <PEER_MAX)。
|
|
251
|
+
function inPeerRange(version) {
|
|
252
|
+
return compareSemver(version, PEER_MIN) >= 0 && compareSemver(version, PEER_MAX) < 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// detectVersions(reader?) — 纯探测三包版本并产出 { versions, warnings }。
|
|
256
|
+
// `reader` 可选注入(默认 readPackageVersion),供测试模拟「包缺失 / 版本越界」
|
|
257
|
+
// 而无需删除 node_modules(与 loadSoft 的 importer 注入同一思路)。
|
|
258
|
+
export function detectVersions(reader = readPackageVersion) {
|
|
259
|
+
const versions = {};
|
|
260
|
+
for (const pkg of PROBED_PACKAGES) versions[pkg] = reader(pkg);
|
|
261
|
+
const warnings = [];
|
|
262
|
+
for (const pkg of PROBED_PACKAGES) {
|
|
263
|
+
const version = versions[pkg];
|
|
264
|
+
if (version === 'unknown') {
|
|
265
|
+
warnings.push(`未检测到 @deepseek-ai/${pkg} 版本(包缺失或被宿主裁剪)——请确认其已按 peerDependencies 范围安装`);
|
|
266
|
+
} else if (!inPeerRange(version)) {
|
|
267
|
+
warnings.push(`@deepseek-ai/${pkg} 版本 ${version} 超出 peerDependencies 范围(>=${PEER_MIN} <${PEER_MAX}),派发行为可能与预期不符`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return { versions, warnings };
|
|
271
|
+
}
|
|
272
|
+
|
|
189
273
|
// Test-only access to the local degraded implementations (package imports
|
|
190
274
|
// resolve here, so the real functions win; __fallbacks lets a test exercise the
|
|
191
275
|
// fail-soft path without deleting node_modules).
|
|
@@ -212,4 +296,6 @@ export {
|
|
|
212
296
|
resolveChildAgentOptions,
|
|
213
297
|
defineTool,
|
|
214
298
|
readResult,
|
|
299
|
+
// ---- version probe seam (see detectVersions doc) ----
|
|
300
|
+
readPackageVersion,
|
|
215
301
|
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// lib/core/whitelist.mjs — target-preset whitelist derived from the runtime
|
|
2
|
+
// roster, not hard-coded: system-trust presets when agentPresets exists, else
|
|
3
|
+
// the shipped fallback names. Moved verbatim from index.mjs;
|
|
4
|
+
// import-free (no @deepseek-ai dependency).
|
|
5
|
+
//
|
|
6
|
+
// Injection note: the original apply-closure version took `agentCtx` and read
|
|
7
|
+
// `agentCtx.get('agentPresets')` itself. The caller now injects the service —
|
|
8
|
+
// `resolveWhitelist(parent.ctx.get('agentPresets'))` — so the module does not
|
|
9
|
+
// depend on apply-closure state. `parent.ctx.get('agentPresets')` yields the
|
|
10
|
+
// exact same value (service or undefined), so the fallback semantics are
|
|
11
|
+
// unchanged. FALLBACK_WHITELIST is exported for the tests.
|
|
12
|
+
|
|
13
|
+
// The target-preset whitelist is derived from the runtime roster, not
|
|
14
|
+
// hard-coded: system-trust presets when agentPresets exists, else the
|
|
15
|
+
// shipped fallback names.
|
|
16
|
+
export const FALLBACK_WHITELIST = ['standard', 'code', 'minimal'];
|
|
17
|
+
|
|
18
|
+
export async function resolveWhitelist(agentPresets) {
|
|
19
|
+
if (agentPresets === undefined) return FALLBACK_WHITELIST;
|
|
20
|
+
const presets = await agentPresets.list();
|
|
21
|
+
return (presets ?? []).filter((preset) => preset && preset.trust === 'system').map((preset) => preset.id);
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-subagent-profile",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Dispatch one-shot subtasks to derived subagents with per-task overrides (preset/model/provider/reasoningEffort/persona/tool whitelist), a runtime-derived cost guard, a subagent-profiles service, observability metadata, and a web-GUI settings page plus a dispatch tool-call card.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
35
|
"@deepseek-ai/dsh-agent": ">=0.1.0-rc.6 <0.2.0",
|
|
36
|
-
"@deepseek-ai/dsh-subagent": ">=0.1.0-rc.6 <0.2.0",
|
|
37
36
|
"@deepseek-ai/dsh-llm": ">=0.1.0-rc.6 <0.2.0",
|
|
37
|
+
"@deepseek-ai/dsh-subagent": ">=0.1.0-rc.6 <0.2.0",
|
|
38
38
|
"@deepseek-ai/dsh-tools": ">=0.1.0-rc.6 <0.2.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependenciesMeta": {
|
|
@@ -53,9 +53,11 @@
|
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@deepseek-ai/dsh-agent": "0.1.0-rc.8",
|
|
56
|
-
"@deepseek-ai/dsh-subagent": "0.1.0-rc.8",
|
|
57
56
|
"@deepseek-ai/dsh-llm": "0.1.0-rc.8",
|
|
58
|
-
"@deepseek-ai/dsh-
|
|
57
|
+
"@deepseek-ai/dsh-subagent": "0.1.0-rc.8",
|
|
58
|
+
"@deepseek-ai/dsh-tools": "0.1.0-rc.8",
|
|
59
|
+
"@eslint/js": "^10.0.1",
|
|
60
|
+
"eslint": "^10.9.0"
|
|
59
61
|
},
|
|
60
62
|
"engines": {
|
|
61
63
|
"node": "^22.19.0 || >=24"
|
|
@@ -73,6 +75,9 @@
|
|
|
73
75
|
"license": "MIT",
|
|
74
76
|
"scripts": {
|
|
75
77
|
"release": "node scripts/release.mjs",
|
|
76
|
-
"test": "node --test \"test/**/*.test.mjs\""
|
|
78
|
+
"test": "node --test \"test/**/*.test.mjs\"",
|
|
79
|
+
"test:bare": "node --test test/pure.test.mjs test/input-schema.test.mjs test/catalog-integrity.test.mjs",
|
|
80
|
+
"preflight": "node scripts/preflight.mjs",
|
|
81
|
+
"leak-scan": "node scripts/leak-scan.mjs"
|
|
77
82
|
}
|
|
78
83
|
}
|