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.
Files changed (40) hide show
  1. package/README.md +77 -40
  2. package/README.zh.md +111 -74
  3. package/docs/screenshots/dispatch-card.png +0 -0
  4. package/docs/screenshots/settings-page1.png +0 -0
  5. package/docs/screenshots/settings-page2.png +0 -0
  6. package/index.mjs +276 -81
  7. package/lib/client.js +3218 -166
  8. package/lib/core/adoption-reminder.mjs +48 -0
  9. package/lib/core/adoption-tracker.mjs +430 -0
  10. package/lib/core/background-ledger.mjs +71 -0
  11. package/lib/core/catalog-cache.mjs +45 -7
  12. package/lib/core/catalog.mjs +6 -6
  13. package/lib/core/cost-evidence.mjs +145 -0
  14. package/lib/core/cost-guard.mjs +71 -44
  15. package/lib/core/decision-trace.mjs +413 -0
  16. package/lib/core/delegation.mjs +111 -50
  17. package/lib/core/dispatch-gates.mjs +153 -0
  18. package/lib/core/dispatch-guard.mjs +156 -0
  19. package/lib/core/dispatch-schema.mjs +103 -14
  20. package/lib/core/dispatch-tool.mjs +220 -204
  21. package/lib/core/draft-gates.mjs +45 -0
  22. package/lib/core/drafts-store.mjs +45 -0
  23. package/lib/core/escape.mjs +130 -0
  24. package/lib/core/evolution-advice.mjs +224 -0
  25. package/lib/core/evolution-ledger.mjs +300 -0
  26. package/lib/core/evolution-summary.mjs +255 -0
  27. package/lib/core/http-routes.mjs +256 -72
  28. package/lib/core/intersection.mjs +6 -9
  29. package/lib/core/presets-sync.mjs +161 -43
  30. package/lib/core/prices.mjs +46 -0
  31. package/lib/core/profile-directory.mjs +139 -0
  32. package/lib/core/profile-provider.mjs +42 -39
  33. package/lib/core/profiles-store.mjs +103 -76
  34. package/lib/core/pure.mjs +110 -66
  35. package/lib/core/reminder-store.mjs +172 -0
  36. package/lib/core/shims.mjs +67 -76
  37. package/lib/core/whitelist.mjs +23 -17
  38. package/package.json +82 -83
  39. package/presets/orchestrator/agent.cordis.yml +59 -87
  40. package/presets/orchestrator/NOTICE +0 -3
@@ -2,106 +2,39 @@
2
2
  // 结果 schema 一致性锁与 syncTool 注册/注销逻辑,从 index.mjs 逐字拆出。
3
3
  // 仅引用 lib + shims;无 @deepseek-ai 依赖(shims 是唯一入口)。
4
4
  //
5
- // Injection: every apply-closure / ctx dependency is an explicit parameter —
6
- // register ctx.tools.register (the tool is registered/unregistered by
7
- // syncTool, so the settings switch can remove it at runtime),
8
- // store the profile store (resolveProfile for the base profile,
9
- // getAllowFailOpen for the cost guard),
10
- // getEnabled reads the apply-closure `enabled` flag (continuable fail-loud
11
- // gate + syncTool registration condition),
12
- // getService request-time service getter (ctx.get('toolResultPruner') /
13
- // ctx.get('jobs') are read per call, never at apply time),
14
- // logger ctx.logger (decision-level dispatch log + continuable warns),
15
- // subagents ctx.subagents (start / startContinuable drive the child).
16
- // The factory returns { syncTool, dispose }: syncTool is handed to the HTTP
17
- // routes (/set-enabled), dispose runs on plugin teardown.
5
+ // 注入:所有 apply 闭包 / ctx 依赖都是显式参数——
6
+ // register ctx.tools.register(工具由 syncTool 注册/注销,设置开关可运行时移除),
7
+ // store profile store(resolveProfile 取基方案、getAllowFailOpen cost guard),
8
+ // getEnabled 读取 apply 闭包 `enabled` 标志(continuable fail-loud + syncTool 注册条件),
9
+ // getService 请求时服务 getter(ctx.get('toolResultPruner') / ctx.get('jobs')
10
+ // 按次读取,绝不在 apply 时读),
11
+ // logger ctx.logger(决策级派发日志 + continuable 告警),
12
+ // subagents ctx.subagents(start / startContinuable 驱动子 Agent),
13
+ // guard dispatch guard(execute 入口 acquire / 结算 release+记账 / syncTool 禁用时 cancelAll+reset)。
14
+ // 工厂返回 { syncTool, dispose }:syncTool 交给 HTTP 路由(/set-enabled),dispose 在插件卸载时运行。
18
15
  //
19
- // execute 按预检段 + 前台/后台/continuable 三分支拆为模块级私有函数;
20
- // parameters 声明为纯数据,驻留模块级常量(与数据表同性质,不受函数行门约束);
21
- // output 结果 schema 已拆至 lib/core/dispatch-schema.mjs(守文件行门)——
22
- // 工厂保持装配态;除各任务明示的新增行为外,执行路径零漂移。
16
+ // execute 按预检段 + 三分支拆为模块级私有函数;parameters/output schema 拆至 dispatch-schema.mjs,预检闸拆至 dispatch-gates.mjs(守文件行门),零漂移。
23
17
 
24
18
  import { defineTool } from './shims.mjs';
25
- import { computeContinuableAllow, textFrom, stopReasonError, withPartialText, pruneBlocks, assertResultSchemaConsistency } from './pure.mjs';
19
+ import { textFrom, stopReasonError, withPartialText, pruneBlocks, assertResultSchemaConsistency, trustLabel, trustAudit, dispatchLabel } from './pure.mjs';
26
20
  import { resolveWhitelist } from './whitelist.mjs';
27
- import { assertCostGuard } from './cost-guard.mjs';
28
- import { settleStart, collectChildUsage } from './delegation.mjs';
29
- import { DISPATCH_OUTPUT_SCHEMA } from './dispatch-schema.mjs';
21
+ import { recordEscapeAllow } from './escape.mjs';
22
+ import { settleStart, collectChildUsage, collectChildCalls } from './delegation.mjs';
23
+ import { DISPATCH_OUTPUT_SCHEMA, DISPATCH_PARAMETERS, DISPATCH_RENDER } from './dispatch-schema.mjs';
24
+ import { createDecisionTrace, finalizeTrace, assertTraceSize, parentContextOf, requestedOf, effectiveMeta, recordApprovalGate, recordFailure, profileSnapshotOf } from './decision-trace.mjs';
25
+ import { profileDirectoryRows } from './profile-directory.mjs';
26
+ import { applyWhitelistGate, applyCostGuardGate, applyIntersectionGate, assertContinuableEnabled, readParentToolNames, applyBudgetGate } from './dispatch-gates.mjs';
27
+ import { trackForegroundInflight, trackBackgroundInflight, trackContinuableInflight } from './dispatch-guard.mjs';
28
+ import { finishForegroundLedger, finishBackgroundLedger, finishContinuableLedger } from './evolution-ledger.mjs';
29
+ import { profileKeyOf } from './evolution-summary.mjs';
30
+ import { promptExcerptOf } from './adoption-reminder.mjs';
30
31
 
31
- // --- 工具声明纯数据(从工厂提到模块级;defineTool 只读不改)----------------------
32
-
33
- const DISPATCH_PARAMETERS = {
34
- profile: { type: 'string', description: 'Optional profile id from the profile registry (built-ins: swap-standard, researcher, plus any you define in the settings page); omit to inherit the parent preset and tools as-is.' },
35
- preset: { type: 'string', description: 'Explicit target preset override; must be a system-trust preset of this runtime.' },
36
- model: { type: 'string', description: 'Explicit model override for the child.' },
37
- provider: { type: 'string', description: 'Explicit provider override for the child.' },
38
- reasoningEffort: { type: 'string', description: 'Explicit reasoning-effort override injected into every child request.' },
39
- tokenTier: { type: 'string', enum: ['cheap', 'balanced', 'premium'], description: '成本/深度分层(估算口径,非计费):cheap 省 token、balanced 均衡、premium 高成本。覆盖 profile 的 tokenTier;缺省 balanced。' },
40
- persona: { type: 'string', description: 'Persona text shadowing the child deployment:persona section.' },
41
- toolFilter: {
42
- type: 'object',
43
- // DSL 对象参数默认拒绝未知键,toolFilter 必须闭合其 schema,
44
- // 否则 defineTool 在 apply 时 throw、插件加载失败。
45
- additionalProperties: false,
46
- description: 'Extra tool whitelist intersection for the child (intersected with the parent tool set).',
47
- properties: {
48
- allow: { type: 'array', items: { type: 'string' }, description: 'When present, only these tool names are kept.' },
49
- deny: { type: 'array', items: { type: 'string' }, description: 'These tool names are always removed.' }
50
- }
51
- },
52
- maxTokens: { type: 'number', description: 'Explicit max-tokens budget for the child.' },
53
- maxDepth: { type: 'number', description: 'Absolute delegation-depth cap for this child.' },
54
- run_in_background: { type: 'boolean', description: '异步 one-shot:走 jobs.start 包 start(),返回 jobId;仍单轮即弃,非 continuable' },
55
- continuable: { type: 'boolean', description: 'Start a durable continuable subagent instead of a one-shot: returns a subagentId immediately and keeps the child conversation available for later turns via the send_message tool. Defaults to false.' },
56
- // 信封模式 = opt-in(仅「中段即交付物」的任务用):true 时把信封骨架追加进
57
- // 子 persona(systemPrompt 影子段,模型可见),子 Agent 按结构化信封汇报;
58
- // 默认 false 走结果剪枝回收。
59
- envelope: { type: 'boolean', description: 'true 时子 Agent 按结构化信封汇报(简短结论 + 结构分项 + 关键发现落点),适合中段即交付物的长任务;默认 false 走剪枝回收' },
60
- prompt: { type: 'string', required: true, description: 'The complete, self-contained task for the child (it does not see this conversation).' }
61
- };
62
-
63
- // 信封骨架:envelope:true 时追加进子 persona 的结构化汇报契约。三分支共享此
64
- // 常量;骨架只进 persona(systemPrompt 影子段、模型可见),不进 descriptor
65
- // (model-hidden、仅 session 记录),故 execute 在三分支创建子 Agent 前统一
66
- // 追加到 merged.persona。
32
+ // 信封骨架:envelope:true 时追加进子 persona 的结构化汇报契约(三分支共享);只进 persona(模型可见),不进 descriptor(仅 session 记录)。
67
33
  const ENVELOPE_SKELETON = '完成前按以下骨架输出:## 结论(1-2 句)\n## 结构分项(逐项)\n## 关键发现落点(文件路径/数据位置)';
68
34
 
69
- // continuable 丢弃 preset 换用与 reasoningEffort —— 渲染行把 `ignored`
70
- // 列表回显出来(`reasoningEffort=<值>(ignored)`,再加 ignored 项明细),让模型
71
- // 「看见」被丢弃项;background/foreground 无忽略项时该后缀为空。
72
- const DISPATCH_RENDER = (_args, value) => {
73
- const ignored = value.ignored !== undefined && value.ignored.length > 0
74
- ? `(ignored: ${value.ignored.join(', ')})`
75
- : '';
76
- const tier = typeof value.tokenTier === 'string' && value.tokenTier !== ''
77
- ? ` · tokenTier=${value.tokenTier}`
78
- : '';
79
- const tokens = typeof value.childTotalTokens === 'number'
80
- ? ` · childTotalTokens=${value.childTotalTokens}`
81
- : '';
82
- const elapsed = typeof value.elapsedMs === 'number'
83
- ? ` · elapsedMs=${value.elapsedMs}`
84
- : '';
85
- const stopReason = typeof value.stopReason === 'string' && value.stopReason !== ''
86
- ? ` · stopReason=${value.stopReason}`
87
- : '';
88
- // childUsage 是嵌套对象,render 行是扁平的 key=value,故 JSON 序列化为单段
89
- // 供 client 侧 parseDispatchText 解析回对象(五段分解)。仅前台 completed 结算
90
- // 携带;后台结算经 job 结果携带,continuable 不携带,故该段只在 foreground 行。
91
- const usage = value.childUsage !== undefined && value.childUsage !== null && typeof value.childUsage === 'object'
92
- ? ` · childUsage=${JSON.stringify(value.childUsage)}`
93
- : '';
94
- const text = value.kind === 'background'
95
- ? `[dispatch] background job ${value.jobId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}${tier}`
96
- : value.kind === 'continuable'
97
- ? `[dispatch] started subagent ${value.subagentId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}${tier}`
98
- : `[dispatch] profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}${tier}${tokens}${usage}${elapsed}${stopReason}\n\n${value.output}`;
99
- return [{ type: 'text', text }];
100
- };
101
-
102
35
  // --- execute 预检段(从 execute 拆出;行为逐字不变)-----------------------------
103
36
 
104
- // Resolve the base profile (side channel), then overlay explicit args.
37
+ // 解析基方案(旁路),再叠加显式参数。
105
38
  function mergeProfileArgs(args, store) {
106
39
  const base = args.profile !== undefined ? store.resolveProfile(args.profile) : {};
107
40
  const merged = { ...base };
@@ -111,21 +44,7 @@ function mergeProfileArgs(args, store) {
111
44
  return merged;
112
45
  }
113
46
 
114
- // Pre-check: explicit concrete preset must be in the runtime-derived whitelist;
115
- // a preset equal to the parent's composed preset is rewritten to
116
- // 'inherit' (no swap).
117
- async function assertPresetWhitelist(parent, merged) {
118
- if (typeof merged.preset !== 'string' || merged.preset === 'inherit') return;
119
- const whitelist = new Set(await resolveWhitelist(parent.ctx.get('agentPresets')));
120
- if (!whitelist.has(merged.preset)) {
121
- throw new Error(`dispatch: preset "${merged.preset}" is not in the target-preset whitelist`);
122
- }
123
- const parentPresets = parent.ctx.get('agentPresets');
124
- const parentComposed = parentPresets !== undefined ? parentPresets.composedPreset(parent.ctx) : undefined;
125
- if (merged.preset === parentComposed) merged.preset = 'inherit';
126
- }
127
-
128
- // Effective delegation values for observability.
47
+ // 生效的委派值(供可观测元数据)。
129
48
  function buildMeta(args, merged, parent) {
130
49
  return {
131
50
  profile: args.profile ?? '(inline)',
@@ -137,10 +56,10 @@ function buildMeta(args, merged, parent) {
137
56
  };
138
57
  }
139
58
 
140
- // Assemble the foreground/background request (continuable builds its own below).
59
+ // 组装前台/后台请求(continuable 在下方自建)。
141
60
  function buildRequest(args, merged, parent, signal) {
142
61
  return {
143
- label: String(args.prompt ?? '').slice(0, 60),
62
+ label: dispatchLabel(args, merged),
144
63
  prompt: [{ type: 'text', text: args.prompt }],
145
64
  parent,
146
65
  signal,
@@ -151,8 +70,7 @@ function buildRequest(args, merged, parent, signal) {
151
70
  };
152
71
  }
153
72
 
154
- // Decision-level log: resolved effective delegation inputs, after the cost
155
- // guard and after request assembly, before dispatch.
73
+ // 决策级日志:经 cost guard 与请求组装之后、派发之前的生效委派输入。
156
74
  function logDispatchDecision(logger, args, merged, parent) {
157
75
  logger.info('[dsh-subagent-profile] dispatch:', JSON.stringify({
158
76
  profile: args.profile ?? '(inline)',
@@ -174,10 +92,9 @@ function logDispatchDecision(logger, args, merged, parent) {
174
92
  // 假设:continuable 继承父预设(preset swap 被忽略)⇒ 子工具集 ≈ 父工具集;
175
93
  // 失效条件:任何导致子工具集与父工具集不一致的宿主行为变化,父集都可能含
176
94
  // 子集上不存在之工具 → tools.restrict 抛「未知工具」→ 本缓解自动降级为
177
- // fail-loud(保守安全)。
178
- function buildContinuableRequest(args, merged, parent) {
179
- const parentNames = new Set(parent.ctx.tools.schemas(parent).map((schema) => schema.name));
180
- const effectiveAllow = computeContinuableAllow(parentNames, merged.toolFilter);
95
+ // fail-loud(保守安全)。effectiveAllow 由调用方(runDispatch 交集闸处)算好
96
+ // 传入——同一结果同时用于 request 组装与决策轨迹记录,不重复计算。
97
+ function buildContinuableRequest(args, merged, parent, effectiveAllow) {
181
98
  const hasAgentOptions = merged.provider !== undefined || merged.model !== undefined || merged.maxTokens !== undefined;
182
99
  return {
183
100
  prompt: [{ type: 'text', text: args.prompt }],
@@ -195,41 +112,40 @@ function buildContinuableRequest(args, merged, parent) {
195
112
  };
196
113
  }
197
114
 
198
- // Continuable (durable) path — startContinuable publishes a persistent child
199
- // and returns its durable id; the official send_message tool drives later
200
- // turns. Treated first so a caller asking for both background and continuable
201
- // gets the continuable child.
202
- async function runContinuable(args, merged, meta, parent, exec, deps) {
203
- // provider `start` 的 !enabled 检查只拦
204
- // `start`,不拦 `startContinuable` —— 这里显式补上。当前 syncTool 会在
205
- // 禁用时注销 dispatch 工具(间接门),此处是防御性兜底:禁用后
206
- // dispatch(continuable:true) 必须 fail-loud,不得静默派生子树。
115
+ // Continuable(持久)路径:startContinuable 发布持久子会话并返回其持久 id,
116
+ // 后续轮次由官方 send_message 工具驱动。优先处理——同时要 background
117
+ // continuable 的调用方得到 continuable 子会话。
118
+ async function runContinuable(args, merged, meta, parent, exec, deps, trace, effectiveAllow, base) {
119
+ // provider `start` !enabled 检查只拦 `start` 不拦 `startContinuable`——此处
120
+ // 显式兜底(syncTool 注销工具是间接门):禁用后不得静默派生子树,必须 fail-loud。
207
121
  if (!deps.getEnabled()) {
208
122
  throw new Error('dispatch: 插件已禁用(设置 → 子 Agent 方案 重新启用)');
209
123
  }
210
124
  if (args.run_in_background === true) {
211
125
  deps.logger.warn('[dsh-subagent-profile] dispatch: both continuable and run_in_background are true; continuable takes precedence');
212
126
  }
213
- // 已知降级:continuable 标准路径不支持 preset swap 和 reasoningEffort(subagent 包的 SubagentStartRequest 无 preset 字段、AgentOptions 无 reasoningEffort 字段)
127
+ // 已知降级:continuable 标准路径不支持 preset swap 和 reasoningEffort(宿主请求面无对应字段)。
214
128
  if (merged.preset !== undefined && merged.preset !== 'inherit') {
215
129
  deps.logger.warn(`[dsh-subagent-profile] continuable mode cannot swap preset; ignoring "${merged.preset}" (child inherits the parent preset)`);
216
130
  }
217
131
  if (merged.reasoningEffort !== undefined) {
218
132
  deps.logger.warn(`[dsh-subagent-profile] continuable mode cannot set reasoningEffort; ignoring "${merged.reasoningEffort}"`);
219
133
  }
220
- const continuableRequest = buildContinuableRequest(args, merged, parent);
134
+ const continuableRequest = buildContinuableRequest(args, merged, parent, effectiveAllow);
221
135
  const { childId } = await deps.subagents.startContinuable({
222
136
  provider: 'profile',
223
- label: String(args.prompt ?? '').slice(0, 60),
137
+ label: dispatchLabel(args, merged),
224
138
  request: continuableRequest,
225
139
  signal: exec.signal
226
140
  });
227
- // Continuable drops the profile's preset swap and reasoningEffort (the child
228
- // inherits the parent preset), so the observability meta must report what
229
- // actually took effect, not the requested-but-ignored values.
230
- // 可见性修复:`reasoningEffort` 回显**请求值**(经 meta.reasoningEffort),
231
- // `preset:'inherit'` 是真实生效值;`ignored` 明确列出被丢弃项。
232
- return {
141
+ trackContinuableInflight(deps.guard, deps.subagents, childId, parent.session?.header?.id);
142
+ deps.adoptionTracker?.register({ parentSessionId: parent.session?.header?.id, id: childId, ledgerKey: profileKeyOf(base.effective), mode: 'continuable', dispatchedAt: Date.now(), promptExcerpt: promptExcerptOf(args?.prompt), requestedModel: merged.model, parentModel: parent.options?.model });
143
+ // Continuable 丢弃方案的 preset swap reasoningEffort(子继承父预设),故可观测
144
+ // meta 必须报告实际生效值:reasoningEffort 回显请求值,preset:'inherit' 是生效值,
145
+ // ignored 明确列出被丢弃项。
146
+ // 信任标注不适用:continuable 只回 subagentId、无文本 output(后续经宿主的
147
+ // send_message 流转),没有可加前缀的回收文本,故此处不加 trustLabel。
148
+ const out = {
233
149
  kind: 'continuable',
234
150
  subagentId: childId,
235
151
  profile: meta.profile,
@@ -240,44 +156,66 @@ async function runContinuable(args, merged, meta, parent, exec, deps) {
240
156
  tokenTier: meta.tokenTier,
241
157
  ignored: ['preset', 'reasoningEffort']
242
158
  };
159
+ finalizeTrace(trace, {
160
+ effective: effectiveMeta({ ...meta, preset: 'inherit' }, ['preset', 'reasoningEffort']),
161
+ execution: { kind: 'continuable', parentSessionId: parent.session?.header?.id, childSessionId: childId, mode: 'continuable' },
162
+ });
163
+ out.decisionTrace = assertTraceSize(trace);
164
+ finishContinuableLedger(deps.evoLedger, base, childId); // 无结算:continuable 只写静态元数据(无 outcome)
165
+ return out;
243
166
  }
244
167
 
245
168
  // --- 后台 / 前台分支(从 execute 拆出)--------------------------------------------
246
169
 
247
- // Background one-shot (job) path — jobs.start wraps start() with a native
248
- // AbortController (a Node global in a bundle; the dynamic-plugin sandbox needed
249
- // the hand-rolled shim instead); still one turn, not continuable.
250
- async function runBackground(args, meta, request, parent, deps, pruneResultOutput, t0) {
170
+ // 后台 one-shotjob)路径:jobs.start start() 并提供原生
171
+ // AbortController;仍是一轮即弃,非 continuable。
172
+ async function runBackground(args, merged, meta, request, parent, deps, pruneResultOutput, t0, trace, guardHandle, base, exec) {
251
173
  const jobs = deps.getService('jobs');
252
174
  if (jobs === undefined) {
253
- throw new Error('dispatch: background jobs unavailable (load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs)');
175
+ throw new Error('dispatch: 后台派发不可用:缺少 jobs 服务(请安装 @deepseek-ai/dsh-jobs @deepseek-ai/dsh-tool-jobs,或改用前台派发)');
254
176
  }
255
- const jobId = jobs.start({
177
+ // jobId jobs.start 同步返回,故用提升的 let 在结算(异步)时读取。
178
+ let jobId;
179
+ const onSettled = (settled) => {
180
+ guardHandle.finish(jobId, settled.childTotalTokens);
181
+ finishBackgroundLedger(deps.evoLedger, base, jobId, settled);
182
+ if (deps.backgroundLedger !== undefined) deps.backgroundLedger.record(base.session_id, jobId, settled, exec?.callId);
183
+ deps.adoptionTracker?.updateOutput(base.session_id, jobId, typeof settled.output === 'string' ? settled.output : '');
184
+ deps.adoptionTracker?.updateSettled?.(base.session_id, jobId, settled);
185
+ };
186
+ jobId = jobs.start({
256
187
  kind: 'subagent',
257
- label: String(args.prompt ?? '').slice(0, 60),
188
+ label: dispatchLabel(args, merged),
258
189
  owner: parent,
259
190
  run: () => {
260
191
  const controller = new AbortController();
192
+ const startPromise = deps.subagents.start('profile', { ...request, signal: controller.signal });
193
+ startPromise.then((run) => { if (run !== null && run !== undefined && typeof run.id === 'string') deps.backgroundLedger?.recordChild?.(base.session_id, jobId, run.id); }).catch(() => {}); // 结算前补记 jobId → childSessionId,供页头识别后台派发。
261
194
  return {
262
195
  cancel: (reason) => controller.abort(reason ?? 'dispatch: background subagent task killed'),
263
196
  done: settleStart(
264
- deps.subagents.start('profile', { ...request, signal: controller.signal }),
197
+ startPromise,
265
198
  controller.signal,
266
199
  meta,
267
200
  pruneResultOutput,
268
201
  (session) => measureChildTokens(deps.getService, session),
269
- t0
202
+ t0,
203
+ deps.logger,
204
+ onSettled
270
205
  )
271
206
  };
272
207
  }
273
208
  });
274
- return { kind: 'background', jobId, ...meta };
209
+ trackBackgroundInflight(deps.guard, jobs, jobId, parent);
210
+ deps.adoptionTracker?.register({ parentSessionId: parent.session?.header?.id, id: jobId, ledgerKey: profileKeyOf(base.effective), mode: 'background', dispatchedAt: t0, promptExcerpt: promptExcerptOf(args?.prompt), requestedModel: merged.model, parentModel: parent.options?.model }); // 后台结算经 job 结果携带,不入会话块。
211
+ const out = { kind: 'background', jobId, ...meta };
212
+ finalizeTrace(trace, { effective: effectiveMeta(meta, []), execution: { kind: 'background', parentSessionId: parent.session?.header?.id, jobId, mode: 'one-shot' } });
213
+ out.decisionTrace = assertTraceSize(trace);
214
+ return out;
275
215
  }
276
216
 
277
- // childTotalTokens:子 Agent 会话的宿主启发式估算 token 总量(surface 口径),
278
- // 非 provider 计费 usage token。仅 completed 结算时测量;tokenMeter 服务缺失、
279
- // measure 非函数、返回缺 totalTokens 或抛错时均返回 undefined(fail-soft),
280
- // 使测量绝不阻断派发结算。
217
+ // childTotalTokens:宿主 tokenMeter 启发式估算(surface 口径,非计费 usage);仅
218
+ // completed 结算时测量,服务缺失/非函数/抛错均返回 undefined(fail-soft)。
281
219
  function measureChildTokens(getService, session) {
282
220
  if (session === undefined || session === null) return undefined;
283
221
  const meter = getService('tokenMeter');
@@ -292,92 +230,167 @@ function measureChildTokens(getService, session) {
292
230
  }
293
231
  }
294
232
 
295
- // Foreground: collect, always release the handle (dispose even when
296
- // run.result rejects), then fail loud on a non-completed stop reason.
297
- async function runForeground(meta, request, parent, deps, pruneResultOutput, t0) {
233
+ // completed 结算时测量一次(tokenMeter 估算 + usage 分解 + 调用明细),dispose
234
+ // 前读子 session 保证事件流完整;全 fail-soft(缺失/抛错 undefined)绝不阻断结算。
235
+ function measureForegroundRun(deps, run) {
236
+ const childSession = run.localAgent?.session;
237
+ return {
238
+ childTotalTokens: measureChildTokens(deps.getService, childSession),
239
+ childUsage: collectChildUsage(childSession),
240
+ childCalls: collectChildCalls(childSession),
241
+ };
242
+ }
243
+
244
+ // 前台:收集结果,无论 reject 与否都释放句柄,非 completed 停因 fail-loud;
245
+ // run.result reject(undefined)也写失败 outcome。
246
+ async function runForeground(meta, request, parent, deps, pruneResultOutput, t0, trace, guardHandle, base, adoptionBase) {
298
247
  const run = await deps.subagents.start('profile', request);
299
- let result;
300
- let childTotalTokens;
301
- let childUsage;
248
+ finalizeTrace(trace, { execution: { kind: 'foreground', parentSessionId: parent.session?.header?.id, childSessionId: run.id, mode: 'one-shot' } });
249
+ trackForegroundInflight(deps.guard, run);
250
+ deps.adoptionTracker?.register({ parentSessionId: parent.session?.header?.id, id: run.id, ledgerKey: profileKeyOf(base.effective), mode: 'foreground', dispatchedAt: t0, ...adoptionBase }); // 前台派发登记 parent_adopted 待判定。
251
+ let result, measurement;
302
252
  try {
303
253
  result = await run.result;
304
- // completed 结算时测量;dispose 前读子 session 保证事件流完整。
305
- if (result.stopReason === 'completed') {
306
- const childSession = run.localAgent?.session;
307
- childTotalTokens = measureChildTokens(deps.getService, childSession);
308
- childUsage = collectChildUsage(childSession);
309
- }
254
+ if (result.stopReason === 'completed') measurement = measureForegroundRun(deps, run);
310
255
  } finally {
256
+ if (result === undefined) finishForegroundLedger(deps.evoLedger, base, run.id, { stopReason: 'error', output: [] }, [], Date.now() - t0, undefined);
311
257
  await run.dispose().catch(() => {});
258
+ guardHandle.finish(run.id, measurement?.childTotalTokens); // untrack + recordTokens + release
312
259
  }
313
- // completed 视为失败,附子输出部分文本。
260
+ const { childTotalTokens, childUsage, childCalls } = measurement ?? {};
261
+ // 台账:completed 与非 completed 都写 outcome(对照 settleStart 语义),随后非 completed 仍 throw。
262
+ const pruned = pruneResultOutput(result.output);
263
+ const elapsedMs = Date.now() - t0;
264
+ finishForegroundLedger(deps.evoLedger, base, run.id, result, pruned, elapsedMs, childCalls, childTotalTokens, childUsage);
265
+ deps.adoptionTracker?.updateOutput(parent.session?.header?.id, run.id, textFrom(pruned));
266
+ deps.adoptionTracker?.updateSettled?.(parent.session?.header?.id, run.id, { stopReason: result.stopReason, elapsedMs, ...(childTotalTokens !== undefined ? { childTotalTokens } : {}), ...(childUsage !== undefined ? { childUsage } : {}), childSessionId: run.id }); // 结算摘要回填供未采纳提醒结果行,已拒/未完成同样登记。
314
267
  const failure = stopReasonError(result);
315
268
  if (failure !== undefined) throw new Error(withPartialText(failure, result.output));
316
- // 结算 meta:elapsedMs 自 t0;stopReason 取底层值(此分支必 completed);
317
- // childTotalTokens / childUsage 仅可测量时携带。
318
269
  const metaOut = {
319
270
  ...meta,
320
271
  ...(childTotalTokens !== undefined ? { childTotalTokens } : {}),
321
272
  ...(childUsage !== undefined ? { childUsage } : {}),
322
- elapsedMs: Date.now() - t0,
273
+ elapsedMs,
323
274
  stopReason: result.stopReason
324
275
  };
325
- return { output: textFrom(pruneResultOutput(result.output)), ...metaOut };
276
+ finalizeTrace(trace, {
277
+ effective: effectiveMeta(metaOut, []),
278
+ settled: {
279
+ stopReason: result.stopReason,
280
+ elapsedMs: metaOut.elapsedMs,
281
+ ...(childTotalTokens !== undefined ? { childTotalTokens } : {}),
282
+ ...(childUsage !== undefined ? { childUsage } : {}),
283
+ ...(childCalls !== undefined ? { calls: childCalls } : {}),
284
+ },
285
+ });
286
+ // 信任标注:completed 子结果回灌父上下文前加结构化前缀(profile/preset 元数据、
287
+ // 无 prompt 原文),审计同字段 JSON 只进 logger、不进结果字符串;前缀只进 output 文本。
288
+ const output = `${trustLabel(metaOut)}${textFrom(pruned)}`;
289
+ deps.logger.info('[dsh-subagent-profile] trusted-output: ' + trustAudit(metaOut));
290
+ return { output, ...metaOut, decisionTrace: assertTraceSize(trace) };
291
+ }
292
+
293
+ // 预检段读取:whitelist / parentComposed / parentToolNames 只读取一次,同时喂闸
294
+ // 与轨迹(避免重复 list() / schemas()),并组装 trace 骨架与交集闸 input。
295
+ async function prepareTrace(args, parent, deps) {
296
+ const agentPresets = parent.ctx.get('agentPresets');
297
+ const baseWhitelist = new Set(await resolveWhitelist(agentPresets));
298
+ const escapeSet = new Set(deps.getEscapeSet());
299
+ const whitelist = new Set([...baseWhitelist, ...escapeSet]);
300
+ const parentComposed = agentPresets !== undefined ? agentPresets.composedPreset(parent.ctx) : undefined;
301
+ const parentToolNames = readParentToolNames(parent);
302
+ const parentToolCount = parentToolNames.length;
303
+ // 决策输入快照:与 dispatch:profiles section 同源同序(profileDirectoryRows,
304
+ // enabled、cheap-first)——「模型当时看到哪些候选」是回答「为什么这么选」的记录面。
305
+ const rows = profileDirectoryRows(deps.store);
306
+ const snapshot = profileSnapshotOf(rows, { chosenId: args.profile });
307
+ const advicePresent = typeof deps.getEvolutionAdvice === 'function' && deps.getEvolutionAdvice() === true && parentComposed === 'orchestrator';
308
+ const trace = createDecisionTrace(
309
+ parentContextOf({
310
+ parentPreset: parentComposed,
311
+ parentProvider: parent.options.provider,
312
+ parentModel: parent.options.model,
313
+ parentToolCount,
314
+ allowFailOpen: deps.store.getAllowFailOpen(),
315
+ }),
316
+ requestedOf(args, { profiles: snapshot.entries, advicePresent })
317
+ );
318
+ const mode = args.continuable === true ? 'continuable' : (args.run_in_background === true ? 'background' : 'foreground');
319
+ const intersectionInput = { parentToolCount, requestedToolFilter: args.toolFilter, mode };
320
+ return { whitelist, baseWhitelist, escapeSet, parentComposed, parentToolNames, trace, mode, intersectionInput };
326
321
  }
327
322
 
328
- // execute 主体:预检段 + 三分支分派,行为逐字不变。
323
+ // execute 主体:预检段 + 三分支分派。任一闸 fail 先记 fail 闸再 rethrow 原错误
324
+ // 对象,失败 trace 进进程内台账(deps.ledger)。
329
325
  async function runDispatch(args, exec, deps) {
330
326
  const parent = exec.agent;
331
- if (!parent) throw new Error('dispatch requires calling agent');
327
+ if (!parent) throw new Error('dispatch: 缺少调用 Agent(值需来自模型/编排者会话,请勿直接调用本工具)');
332
328
  // 派发耗时基准:execute 入口记 t0,前台/后台各自在结算处算 elapsedMs。
333
- // continuable 不结算、不携带,故 t0 仅穿线到前台/后台。
334
329
  const t0 = Date.now();
335
- const merged = mergeProfileArgs(args, deps.store);
336
- // 信封模式 opt-in:三分支创建子 Agent 前,把信封骨架追加进子 persona。
337
- // persona systemPrompt 影子段(模型可见);descriptor 是 model-hidden,
338
- // 故骨架只追加 persona、不触碰 descriptor。既有 persona 为空/未设时直接
339
- // 置为骨架,非空时以空行衔接追加,不覆盖原文。
340
- if (args.envelope === true) {
341
- const hasPersona = merged.persona !== undefined && merged.persona.trim() !== '';
342
- merged.persona = hasPersona
343
- ? `${merged.persona}\n\n${ENVELOPE_SKELETON}`
344
- : ENVELOPE_SKELETON;
330
+ const { whitelist, baseWhitelist, escapeSet, parentComposed, parentToolNames, trace, mode, intersectionInput } = await prepareTrace(args, parent, deps);
331
+ // 预算闸句柄提至 try 外:任何分支 throw(前台 start reject / 后台 jobs 缺失等)
332
+ // 都在 catch 里幂等 release 并发槽——防错误路径把父会话的并发额度永久耗尽
333
+ // (正常路径 finish release,catch 再调为 no-op)。
334
+ let guardHandle = { release: () => {} };
335
+ try {
336
+ const merged = mergeProfileArgs(args, deps.store);
337
+ // 信封模式 opt-in:三分支创建子 Agent 前,把信封骨架追加进子 persona(systemPrompt
338
+ // 影子段、模型可见),不触碰 descriptor;既有 persona 非空时以空行衔接追加。
339
+ if (args.envelope === true) {
340
+ const hasPersona = merged.persona !== undefined && merged.persona.trim() !== '';
341
+ merged.persona = hasPersona ? `${merged.persona}\n\n${ENVELOPE_SKELETON}` : ENVELOPE_SKELETON;
342
+ }
343
+ assertContinuableEnabled(deps, args);
344
+ applyWhitelistGate(trace, merged, whitelist, parentComposed, escapeSet, baseWhitelist);
345
+ // Cost guard(运行时推导;硬上限始终生效,llm 能力核验由 allowFailOpen 门控)。
346
+ await applyCostGuardGate(trace, merged, parent, deps);
347
+ // 交集闸(三分支统一闸序,见 dispatch-gates.mjs);continuable 的 effectiveAllow
348
+ // 同时用于 request 组装。
349
+ const effectiveAllow = applyIntersectionGate(trace, mode, intersectionInput, merged, parentToolNames);
350
+ recordApprovalGate(trace);
351
+ const meta = buildMeta(args, merged, parent);
352
+ const request = buildRequest(args, merged, parent, exec.signal);
353
+ const adoptionBase = { promptExcerpt: promptExcerptOf(args?.prompt), requestedModel: merged.model, parentModel: parent.options?.model };
354
+ // 结果回收默认剪枝:在 textFrom 前复用宿主 toolResultPruner.pruneContent 预剪;
355
+ // pruner 缺失时 pruneBlocks 回退为不剪(剪枝是增强、非硬依赖)。
356
+ const pruneResultOutput = (blocks) => pruneBlocks(blocks, deps.getService('toolResultPruner'));
357
+ logDispatchDecision(deps.logger, args, merged, parent);
358
+ // 总预算守卫:前/后台在 execute 入口占并发额度(continuable 不占),结算处 release
359
+ // + recordTokens。guardHandle 传入分支用于释放与父 sessionId 记账。
360
+ guardHandle = applyBudgetGate(deps, parent, args, trace);
361
+ // 派发台账静态元数据(双栏 cfg/task 指纹),分支结算处补 child_id+outcome 写盘。
362
+ const base = deps.evoLedger.baseEntry({ parent, args, merged, mode });
363
+ recordEscapeAllow(deps, parent, merged, trace);
364
+ if (args.continuable === true) return await runContinuable(args, merged, meta, parent, exec, deps, trace, effectiveAllow, base);
365
+ if (args.run_in_background === true) return await runBackground(args, merged, meta, request, parent, deps, pruneResultOutput, t0, trace, guardHandle, base, exec);
366
+ return await runForeground(meta, request, parent, deps, pruneResultOutput, t0, trace, guardHandle, base, adoptionBase);
367
+ } catch (error) {
368
+ guardHandle.release();
369
+ recordFailure(deps.ledger, parent, trace);
370
+ throw error;
345
371
  }
346
- await assertPresetWhitelist(parent, merged);
347
- // Cost guard(运行时推导;硬上限始终生效,llm 能力核验由 allowFailOpen 门控;
348
- // llm 目录读取走共享 catalog 快照)。
349
- await assertCostGuard(parent, merged, deps.store.getAllowFailOpen(), deps.logger, deps.catalog);
350
- const meta = buildMeta(args, merged, parent);
351
- const request = buildRequest(args, merged, parent, exec.signal);
352
- // 结果回收默认剪枝:在 textFrom(result.output) 之前复用宿主
353
- // toolResultPruner.pruneContent 预剪。`pruneResultOutput` 每次现取
354
- // ctx.get('toolResultPruner') 以反映服务就绪状态;pruner 缺失时
355
- // pruneBlocks 回退为不剪(剪枝是增强、非硬依赖)。envelope:true 时信封
356
- // 骨架已在上方追加进 merged.persona(不改变此处的剪枝回收路径)。
357
- const pruneResultOutput = (blocks) => pruneBlocks(blocks, deps.getService('toolResultPruner'));
358
- logDispatchDecision(deps.logger, args, merged, parent);
359
- if (args.continuable === true) return runContinuable(args, merged, meta, parent, exec, deps);
360
- if (args.run_in_background === true) return runBackground(args, meta, request, parent, deps, pruneResultOutput, t0);
361
- return runForeground(meta, request, parent, deps, pruneResultOutput, t0);
362
372
  }
363
373
 
364
- export function createDispatchTool({ register, store, getEnabled, getService, logger, subagents, catalog }) {
365
- const deps = { store, getEnabled, getService, logger, subagents, catalog };
374
+ export function createDispatchTool({ register, store, getEnabled, getService, logger, subagents, catalog, ledger, guard, evoLedger, backgroundLedger, adoptionTracker, getEscapeSet, getEvolutionAdvice }) {
375
+ const deps = { store, getEnabled, getService, logger, subagents, catalog, ledger, guard, evoLedger, backgroundLedger, adoptionTracker, getEscapeSet, getEvolutionAdvice };
366
376
  const dispatchTool = defineTool({
367
377
  name: 'dispatch',
368
- description: 'Dispatch a subtask to a derived subagent, optionally overriding its preset, model, provider, reasoning effort, persona, tool whitelist, token budget, or recursion depth. Foreground waits for the result; run_in_background: true starts a background job (single turn); continuable: true starts a durable subagent whose conversation stays available for later turns via the send_message tool. 前瞻:continuable 模式忽略 preset 换用与 reasoningEffort(结果以 ignored 提示)。',
378
+ description: '派发子任务给派生子 Agent,可逐个覆盖其 presetmodelprovider、推理档位、persona、工具白名单、token 预算或递归深度。优先使用已保存的 profile,仅少数字段需要临时覆盖时才传 per-call 参数;未指定 model 时子代理继承父模型,查资料/汇总/读文件等轻任务请优先考虑 cheap 方案或显式 model=flash。前台等待结果;run_in_background: true 启动后台任务(单轮即弃);continuable: true 启动持久子 Agent,后续轮次经 send_message 工具延续对话。前瞻:continuable 模式忽略 preset 换用与 reasoningEffort(结果以 ignored 提示)。',
369
379
  parameters: DISPATCH_PARAMETERS,
370
- output: { schema: DISPATCH_OUTPUT_SCHEMA, render: DISPATCH_RENDER },
380
+ output: {
381
+ schema: DISPATCH_OUTPUT_SCHEMA,
382
+ render: DISPATCH_RENDER,
383
+ // 决策轨迹经 presentationMeta 投影进会话块 meta(客户端台账数据源),不进 render 行。
384
+ presentationMeta: (_args, value) => value.decisionTrace,
385
+ },
371
386
  isConcurrencySafe: () => true,
372
387
  execute: (args, exec) => runDispatch(args, exec, deps),
373
388
  });
374
- // 共享一致性规则 lock: the closed oneOf result schema must carry an identical
375
- // shared meta key set across all three branches. Fires only at apply time; a
376
- // future meta-field add that forgets one 分支 throws here (once), so the
377
- // model-side schema never silently rejects a分支.
389
+ // 共享一致性规则锁:闭合 oneOf 结果 schema 的三个分支必须携带同一套共享
390
+ // 元数据键集。只在 apply 时触发;未来加元数据字段时若漏掉某个分支,在这里
391
+ // throw 一次(而不是让模型侧 schema 静默拒绝某个分支)。
378
392
  assertResultSchemaConsistency(dispatchTool.output.schema);
379
- // Register the tool only while enabled; unregister it the moment the switch
380
- // turns off so it disappears from the model's tool list without a restart.
393
+ // 只在启用时注册工具;开关一关立即注销,使工具无需重启就从模型的工具列表消失。
381
394
  let disposeTool;
382
395
  function syncTool() {
383
396
  if (getEnabled() && disposeTool === undefined) {
@@ -385,7 +398,10 @@ export function createDispatchTool({ register, store, getEnabled, getService, lo
385
398
  } else if (!getEnabled() && disposeTool !== undefined) {
386
399
  const dispose = disposeTool;
387
400
  disposeTool = undefined;
388
- dispose();
401
+ if (typeof dispose === 'function') dispose();
402
+ // 禁用:级联取消在途派发 + 清空并发/token 记账(重开后记账从零起步)。
403
+ deps.guard.cancelAll();
404
+ deps.guard.reset();
389
405
  }
390
406
  }
391
407
  syncTool();
@@ -395,7 +411,7 @@ export function createDispatchTool({ register, store, getEnabled, getService, lo
395
411
  if (disposeTool !== undefined) {
396
412
  const dispose = disposeTool;
397
413
  disposeTool = undefined;
398
- dispose();
414
+ if (typeof dispose === 'function') dispose();
399
415
  }
400
416
  }
401
417
  };
@@ -0,0 +1,45 @@
1
+ // lib/core/draft-gates.mjs — auto-profile S1 draft 落库前的闸评估。
2
+ // 与派发守卫闸同源(形状/白名单/目录),但不落库、不 throw——只产出结构化 checks
3
+ // 供 /draft/preview 与应用复用。依赖注入:ctx / catalog 由调用方传入;store 只读
4
+ // profiles Map 判断 id 冲突。
5
+
6
+ import { sanitizeProfile } from './pure.mjs';
7
+ import { resolveWhitelist } from './whitelist.mjs';
8
+
9
+ // 评估一个 draft:sanitize(形状/硬上限)→ id 冲突 → 白名单 → 目录。checks 每项
10
+ // 为 { name, verdict: 'pass'|'fail', reason? };ok = 全部 pass。任一闸 fail 时 clean
11
+ // 仍返回(sanitize 归一后的形态),调用方可用其展示「将被写入的生效值」。
12
+ export async function assessDraftProfile({ ctx, store, catalog, getEscapeSet, draft }) {
13
+ if (draft === null || typeof draft !== 'object') {
14
+ return { ok: false, checks: [{ name: 'draft', verdict: 'fail', reason: 'draft 不存在' }], clean: null };
15
+ }
16
+ const config = draft.config !== null && typeof draft.config === 'object' ? draft.config : {};
17
+ const profile = { ...config, name: draft.name, description: draft.description };
18
+ const { clean, warnings } = sanitizeProfile(profile, { strict: true });
19
+ const checks = [];
20
+ checks.push(warnings.length > 0
21
+ ? { name: 'sanitize', verdict: 'fail', reason: warnings.map((w) => w.field + ':' + w.reason).join(';') }
22
+ : { name: 'sanitize', verdict: 'pass' });
23
+ if (typeof clean.id !== 'string' || clean.id === '') {
24
+ checks.push({ name: 'id', verdict: 'fail', reason: 'draft 缺少 profile id' });
25
+ } else if (store.profiles.has(clean.id)) {
26
+ checks.push({ name: 'id', verdict: 'fail', reason: 'profile ' + clean.id + ' 已存在(请先删除或改名)' });
27
+ } else {
28
+ checks.push({ name: 'id', verdict: 'pass' });
29
+ }
30
+ const whitelist = new Set(await resolveWhitelist(ctx.get('agentPresets'), getEscapeSet()));
31
+ if (typeof clean.preset === 'string' && clean.preset !== '' && clean.preset !== 'inherit' && !whitelist.has(clean.preset)) {
32
+ checks.push({ name: 'whitelist', verdict: 'fail', reason: '目标预设 ' + clean.preset + ' 不在 system-trust 白名单(可开启逃生舱并添加该预设后重试)' });
33
+ } else {
34
+ checks.push({ name: 'whitelist', verdict: 'pass' });
35
+ }
36
+ const snapshot = await catalog.getSnapshot();
37
+ if (typeof clean.model === 'string' && clean.model !== '' && !snapshot.models.some((m) => m && m.id === clean.model)) {
38
+ checks.push({ name: 'catalog', verdict: 'fail', reason: '模型 ' + clean.model + ' 不在当前模型目录' });
39
+ } else if (typeof clean.provider === 'string' && clean.provider !== '' && !snapshot.models.some((m) => m && m.provider === clean.provider)) {
40
+ checks.push({ name: 'catalog', verdict: 'fail', reason: '提供方 ' + clean.provider + ' 不在当前模型目录' });
41
+ } else {
42
+ checks.push({ name: 'catalog', verdict: 'pass' });
43
+ }
44
+ return { ok: checks.every((c) => c.verdict === 'pass'), checks, clean };
45
+ }