dsh-subagent-profile 0.1.0 → 0.2.0
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 +34 -3
- package/README.zh.md +33 -2
- package/cordis.patch.yml +2 -2
- package/index.mjs +359 -172
- package/lib/client.js +250 -56
- package/lib/pure.mjs +331 -0
- package/lib/shims.mjs +215 -0
- package/package.json +7 -3
- package/presets/orchestrator/agent.cordis.yml +7 -9
package/lib/pure.mjs
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// lib/pure.mjs — import-free pure helpers extracted from index.mjs (V2 T0-2).
|
|
2
|
+
// These four have no @deepseek-ai imports and no external dependencies; each
|
|
3
|
+
// function body is verbatim from index.mjs. @deepseek-ai symbols stay out of
|
|
4
|
+
// this module — the readResult symbols converge in lib/shims.mjs, and index.mjs's
|
|
5
|
+
// remaining direct @deepseek-ai imports converge there too after the V2.0-mid
|
|
6
|
+
// 12-module split (target state).
|
|
7
|
+
|
|
8
|
+
// Shipped toStopReason: map a turn-end reason to the seam's terminal vocabulary.
|
|
9
|
+
export function toStopReason(reason) {
|
|
10
|
+
switch (reason?.kind) {
|
|
11
|
+
case 'completed': return 'completed';
|
|
12
|
+
case 'max-tokens': return 'max-tokens';
|
|
13
|
+
case 'aborted': return 'aborted';
|
|
14
|
+
case 'blocked': return 'refusal';
|
|
15
|
+
default: return 'error';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Shipped stopReasonError + withPartialText wording (dsh-tool-subagent L55-75).
|
|
20
|
+
export function stopReasonError(result) {
|
|
21
|
+
switch (result.stopReason) {
|
|
22
|
+
case 'completed': return;
|
|
23
|
+
case 'aborted': return 'dispatch: subagent run was cancelled';
|
|
24
|
+
case 'error': return 'dispatch: subagent run failed';
|
|
25
|
+
case 'max-tokens': return 'dispatch: subagent run hit its token limit before finishing';
|
|
26
|
+
case 'refusal': return 'dispatch: subagent declined the task';
|
|
27
|
+
default: return `dispatch: subagent run ended abnormally (${String(result.stopReason)})`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function withPartialText(error, output) {
|
|
32
|
+
const text = (Array.isArray(output) ? output : [])
|
|
33
|
+
.filter((block) => block && block.type === 'text')
|
|
34
|
+
.map((block) => block.text)
|
|
35
|
+
.join('');
|
|
36
|
+
return text.length === 0 ? error : `${error}\nPartial output before the run ended:\n${text}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function textFrom(blocks) {
|
|
40
|
+
return (Array.isArray(blocks) ? blocks : [])
|
|
41
|
+
.filter((block) => block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
|
|
42
|
+
.map((block) => block.text)
|
|
43
|
+
.join('');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- V2 安全 P0-a:统一输入 schema(SPEC §7.2 / §12.1-12.2)-----------------
|
|
47
|
+
// sanitizeProfile is the single per-profile sampler shared by loadProfiles
|
|
48
|
+
// (strict=false, migration-tolerant) and the HTTP /add write path (strict=true,
|
|
49
|
+
// write-reject). It normalizes each field with a field-specific sampler and
|
|
50
|
+
// returns `{ clean, warnings }`:
|
|
51
|
+
// - clean — the sanitized profile, minus any rejected field.
|
|
52
|
+
// - warnings — array of `{ field, reason }` (reason is Chinese). Only
|
|
53
|
+
// rejections / over-limit conditions land here; silent
|
|
54
|
+
// normalizations (e.g. description newline-flattening, toolFilter
|
|
55
|
+
// dedupe) do NOT produce a warning, so callers can treat a
|
|
56
|
+
// non-empty warnings list as "this write would drop data".
|
|
57
|
+
|
|
58
|
+
// persona length cap. 建议值 2048,待实测(SPEC §7.2 / §13 回填清单)。The cap
|
|
59
|
+
// applies to the INJECTED persona text, i.e. the guidance prefix + the raw text.
|
|
60
|
+
export const PERSONA_MAX_CHARS = 2048;
|
|
61
|
+
|
|
62
|
+
// The persona is injected as a shadow section; the guidance marker is prefixed
|
|
63
|
+
// to make its non-authoritative nature explicit (双防线). 仅用于写入校验与提示。
|
|
64
|
+
export const GUIDANCE_PREFIX = '[guidance, not authority] ';
|
|
65
|
+
|
|
66
|
+
// Shared delegation caps — single source of truth for sanitizeProfile and the
|
|
67
|
+
// cost guard in index.mjs (SPEC §7.3 keeps these hard caps always-on).
|
|
68
|
+
export const MAX_TOKENS = 65536;
|
|
69
|
+
export const MAX_DEPTH = 3;
|
|
70
|
+
|
|
71
|
+
// --- V2 安全 P0-b:cost guard 硬上限(SPEC §7.3)-------------------------------
|
|
72
|
+
// assertHardLimits is the always-on budget-cap check. It was moved OUT of the
|
|
73
|
+
// `llm`-dependency branch in index.mjs's assertCostGuard: maxTokens / maxDepth
|
|
74
|
+
// are hard delegation caps, so they must NOT silently stop applying when the
|
|
75
|
+
// `llm` service is absent (the old `if (llm === undefined) return` skipped them).
|
|
76
|
+
// 超限 throw(中文、可操作),与 sanitizeProfile 共用同一组常量。非数字/未设值
|
|
77
|
+
// 不触发(sanitizeProfile 已在写路径拒绝非数字,这里仅兜底运行时竞态)。
|
|
78
|
+
export function assertHardLimits(maxTokens, maxDepth) {
|
|
79
|
+
if (typeof maxTokens === 'number' && maxTokens > MAX_TOKENS) {
|
|
80
|
+
throw new Error(`dispatch: maxTokens ${maxTokens} 超过委派上限 ${MAX_TOKENS}`);
|
|
81
|
+
}
|
|
82
|
+
if (typeof maxDepth === 'number' && maxDepth > MAX_DEPTH) {
|
|
83
|
+
throw new Error(`dispatch: maxDepth ${maxDepth} 超过委派上限 ${MAX_DEPTH}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- V2 安全 P0-b:continuable 工具门闭集(SPEC §7.1)-------------------------
|
|
88
|
+
// computeContinuableAllow pre-computes the CLOSED tool `allow` set for the
|
|
89
|
+
// continuable dispatch path: 父工具集 − run_code − (toolFilter.deny),并在存在
|
|
90
|
+
// toolFilter.allow 时再 ∩ allow。空集 fail-loud(throw)—— 绝不静默派发零工具。
|
|
91
|
+
//
|
|
92
|
+
// 代码编辑者注意:这里写代码注释的「假设 / 失效条件」必须与 index.mjs
|
|
93
|
+
// continuable 分支的注释保持一致(SPEC §7.1 要求写入代码注释与 README)。
|
|
94
|
+
export function computeContinuableAllow(parentNames, toolFilter = {}) {
|
|
95
|
+
const parent = new Set(parentNames);
|
|
96
|
+
parent.delete('run_code');
|
|
97
|
+
// deny 与 allow 对称守卫:非数组(如恶意/异常输入)按「无 deny」处理,防抛裸
|
|
98
|
+
// TypeError;allow 同理在下方用 Array.isArray 守卫。
|
|
99
|
+
const deny = Array.isArray(toolFilter?.deny) ? toolFilter.deny : [];
|
|
100
|
+
for (const name of deny) parent.delete(name);
|
|
101
|
+
let result = [...parent];
|
|
102
|
+
const allow = toolFilter?.allow;
|
|
103
|
+
if (Array.isArray(allow)) {
|
|
104
|
+
const allowSet = new Set(allow);
|
|
105
|
+
result = result.filter((name) => allowSet.has(name));
|
|
106
|
+
}
|
|
107
|
+
// 空集 fail-loud:派发一个零工具子 Agent 是静默降级,必须拒绝。
|
|
108
|
+
if (result.length === 0) {
|
|
109
|
+
throw new Error('dispatch: continuable 工具集为空,拒绝派发');
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// description/name sampler: `\n→空格`压平. These are display lines (the
|
|
115
|
+
// dispatch:profiles section interpolates them into a single row), so a newline
|
|
116
|
+
// would break the row. 压平仅作存储层;引号包裹由显示层负责——显示层在该行给
|
|
117
|
+
// description 加引号(空时显示 '(无描述)' 不套引号),本纯函数只保证存储值
|
|
118
|
+
// 是单行、无换行。
|
|
119
|
+
function sanitizeShortText(value) {
|
|
120
|
+
return value.replace(/\r\n|\r|\n/g, ' ');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- V2 Token P0:结果回收默认剪枝(SPEC §8.2)-------------------------------
|
|
124
|
+
// 子结果默认复用宿主 `toolResultPruner.pruneContent` 预剪(纯函数、零 LLM),
|
|
125
|
+
// 在 `textFrom` 之前执行,把回灌进父上下文的体积压到阈值内。
|
|
126
|
+
//
|
|
127
|
+
// 阈值常量:head / tail / minKeep 是**子级**预期裁剪口径(比 agent.cordis.yml
|
|
128
|
+
// 现行 compaction-basic 的 4096/1024 更保守)。**待实测**——宿主
|
|
129
|
+
// toolResultPruner.pruneContent(blocks) 只接收 blocks,自身读取其配置
|
|
130
|
+
// (thresholdChars/headChars/tailChars),因此这三个常量当前**不会**作为实参
|
|
131
|
+
// 传给宿主 pruner;它们记录本条目的子级口径,并保留给 V2.0-中期「信封 / 精修
|
|
132
|
+
// 剪枝」路径使用。改动前先实测真实分布再回填 SPEC §13。
|
|
133
|
+
export const PRUNE_HEAD_CHARS = 2048;
|
|
134
|
+
export const PRUNE_TAIL_CHARS = 1024;
|
|
135
|
+
export const PRUNE_MIN_KEEP = 128;
|
|
136
|
+
|
|
137
|
+
// Apply host pruning to one subagent result's content blocks.
|
|
138
|
+
// - `blocks` — the result.output content blocks (or undefined; not an array).
|
|
139
|
+
// - `pruner` — the `toolResultPruner` service, or undefined.
|
|
140
|
+
// Returns the (possibly pruned) blocks, or an empty array placeholder. When the
|
|
141
|
+
// pruner is absent (headless deployment without the compaction pruner) OR the
|
|
142
|
+
// content is not an array, it falls back to NO pruning — 剪枝是增强,绝非硬依赖
|
|
143
|
+
// (SPEC §8.2)。A host pruner that throws on an unusual content shape also
|
|
144
|
+
// falls back to the full output, because automatic pruning must NEVER swallow a
|
|
145
|
+
// legitimate child result.
|
|
146
|
+
export function pruneBlocks(blocks, pruner) {
|
|
147
|
+
if (Array.isArray(blocks) && pruner !== undefined && typeof pruner.pruneContent === 'function') {
|
|
148
|
+
try {
|
|
149
|
+
const pruned = pruner.pruneContent(blocks);
|
|
150
|
+
return pruned ?? blocks;
|
|
151
|
+
} catch {
|
|
152
|
+
return blocks;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return Array.isArray(blocks) ? blocks : [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- V2 Token P0:continuable 可见性修复(SPEC §8.4 / 共享规则 R1)-----------
|
|
159
|
+
// R1 requires the three closed `output.schema.oneOf` branches
|
|
160
|
+
// (background / continuable / foreground) to carry an IDENTICAL shared meta key
|
|
161
|
+
// set whenever a result-meta field is added — so `ignored`, `reasoningEffort`,
|
|
162
|
+
// `profile/preset/provider/model` must appear in ALL three branches, keeping the
|
|
163
|
+
// model-side schema from rejecting a分支 that "forgot" the field.
|
|
164
|
+
//
|
|
165
|
+
// 判据:元数据集合 = 每个分支 properties 的键集,**剔除**各分支自有的判别键
|
|
166
|
+
// (`kind`/`jobId`/`subagentId`/`output`——background/continuable/foreground 各自
|
|
167
|
+
// 的判别字段不同,不纳入一致性比较)。剩下必须是三者的公共元数据集合,三处
|
|
168
|
+
// 逐一对齐;任一分支缺漏/多余公共元数据键即 throw(中文、指明 R1)。
|
|
169
|
+
const RESULT_SCHEMA_DISCRIMINATOR_KEYS = new Set(['kind', 'jobId', 'subagentId', 'output']);
|
|
170
|
+
|
|
171
|
+
export function assertResultSchemaConsistency(schema) {
|
|
172
|
+
if (!schema || typeof schema !== 'object') {
|
|
173
|
+
throw new Error('dispatch: 结果 schema 必须为对象(R1 closed oneOf)');
|
|
174
|
+
}
|
|
175
|
+
const branches = schema.oneOf;
|
|
176
|
+
if (!Array.isArray(branches) || branches.length === 0) {
|
|
177
|
+
throw new Error('dispatch: 结果 schema 必须包含非空 oneOf 分支');
|
|
178
|
+
}
|
|
179
|
+
const metaSets = branches.map((branch, index) => {
|
|
180
|
+
if (!branch || typeof branch !== 'object' || branch.properties === null || typeof branch.properties !== 'object') {
|
|
181
|
+
throw new Error(`dispatch: 结果 schema oneOf 分支 ${index + 1} 缺少 properties 对象`);
|
|
182
|
+
}
|
|
183
|
+
return new Set(
|
|
184
|
+
Object.keys(branch.properties).filter((key) => !RESULT_SCHEMA_DISCRIMINATOR_KEYS.has(key))
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
const ref = metaSets[0];
|
|
188
|
+
for (let i = 1; i < metaSets.length; i++) {
|
|
189
|
+
const current = metaSets[i];
|
|
190
|
+
const same = current.size === ref.size && [...ref].every((key) => current.has(key));
|
|
191
|
+
if (!same) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`dispatch: 结果 schema oneOf 分支 ${i + 1} 的元数据字段集与分支 1 不一致(R1:凡向结果 meta 增字段须同步全三分支;期望 ${[...ref].join(', ')},实际 ${[...current].join(', ')})`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The full field set that may live on a profile. ANY other key — including
|
|
201
|
+
// __proto__ / constructor / prototype (which JSON.parse can produce as OWN
|
|
202
|
+
// properties) — is dropped instead of copied, so assignment never walks into
|
|
203
|
+
// the object prototype chain (prototype-pollution guard).
|
|
204
|
+
const KNOWN_PROFILE_FIELDS = new Set([
|
|
205
|
+
'id', 'name', 'description', 'persona', 'preset', 'provider', 'model',
|
|
206
|
+
'reasoningEffort', 'enabled', 'maxTokens', 'maxDepth', 'toolFilter',
|
|
207
|
+
'builtin', 'deleted',
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Sanitize one profile entry. `options.strict`:
|
|
212
|
+
* - strict=false (migration read): an over-length persona is KEPT (not
|
|
213
|
+
* truncated) and warned; over-limit maxTokens/maxDepth and illegal
|
|
214
|
+
* toolFilter subfields are dropped (field removed) + warned.
|
|
215
|
+
* - strict=true (write path): an over-length persona is also REMOVED from
|
|
216
|
+
* clean and warned, so the caller can reject the whole write with a 400.
|
|
217
|
+
* Non-rejection normalizations (description/name flatten, toolFilter dedupe) are
|
|
218
|
+
* silent and never produce a warning.
|
|
219
|
+
*
|
|
220
|
+
* Security (P1): `clean` is `Object.create(null)` (no inherited `__proto__`
|
|
221
|
+
* setter) and only whitelisted fields are copied, so a hostile `__proto__` /
|
|
222
|
+
* `constructor` / `prototype` key is ignored rather than polluting the result.
|
|
223
|
+
*/
|
|
224
|
+
export function sanitizeProfile(profile, options = {}) {
|
|
225
|
+
const { strict = false } = options;
|
|
226
|
+
const clean = Object.create(null);
|
|
227
|
+
const warnings = [];
|
|
228
|
+
if (profile === null || typeof profile !== 'object' || Array.isArray(profile)) {
|
|
229
|
+
return { clean, warnings: [{ field: '(root)', reason: 'profile 不是对象' }] };
|
|
230
|
+
}
|
|
231
|
+
for (const [key, value] of Object.entries(profile)) {
|
|
232
|
+
if (!KNOWN_PROFILE_FIELDS.has(key)) {
|
|
233
|
+
warnings.push({ field: key, reason: '未知字段已忽略' });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
switch (key) {
|
|
237
|
+
case 'name':
|
|
238
|
+
case 'description': {
|
|
239
|
+
if (value === undefined) break;
|
|
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);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
case 'persona': {
|
|
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;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case 'maxTokens': {
|
|
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;
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
case 'maxDepth': {
|
|
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;
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
case 'toolFilter': {
|
|
304
|
+
if (value === undefined) break;
|
|
305
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
306
|
+
warnings.push({ field: 'toolFilter', reason: 'toolFilter 必须为对象' });
|
|
307
|
+
break;
|
|
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;
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
default:
|
|
326
|
+
clean[key] = value;
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return { clean, warnings };
|
|
331
|
+
}
|
package/lib/shims.mjs
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// lib/shims.mjs — facade (V2 Task 6a). The ONLY module that imports the
|
|
2
|
+
// @deepseek-ai symbols index.mjs relies on, converging the previously
|
|
3
|
+
// top-level-scattered import surface (SPEC §9.2). Two failure classes:
|
|
4
|
+
//
|
|
5
|
+
// Guard-type (fail-loud, NO fallback): assertSubagentMaxDepth /
|
|
6
|
+
// resolveChildDepth are STATICALLY imported and re-exported. A rename/removal
|
|
7
|
+
// in the host rc makes this module — and therefore index.mjs — fail at load
|
|
8
|
+
// time with the ESM link error "The requested module ... does not provide an
|
|
9
|
+
// export named '...'". That is exactly the loading-time isolation the spec
|
|
10
|
+
// wants for the delegation-depth safety gates: no weakened reimplementation is
|
|
11
|
+
// ever substituted, so a child can never be dispatched past MAX_DEPTH.
|
|
12
|
+
//
|
|
13
|
+
// Function-mapping (fail-soft + warn): foldConsumedWork / finalAssistantOutput
|
|
14
|
+
// / createUserMessage / appendDelegatedPolicyOverrides /
|
|
15
|
+
// captureDelegatedPolicyOverrides / resolveChildAgentOptions / defineTool are
|
|
16
|
+
// loaded with a dynamic top-level-await import wrapped in try/catch. On an
|
|
17
|
+
// import failure (or a renamed/absent named export) the module still loads, a
|
|
18
|
+
// console.warn is emitted (module top level has no ctx, so no logger), and a
|
|
19
|
+
// local functionally-equivalent (or safe-degraded) implementation is used.
|
|
20
|
+
//
|
|
21
|
+
// A dynamic import whose package loads but whose named export was renamed or
|
|
22
|
+
// removed resolves the destructure to `undefined` WITHOUT throwing, so loadSoft
|
|
23
|
+
// also verifies the value is a function before accepting it.
|
|
24
|
+
|
|
25
|
+
import { randomUUID } from 'node:crypto';
|
|
26
|
+
// Guard-type: static, fail-loud — no fallback (SPEC §9.2). Kept as the only two
|
|
27
|
+
// static @deepseek-ai imports; a missing export aborts module load with a clear
|
|
28
|
+
// error BEFORE apply can run, which is the isolation this class exists for.
|
|
29
|
+
import { assertSubagentMaxDepth, resolveChildDepth } from '@deepseek-ai/dsh-subagent';
|
|
30
|
+
import { toStopReason } from './pure.mjs';
|
|
31
|
+
|
|
32
|
+
// warn: at module top level there is no ctx / logger, so degrade to console.warn
|
|
33
|
+
// (SPEC §9.2). The prefix keeps the source recognizable in a shared host log.
|
|
34
|
+
function warn(...parts) {
|
|
35
|
+
console.warn('[dsh-subagent-profile]', ...parts);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Load one function-mapping `@deepseek-ai` symbol, returning a local fallback when
|
|
39
|
+
// the package or the named export is unavailable. `warnMessage` carries the
|
|
40
|
+
// actionable user-facing text. `importer` is an injectable loader (defaults to
|
|
41
|
+
// the dynamic `import` — see DYNAMIC_IMPORT) so tests / future loaders can
|
|
42
|
+
// override the failure route (e.g. stub it to throw, or return a module object
|
|
43
|
+
// missing the symbol). It is exported as a seam for that purpose.
|
|
44
|
+
//
|
|
45
|
+
// Note: `import` is a keyword and cannot be used as a value reference
|
|
46
|
+
// (`importer = import` is a SyntaxError), so the default is a thin wrapper around
|
|
47
|
+
// the dynamic import expression.
|
|
48
|
+
const DYNAMIC_IMPORT = (specifier) => import(specifier);
|
|
49
|
+
async function loadSoft(pkg, symbol, fallback, warnMessage, importer = DYNAMIC_IMPORT) {
|
|
50
|
+
try {
|
|
51
|
+
const mod = await importer(pkg);
|
|
52
|
+
if (typeof mod?.[symbol] === 'function') return mod[symbol];
|
|
53
|
+
warn(warnMessage);
|
|
54
|
+
return fallback;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
57
|
+
warn(`${warnMessage}(导入失败:${detail})`);
|
|
58
|
+
return fallback;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- local fallbacks (import-free, duck-typed, functionally equivalent) ----
|
|
63
|
+
// Each is the minimal local reimplementation that preserves the observable
|
|
64
|
+
// contract of the shipped helper. See the fallback-semantics report in the Task
|
|
65
|
+
// 6a summary; these are exported via `__fallbacks` so tests can exercise the
|
|
66
|
+
// degraded path even though the junction packages resolve successfully here.
|
|
67
|
+
|
|
68
|
+
// foldConsumedWork: **近似、非等价**——readResult 只读 `.end`(终止 turn/end 事件)来
|
|
69
|
+
// 推导 stopReason。shipped fold 是精密的 stepped/claimed 状态机;本降级实现取最后一个
|
|
70
|
+
// `turn/end` 事件。在多 turn 或「有 claim 但未 step」的 turn 边缘场景,`.end` 可能异于
|
|
71
|
+
// 宿主 shipped fold(后者只在 stepped 或 claimed+accountsForClaim 的 turn 上落 `.end`),
|
|
72
|
+
// 因此**勿当作完全等价**。仅因 readResult 只消费 `.end`、且此为减压路径才作此近似;
|
|
73
|
+
// `droppedUnrun` 不被 readResult 使用,保守置 false。
|
|
74
|
+
function foldConsumedWorkFallback(events) {
|
|
75
|
+
let end;
|
|
76
|
+
for (const event of events) {
|
|
77
|
+
if (event && event.type === 'turn/end') end = event;
|
|
78
|
+
}
|
|
79
|
+
return { ...(end === undefined ? {} : { end }), droppedUnrun: false };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// finalAssistantOutput: identical fold rule — last non-empty assistant/message,
|
|
83
|
+
// else accumulated text-delta chunks, else undefined.
|
|
84
|
+
function finalAssistantOutputFallback(events) {
|
|
85
|
+
let message;
|
|
86
|
+
const partial = [];
|
|
87
|
+
for (const event of events) {
|
|
88
|
+
if (event && event.type === 'assistant/message') {
|
|
89
|
+
const content = event.data?.message?.content;
|
|
90
|
+
if (Array.isArray(content) && content.length > 0) message = content;
|
|
91
|
+
} else if (event && event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') {
|
|
92
|
+
const text = event.data.chunk.text;
|
|
93
|
+
if (typeof text === 'string' && text.length > 0) partial.push(text);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (message !== undefined) return message;
|
|
97
|
+
const text = partial.join('');
|
|
98
|
+
return text.length > 0 ? [{ type: 'text', text }] : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// createUserMessage: build the user-role message the followup driver needs. The
|
|
102
|
+
// shipped helper uses a branded MessageId + deepFreeze; the fallback provides the
|
|
103
|
+
// same observable shape (role/content/id/source) with a fresh random id. Note:
|
|
104
|
+
// the returned object is MUTABLE (no deepFreeze) — consumers must NOT rely on
|
|
105
|
+
// the shipped deepFreeze immutability; treat it as a plain message object.
|
|
106
|
+
function createUserMessageFallback(input) {
|
|
107
|
+
return {
|
|
108
|
+
...input,
|
|
109
|
+
role: 'user',
|
|
110
|
+
id: randomUUID(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// appendDelegatedPolicyOverrides: the delegation policy is authored INTO the
|
|
115
|
+
// child's log for reconstruction. This is also the security-relevant
|
|
116
|
+
// "approval: never" pin, so it must NOT be a no-op — reproduce the append
|
|
117
|
+
// faithfully to keep the child's own log reconstructable and the pin visible.
|
|
118
|
+
function appendDelegatedPolicyOverridesFallback(childSession, overrides) {
|
|
119
|
+
const o = overrides || {};
|
|
120
|
+
if (o.sandboxMode !== undefined) {
|
|
121
|
+
childSession.append('sandbox/mode', { mode: o.sandboxMode, source: 'delegation' });
|
|
122
|
+
}
|
|
123
|
+
if (o.approvalPolicy !== undefined) {
|
|
124
|
+
childSession.append('approval/policy', { policy: o.approvalPolicy, source: 'delegation' });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// captureDelegatedPolicyOverrides: the parent's explicit sandbox override (or
|
|
129
|
+
// undefined when none) plus the approval pin 'never' when the parent has an
|
|
130
|
+
// approval service. Optional chaining keeps a mock parent (tests) working.
|
|
131
|
+
function captureDelegatedPolicyOverridesFallback(parent) {
|
|
132
|
+
return {
|
|
133
|
+
sandboxMode: parent.ctx?.get?.('sandboxPolicy')?.overrideOf?.(parent.session),
|
|
134
|
+
approvalPolicy: parent.ctx?.get?.('approval') === undefined ? undefined : 'never',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// resolveChildAgentOptions: merge the parent's route (provider/model/maxTokens,
|
|
139
|
+
// present-only) with the per-child overrides and stamp the child's own depth.
|
|
140
|
+
// This must preserve the child-config semantics — a no-op here would silently
|
|
141
|
+
// break delegation routing.
|
|
142
|
+
function resolveChildAgentOptionsFallback(parent, requested, childDepth) {
|
|
143
|
+
const parentProvider = parent.options?.provider;
|
|
144
|
+
const parentModel = parent.options?.model;
|
|
145
|
+
const parentMaxTokens = parent.options?.maxTokens;
|
|
146
|
+
return {
|
|
147
|
+
...(parentProvider !== undefined ? { provider: parentProvider } : {}),
|
|
148
|
+
...(parentModel !== undefined ? { model: parentModel } : {}),
|
|
149
|
+
...(parentMaxTokens !== undefined ? { maxTokens: parentMaxTokens } : {}),
|
|
150
|
+
...(requested || {}),
|
|
151
|
+
subagentDepth: childDepth,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// defineTool: the dispatch tool cannot exist without dsh-tools. Fail-loud at the
|
|
156
|
+
// point of use with a clear, actionable message — the module still LOADS, and
|
|
157
|
+
// calling this during apply surfaces the exact missing-dependency story instead
|
|
158
|
+
// of a cryptic module-not-found at import time (SPEC §9.2 "不崩溃").
|
|
159
|
+
function defineToolFallback() {
|
|
160
|
+
throw new Error('dsh-tools 缺失:dispatch 工具不可用');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- wire the function-mapping symbols (module top-level await) --------------
|
|
164
|
+
// The seven loads are independent, so they run in parallel (Promise.all) to cut
|
|
165
|
+
// module-load latency; each still degrades to its local fallback on failure. We
|
|
166
|
+
// keep the real functions when the junction resolves, and the fallbacks when a
|
|
167
|
+
// host rc renamed/removed a symbol.
|
|
168
|
+
const [foldConsumedWork, finalAssistantOutput, createUserMessage, appendDelegatedPolicyOverrides, captureDelegatedPolicyOverrides, resolveChildAgentOptions, defineTool] = await Promise.all([
|
|
169
|
+
loadSoft('@deepseek-ai/dsh-agent', 'foldConsumedWork', foldConsumedWorkFallback, 'dsh-agent 的 foldConsumedWork 不可用,结果裁切使用本地降级实现'),
|
|
170
|
+
loadSoft('@deepseek-ai/dsh-subagent', 'finalAssistantOutput', finalAssistantOutputFallback, 'dsh-subagent 的 finalAssistantOutput 不可用,子结果选取使用本地降级实现'),
|
|
171
|
+
loadSoft('@deepseek-ai/dsh-llm', 'createUserMessage', createUserMessageFallback, 'dsh-llm 的 createUserMessage 不可用,用户消息构造使用本地降级实现'),
|
|
172
|
+
loadSoft('@deepseek-ai/dsh-subagent', 'appendDelegatedPolicyOverrides', appendDelegatedPolicyOverridesFallback, 'dsh-subagent 的 appendDelegatedPolicyOverrides 不可用,委派策略追加使用本地降级实现'),
|
|
173
|
+
loadSoft('@deepseek-ai/dsh-subagent', 'captureDelegatedPolicyOverrides', captureDelegatedPolicyOverridesFallback, 'dsh-subagent 的 captureDelegatedPolicyOverrides 不可用,委派策略捕获使用本地降级实现'),
|
|
174
|
+
loadSoft('@deepseek-ai/dsh-subagent', 'resolveChildAgentOptions', resolveChildAgentOptionsFallback, 'dsh-subagent 的 resolveChildAgentOptions 不可用,子 Agent 选项解析使用本地降级实现'),
|
|
175
|
+
loadSoft('@deepseek-ai/dsh-tools', 'defineTool', defineToolFallback, 'dsh-tools 缺失:dispatch 工具不可用'),
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
// readResult: shipped shape. The terminal turn reason comes from foldConsumedWork;
|
|
179
|
+
// the selected output comes from finalAssistantOutput (last non-empty assistant
|
|
180
|
+
// message, else joined text-delta chunks, else undefined -> []).
|
|
181
|
+
function readResult(child, boundary, cancelled) {
|
|
182
|
+
const own = child.session.events.slice(boundary);
|
|
183
|
+
const end = foldConsumedWork(own).end;
|
|
184
|
+
const recorded = toStopReason(end?.data.reason);
|
|
185
|
+
const stopReason = cancelled && recorded !== 'completed' ? 'aborted' : recorded;
|
|
186
|
+
return { output: finalAssistantOutput(own) ?? [], stopReason };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Test-only access to the local degraded implementations (package imports
|
|
190
|
+
// resolve here, so the real functions win; __fallbacks lets a test exercise the
|
|
191
|
+
// fail-soft path without deleting node_modules).
|
|
192
|
+
export const __fallbacks = {
|
|
193
|
+
foldConsumedWork: foldConsumedWorkFallback,
|
|
194
|
+
finalAssistantOutput: finalAssistantOutputFallback,
|
|
195
|
+
createUserMessage: createUserMessageFallback,
|
|
196
|
+
appendDelegatedPolicyOverrides: appendDelegatedPolicyOverridesFallback,
|
|
197
|
+
captureDelegatedPolicyOverrides: captureDelegatedPolicyOverridesFallback,
|
|
198
|
+
resolveChildAgentOptions: resolveChildAgentOptionsFallback,
|
|
199
|
+
defineTool: defineToolFallback,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export {
|
|
203
|
+
assertSubagentMaxDepth,
|
|
204
|
+
resolveChildDepth,
|
|
205
|
+
// ---- test seam / injectable loader (see loadSoft doc) ----
|
|
206
|
+
loadSoft,
|
|
207
|
+
foldConsumedWork,
|
|
208
|
+
finalAssistantOutput,
|
|
209
|
+
createUserMessage,
|
|
210
|
+
appendDelegatedPolicyOverrides,
|
|
211
|
+
captureDelegatedPolicyOverrides,
|
|
212
|
+
resolveChildAgentOptions,
|
|
213
|
+
defineTool,
|
|
214
|
+
readResult,
|
|
215
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-subagent-profile",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"index.mjs",
|
|
18
|
-
"lib
|
|
18
|
+
"lib",
|
|
19
19
|
"cordis.patch.yml",
|
|
20
20
|
"presets",
|
|
21
21
|
"docs",
|
|
@@ -70,5 +70,9 @@
|
|
|
70
70
|
"dispatch",
|
|
71
71
|
"profiles"
|
|
72
72
|
],
|
|
73
|
-
"license": "MIT"
|
|
73
|
+
"license": "MIT",
|
|
74
|
+
"scripts": {
|
|
75
|
+
"release": "node scripts/release.mjs",
|
|
76
|
+
"test": "node --test \"test/**/*.test.mjs\""
|
|
77
|
+
}
|
|
74
78
|
}
|
|
@@ -35,15 +35,13 @@
|
|
|
35
35
|
name: '@deepseek-ai/dsh-persona'
|
|
36
36
|
config:
|
|
37
37
|
text: |-
|
|
38
|
-
你是主协调 Agent(Orchestrator),由 {{model}} 驱动,工作目录是 {{cwd}}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
5. 失败处理:子 Agent 失败或超出权限时,自己接手重做,或用 ask_user_question 向用户澄清。
|
|
46
|
-
6. 边界:子 Agent 的能力 ⊆ 你的能力,审批恒为「永不」;不要委派你也没有权限做的事,也不要让子 Agent 提权。
|
|
38
|
+
你是主协调 Agent(Orchestrator),由 {{model}} 驱动,工作目录是 {{cwd}}。职责是「拆解 → 委派 → 集成」:把复杂任务拆成边界清晰的子任务,按场景交给合适的子 Agent,再亲自核对质量、整合成连贯交付(别原样堆砌)。
|
|
39
|
+
|
|
40
|
+
委派:优先 dispatch 按场景选方案(swap-standard=标准编码、researcher=调研检索,可在「子 Agent 方案」设置页自定义);多轮可续探索用 subagent/subagent_fork,多路批量并行用 workflow,长任务反复迭代用 ralph;能后台并行就 run_in_background,不要串行干等。
|
|
41
|
+
|
|
42
|
+
prompt 必须自包含:子 Agent 看不到你的对话,写全背景、目标、期望产出、验收标准与禁止事项。
|
|
43
|
+
|
|
44
|
+
边界:子 Agent 能力 ⊆ 你,审批恒为「永不」;子 Agent 失败或超出权限时自己接手重做,或用 ask_user_question 向用户澄清。
|
|
47
45
|
|
|
48
46
|
- id: agent-instructions
|
|
49
47
|
name: '@deepseek-ai/dsh-agent-instructions'
|