dsh-subagent-profile 0.3.1 → 0.3.3

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.
@@ -2,100 +2,41 @@
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 门 +
9
+ // syncTool 注册条件),
10
+ // getService 请求时服务 getter(ctx.get('toolResultPruner') / ctx.get('jobs')
11
+ // 按次读取,绝不在 apply 时读),
12
+ // logger ctx.logger(决策级派发日志 + continuable 告警),
13
+ // subagents ctx.subagents(start / startContinuable 驱动子 Agent),
14
+ // guard dispatch guard(execute 入口 acquire / 结算 release+记账 /
15
+ // syncTool 禁用时 cancelAll+reset)。
16
+ // 工厂返回 { syncTool, dispose }:syncTool 交给 HTTP 路由(/set-enabled),
17
+ // dispose 在插件卸载时运行。
18
18
  //
19
- // execute 按预检段 + 前台/后台/continuable 三分支拆为模块级私有函数;
20
- // parameters 声明为纯数据,驻留模块级常量(与数据表同性质,不受函数行门约束);
21
- // output 结果 schema 已拆至 lib/core/dispatch-schema.mjs(守文件行门)——
22
- // 工厂保持装配态;除各任务明示的新增行为外,执行路径零漂移。
19
+ // execute 按预检段 + 三分支拆为模块级私有函数;parameters / output schema 拆至
20
+ // dispatch-schema.mjs,预检闸应用拆至 dispatch-gates.mjs(守文件行门),零漂移。
23
21
 
24
22
  import { defineTool } from './shims.mjs';
25
- import { computeContinuableAllow, textFrom, stopReasonError, withPartialText, pruneBlocks, assertResultSchemaConsistency } from './pure.mjs';
23
+ import { textFrom, stopReasonError, withPartialText, pruneBlocks, assertResultSchemaConsistency, trustLabel, trustAudit, dispatchLabel, tierSortKey } from './pure.mjs';
26
24
  import { resolveWhitelist } from './whitelist.mjs';
27
- import { assertCostGuard } from './cost-guard.mjs';
28
- import { settleStart } from './delegation.mjs';
29
- import { DISPATCH_OUTPUT_SCHEMA } from './dispatch-schema.mjs';
25
+ import { recordEscapeAllow } from './escape.mjs';
26
+ import { settleStart, collectChildUsage, collectChildCalls } from './delegation.mjs';
27
+ import { DISPATCH_OUTPUT_SCHEMA, DISPATCH_PARAMETERS, DISPATCH_RENDER } from './dispatch-schema.mjs';
28
+ import { createDecisionTrace, finalizeTrace, assertTraceSize, parentContextOf, requestedOf, effectiveMeta, recordApprovalGate, recordFailure, profileSnapshotOf } from './decision-trace.mjs';
29
+ import { applyWhitelistGate, applyCostGuardGate, applyIntersectionGate, assertContinuableEnabled, readParentToolNames, applyBudgetGate } from './dispatch-gates.mjs';
30
+ import { trackForegroundInflight, trackBackgroundInflight, trackContinuableInflight } from './dispatch-guard.mjs';
31
+ import { finishForegroundLedger, finishBackgroundLedger, finishContinuableLedger } from './evolution-ledger.mjs';
30
32
 
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。
33
+ // 信封骨架:envelope:true 时追加进子 persona 的结构化汇报契约(三分支共享);骨架
34
+ // 只进 persona(systemPrompt 影子段、模型可见),不进 descriptor(model-hidden,仅 session 记录)。
67
35
  const ENVELOPE_SKELETON = '完成前按以下骨架输出:## 结论(1-2 句)\n## 结构分项(逐项)\n## 关键发现落点(文件路径/数据位置)';
68
36
 
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
- const text = value.kind === 'background'
89
- ? `[dispatch] background job ${value.jobId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}${tier}`
90
- : value.kind === 'continuable'
91
- ? `[dispatch] started subagent ${value.subagentId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}${tier}`
92
- : `[dispatch] profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}${tier}${tokens}${elapsed}${stopReason}\n\n${value.output}`;
93
- return [{ type: 'text', text }];
94
- };
95
-
96
37
  // --- execute 预检段(从 execute 拆出;行为逐字不变)-----------------------------
97
38
 
98
- // Resolve the base profile (side channel), then overlay explicit args.
39
+ // 解析基方案(旁路),再叠加显式参数。
99
40
  function mergeProfileArgs(args, store) {
100
41
  const base = args.profile !== undefined ? store.resolveProfile(args.profile) : {};
101
42
  const merged = { ...base };
@@ -105,21 +46,7 @@ function mergeProfileArgs(args, store) {
105
46
  return merged;
106
47
  }
107
48
 
108
- // Pre-check: explicit concrete preset must be in the runtime-derived whitelist;
109
- // a preset equal to the parent's composed preset is rewritten to
110
- // 'inherit' (no swap).
111
- async function assertPresetWhitelist(parent, merged) {
112
- if (typeof merged.preset !== 'string' || merged.preset === 'inherit') return;
113
- const whitelist = new Set(await resolveWhitelist(parent.ctx.get('agentPresets')));
114
- if (!whitelist.has(merged.preset)) {
115
- throw new Error(`dispatch: preset "${merged.preset}" is not in the target-preset whitelist`);
116
- }
117
- const parentPresets = parent.ctx.get('agentPresets');
118
- const parentComposed = parentPresets !== undefined ? parentPresets.composedPreset(parent.ctx) : undefined;
119
- if (merged.preset === parentComposed) merged.preset = 'inherit';
120
- }
121
-
122
- // Effective delegation values for observability.
49
+ // 生效的委派值(供可观测元数据)。
123
50
  function buildMeta(args, merged, parent) {
124
51
  return {
125
52
  profile: args.profile ?? '(inline)',
@@ -131,10 +58,10 @@ function buildMeta(args, merged, parent) {
131
58
  };
132
59
  }
133
60
 
134
- // Assemble the foreground/background request (continuable builds its own below).
61
+ // 组装前台/后台请求(continuable 在下方自建)。
135
62
  function buildRequest(args, merged, parent, signal) {
136
63
  return {
137
- label: String(args.prompt ?? '').slice(0, 60),
64
+ label: dispatchLabel(args, merged),
138
65
  prompt: [{ type: 'text', text: args.prompt }],
139
66
  parent,
140
67
  signal,
@@ -145,8 +72,7 @@ function buildRequest(args, merged, parent, signal) {
145
72
  };
146
73
  }
147
74
 
148
- // Decision-level log: resolved effective delegation inputs, after the cost
149
- // guard and after request assembly, before dispatch.
75
+ // 决策级日志:经 cost guard 与请求组装之后、派发之前的生效委派输入。
150
76
  function logDispatchDecision(logger, args, merged, parent) {
151
77
  logger.info('[dsh-subagent-profile] dispatch:', JSON.stringify({
152
78
  profile: args.profile ?? '(inline)',
@@ -168,10 +94,9 @@ function logDispatchDecision(logger, args, merged, parent) {
168
94
  // 假设:continuable 继承父预设(preset swap 被忽略)⇒ 子工具集 ≈ 父工具集;
169
95
  // 失效条件:任何导致子工具集与父工具集不一致的宿主行为变化,父集都可能含
170
96
  // 子集上不存在之工具 → tools.restrict 抛「未知工具」→ 本缓解自动降级为
171
- // fail-loud(保守安全)。
172
- function buildContinuableRequest(args, merged, parent) {
173
- const parentNames = new Set(parent.ctx.tools.schemas(parent).map((schema) => schema.name));
174
- const effectiveAllow = computeContinuableAllow(parentNames, merged.toolFilter);
97
+ // fail-loud(保守安全)。effectiveAllow 由调用方(runDispatch 交集闸处)算好
98
+ // 传入——同一结果同时用于 request 组装与决策轨迹记录,不重复计算。
99
+ function buildContinuableRequest(args, merged, parent, effectiveAllow) {
175
100
  const hasAgentOptions = merged.provider !== undefined || merged.model !== undefined || merged.maxTokens !== undefined;
176
101
  return {
177
102
  prompt: [{ type: 'text', text: args.prompt }],
@@ -189,15 +114,12 @@ function buildContinuableRequest(args, merged, parent) {
189
114
  };
190
115
  }
191
116
 
192
- // Continuable (durable) path — startContinuable publishes a persistent child
193
- // and returns its durable id; the official send_message tool drives later
194
- // turns. Treated first so a caller asking for both background and continuable
195
- // gets the continuable child.
196
- async function runContinuable(args, merged, meta, parent, exec, deps) {
197
- // provider `start` 的 !enabled 检查只拦
198
- // `start`,不拦 `startContinuable` —— 这里显式补上。当前 syncTool 会在
199
- // 禁用时注销 dispatch 工具(间接门),此处是防御性兜底:禁用后
200
- // dispatch(continuable:true) 必须 fail-loud,不得静默派生子树。
117
+ // Continuable(持久)路径:startContinuable 发布持久子会话并返回其持久 id,
118
+ // 后续轮次由官方 send_message 工具驱动。优先处理——同时要 background 与
119
+ // continuable 的调用方得到 continuable 子会话。
120
+ async function runContinuable(args, merged, meta, parent, exec, deps, trace, effectiveAllow, base) {
121
+ // provider `start` 的 !enabled 检查只拦 `start` 不拦 `startContinuable`——此处
122
+ // 显式兜底(syncTool 注销工具是间接门):禁用后不得静默派生子树,必须 fail-loud。
201
123
  if (!deps.getEnabled()) {
202
124
  throw new Error('dispatch: 插件已禁用(设置 → 子 Agent 方案 重新启用)');
203
125
  }
@@ -211,19 +133,21 @@ async function runContinuable(args, merged, meta, parent, exec, deps) {
211
133
  if (merged.reasoningEffort !== undefined) {
212
134
  deps.logger.warn(`[dsh-subagent-profile] continuable mode cannot set reasoningEffort; ignoring "${merged.reasoningEffort}"`);
213
135
  }
214
- const continuableRequest = buildContinuableRequest(args, merged, parent);
136
+ const continuableRequest = buildContinuableRequest(args, merged, parent, effectiveAllow);
215
137
  const { childId } = await deps.subagents.startContinuable({
216
138
  provider: 'profile',
217
- label: String(args.prompt ?? '').slice(0, 60),
139
+ label: dispatchLabel(args, merged),
218
140
  request: continuableRequest,
219
141
  signal: exec.signal
220
142
  });
221
- // Continuable drops the profile's preset swap and reasoningEffort (the child
222
- // inherits the parent preset), so the observability meta must report what
223
- // actually took effect, not the requested-but-ignored values.
143
+ trackContinuableInflight(deps.guard, deps.subagents, childId, parent.session?.header?.id);
144
+ // Continuable 丢弃方案的 preset swap 与 reasoningEffort(子继承父预设),
145
+ // 故可观测 meta 必须报告实际生效值,而非被忽略的请求值。
224
146
  // 可见性修复:`reasoningEffort` 回显**请求值**(经 meta.reasoningEffort),
225
147
  // `preset:'inherit'` 是真实生效值;`ignored` 明确列出被丢弃项。
226
- return {
148
+ // 信任标注不适用:continuable 只回 subagentId、无文本 output(后续经宿主的
149
+ // send_message 流转),没有可加前缀的回收文本,故此处不加 trustLabel。
150
+ const out = {
227
151
  kind: 'continuable',
228
152
  subagentId: childId,
229
153
  profile: meta.profile,
@@ -234,21 +158,33 @@ async function runContinuable(args, merged, meta, parent, exec, deps) {
234
158
  tokenTier: meta.tokenTier,
235
159
  ignored: ['preset', 'reasoningEffort']
236
160
  };
161
+ finalizeTrace(trace, {
162
+ effective: effectiveMeta({ ...meta, preset: 'inherit' }, ['preset', 'reasoningEffort']),
163
+ execution: { kind: 'continuable', parentSessionId: parent.session?.header?.id, childSessionId: childId, mode: 'continuable' },
164
+ });
165
+ out.decisionTrace = assertTraceSize(trace);
166
+ finishContinuableLedger(deps.evoLedger, base, childId); // 无结算:continuable 只写静态元数据(无 outcome)
167
+ return out;
237
168
  }
238
169
 
239
170
  // --- 后台 / 前台分支(从 execute 拆出)--------------------------------------------
240
171
 
241
- // Background one-shot (job) path — jobs.start wraps start() with a native
242
- // AbortController (a Node global in a bundle; the dynamic-plugin sandbox needed
243
- // the hand-rolled shim instead); still one turn, not continuable.
244
- async function runBackground(args, meta, request, parent, deps, pruneResultOutput, t0) {
172
+ // 后台 one-shot(job)路径:jobs.start 包 start() 并提供原生
173
+ // AbortController;仍是一轮即弃,非 continuable。
174
+ async function runBackground(args, merged, meta, request, parent, deps, pruneResultOutput, t0, trace, guardHandle, base) {
245
175
  const jobs = deps.getService('jobs');
246
176
  if (jobs === undefined) {
247
- throw new Error('dispatch: background jobs unavailable (load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs)');
177
+ throw new Error('dispatch: 后台派发不可用:缺少 jobs 服务(请安装 @deepseek-ai/dsh-jobs 与 @deepseek-ai/dsh-tool-jobs,或改用前台派发)');
248
178
  }
249
- const jobId = jobs.start({
179
+ // jobId 由 jobs.start 同步返回,故用提升的 let 在结算(异步)时读取。
180
+ let jobId;
181
+ const onSettled = (settled) => {
182
+ guardHandle.finish(jobId, settled.childTotalTokens);
183
+ finishBackgroundLedger(deps.evoLedger, base, jobId, settled);
184
+ };
185
+ jobId = jobs.start({
250
186
  kind: 'subagent',
251
- label: String(args.prompt ?? '').slice(0, 60),
187
+ label: dispatchLabel(args, merged),
252
188
  owner: parent,
253
189
  run: () => {
254
190
  const controller = new AbortController();
@@ -260,18 +196,23 @@ async function runBackground(args, meta, request, parent, deps, pruneResultOutpu
260
196
  meta,
261
197
  pruneResultOutput,
262
198
  (session) => measureChildTokens(deps.getService, session),
263
- t0
199
+ t0,
200
+ deps.logger,
201
+ onSettled
264
202
  )
265
203
  };
266
204
  }
267
205
  });
268
- return { kind: 'background', jobId, ...meta };
206
+ trackBackgroundInflight(deps.guard, jobs, jobId, parent);
207
+ // 后台:execute 返回时 start 尚未 resolve,无 settled(结算经 job 结果携带,不入会话块)。
208
+ const out = { kind: 'background', jobId, ...meta };
209
+ finalizeTrace(trace, { effective: effectiveMeta(meta, []), execution: { kind: 'background', parentSessionId: parent.session?.header?.id, jobId, mode: 'one-shot' } });
210
+ out.decisionTrace = assertTraceSize(trace);
211
+ return out;
269
212
  }
270
213
 
271
- // childTotalTokens:子 Agent 会话的宿主启发式估算 token 总量(surface 口径),
272
- // 非 provider 计费 usage token。仅 completed 结算时测量;tokenMeter 服务缺失、
273
- // measure 非函数、返回缺 totalTokens 或抛错时均返回 undefined(fail-soft),
274
- // 使测量绝不阻断派发结算。
214
+ // childTotalTokens:宿主 tokenMeter 启发式估算(surface 口径,非计费 usage)。
215
+ // 仅 completed 结算时测量;服务缺失/非函数/抛错均返回 undefined(fail-soft)。
275
216
  function measureChildTokens(getService, session) {
276
217
  if (session === undefined || session === null) return undefined;
277
218
  const meter = getService('tokenMeter');
@@ -286,90 +227,163 @@ function measureChildTokens(getService, session) {
286
227
  }
287
228
  }
288
229
 
289
- // Foreground: collect, always release the handle (dispose even when
290
- // run.result rejects), then fail loud on a non-completed stop reason.
291
- async function runForeground(meta, request, parent, deps, pruneResultOutput, t0) {
230
+ // 仅 completed 结算时测量一次(tokenMeter 估算 + usage 分解 + 调用明细),dispose
231
+ // 前读子 session 保证事件流完整;全 fail-soft(缺失/抛错 → undefined)绝不阻断结算。
232
+ function measureForegroundRun(deps, run) {
233
+ const childSession = run.localAgent?.session;
234
+ return {
235
+ childTotalTokens: measureChildTokens(deps.getService, childSession),
236
+ childUsage: collectChildUsage(childSession),
237
+ childCalls: collectChildCalls(childSession),
238
+ };
239
+ }
240
+
241
+ // 前台:收集结果,无论 reject 与否都释放句柄,非 completed 停因 fail-loud;
242
+ // run.result reject(undefined)也写失败 outcome(M9)。
243
+ async function runForeground(meta, request, parent, deps, pruneResultOutput, t0, trace, guardHandle, base) {
292
244
  const run = await deps.subagents.start('profile', request);
245
+ finalizeTrace(trace, { execution: { kind: 'foreground', parentSessionId: parent.session?.header?.id, childSessionId: run.id, mode: 'one-shot' } });
246
+ trackForegroundInflight(deps.guard, run);
293
247
  let result;
294
- let childTotalTokens;
248
+ let measurement;
295
249
  try {
296
250
  result = await run.result;
297
- // 仅 completed 结算时测量(stopReason 非 completed 走失败路径,不测);
298
- // 在 dispose 之前读子 session,保证 measure 拿到完整 surface。
299
- if (result.stopReason === 'completed') {
300
- childTotalTokens = measureChildTokens(deps.getService, run.localAgent?.session);
301
- }
251
+ if (result.stopReason === 'completed') measurement = measureForegroundRun(deps, run);
302
252
  } finally {
253
+ if (result === undefined) finishForegroundLedger(deps.evoLedger, base, run.id, { stopReason: 'error', output: [] }, [], Date.now() - t0, undefined);
303
254
  await run.dispose().catch(() => {});
255
+ guardHandle.finish(run.id, measurement?.childTotalTokens); // untrack + recordTokens + release
304
256
  }
305
- // A non-'completed' stop reason is a failure; attach the child's
306
- // partial output text (withPartialText style).
257
+ const childTotalTokens = measurement?.childTotalTokens;
258
+ const childUsage = measurement?.childUsage;
259
+ const childCalls = measurement?.childCalls;
260
+ // 台账:completed 与非 completed 都写 outcome(对照 settleStart 语义),随后非 completed 仍 throw。
261
+ const pruned = pruneResultOutput(result.output);
262
+ const elapsedMs = Date.now() - t0;
263
+ finishForegroundLedger(deps.evoLedger, base, run.id, result, pruned, elapsedMs, childCalls);
307
264
  const failure = stopReasonError(result);
308
265
  if (failure !== undefined) throw new Error(withPartialText(failure, result.output));
309
- // 结算 meta:elapsedMs 从 execute 入口 t0 计;stopReason 取底层结算值
310
- // (此分支已达 return 必为 completed)。childTotalTokens 仍仅测量时携带。
311
266
  const metaOut = {
312
267
  ...meta,
313
268
  ...(childTotalTokens !== undefined ? { childTotalTokens } : {}),
314
- elapsedMs: Date.now() - t0,
269
+ ...(childUsage !== undefined ? { childUsage } : {}),
270
+ elapsedMs,
315
271
  stopReason: result.stopReason
316
272
  };
317
- return { output: textFrom(pruneResultOutput(result.output)), ...metaOut };
273
+ finalizeTrace(trace, {
274
+ effective: effectiveMeta(metaOut, []),
275
+ settled: {
276
+ stopReason: result.stopReason,
277
+ elapsedMs: metaOut.elapsedMs,
278
+ ...(childTotalTokens !== undefined ? { childTotalTokens } : {}),
279
+ ...(childUsage !== undefined ? { childUsage } : {}),
280
+ ...(childCalls !== undefined ? { calls: childCalls } : {}),
281
+ },
282
+ });
283
+ // 信任标注:completed 子结果回灌父上下文前加结构化前缀(profile/preset 元数据、
284
+ // 无 prompt 原文),审计同字段 JSON 只进 logger、不进结果字符串。render 行首部
285
+ // 格式不变——前缀只进 output 文本(模型看到的正文带头)。
286
+ const output = `${trustLabel(metaOut)}${textFrom(pruned)}`;
287
+ deps.logger.info('[dsh-subagent-profile] trusted-output: ' + trustAudit(metaOut));
288
+ return { output, ...metaOut, decisionTrace: assertTraceSize(trace) };
289
+ }
290
+
291
+ // 预检段读取:whitelist / parentComposed / parentToolNames 只读取一次,同时喂闸
292
+ // 与轨迹(避免重复 list() / schemas()),并组装 trace 骨架与交集闸 input。
293
+ async function prepareTrace(args, parent, deps) {
294
+ const agentPresets = parent.ctx.get('agentPresets');
295
+ const whitelist = new Set(await resolveWhitelist(agentPresets, deps.getEscapeSet()));
296
+ const parentComposed = agentPresets !== undefined ? agentPresets.composedPreset(parent.ctx) : undefined;
297
+ const parentToolNames = readParentToolNames(parent);
298
+ const parentToolCount = parentToolNames.length;
299
+ // 决策输入快照:与 dispatch:profiles section 同源同序(enabled、cheap-first)——
300
+ // 「模型当时看到哪些候选」是回答「为什么这么选」的记录面(P5 补偿)。
301
+ const snapshot = profileSnapshotOf([...deps.store.profiles.values()].filter((p) => p.enabled !== false).sort((a, b) => tierSortKey(a.tokenTier) - tierSortKey(b.tokenTier)));
302
+ const trace = createDecisionTrace(
303
+ parentContextOf({
304
+ parentPreset: parentComposed,
305
+ parentProvider: parent.options.provider,
306
+ parentModel: parent.options.model,
307
+ parentToolCount,
308
+ allowFailOpen: deps.store.getAllowFailOpen(),
309
+ }),
310
+ requestedOf(args, { profiles: snapshot.entries })
311
+ );
312
+ const mode = args.continuable === true ? 'continuable' : (args.run_in_background === true ? 'background' : 'foreground');
313
+ const intersectionInput = { parentToolCount, requestedToolFilter: args.toolFilter, mode };
314
+ return { whitelist, parentComposed, parentToolNames, trace, mode, intersectionInput };
318
315
  }
319
316
 
320
- // execute 主体:预检段 + 三分支分派,行为逐字不变。
317
+ // execute 主体:预检段 + 三分支分派。任一闸 fail 先记 fail 闸再 rethrow 原错误
318
+ // 对象,失败 trace 进进程内台账(deps.ledger)。
321
319
  async function runDispatch(args, exec, deps) {
322
320
  const parent = exec.agent;
323
- if (!parent) throw new Error('dispatch requires calling agent');
321
+ if (!parent) throw new Error('dispatch: 缺少调用 Agent(值需来自模型/编排者会话,请勿直接调用本工具)');
324
322
  // 派发耗时基准:execute 入口记 t0,前台/后台各自在结算处算 elapsedMs。
325
- // continuable 不结算、不携带,故 t0 仅穿线到前台/后台。
326
323
  const t0 = Date.now();
327
- const merged = mergeProfileArgs(args, deps.store);
328
- // 信封模式 opt-in:三分支创建子 Agent 前,把信封骨架追加进子 persona。
329
- // persona 是 systemPrompt 影子段(模型可见);descriptor 是 model-hidden,
330
- // 故骨架只追加 persona、不触碰 descriptor。既有 persona 为空/未设时直接
331
- // 置为骨架,非空时以空行衔接追加,不覆盖原文。
332
- if (args.envelope === true) {
333
- const hasPersona = merged.persona !== undefined && merged.persona.trim() !== '';
334
- merged.persona = hasPersona
335
- ? `${merged.persona}\n\n${ENVELOPE_SKELETON}`
336
- : ENVELOPE_SKELETON;
324
+ const { whitelist, parentComposed, parentToolNames, trace, mode, intersectionInput } = await prepareTrace(args, parent, deps);
325
+ // 预算闸句柄提至 try 外:任何分支 throw(前台 start reject / 后台 jobs 缺失等)
326
+ // 都在 catch 里幂等 release 并发槽——防错误路径把父会话的并发额度永久耗尽
327
+ // (正常路径 finish 已 release,catch 再调为 no-op)。
328
+ let guardHandle = { release: () => {} };
329
+ try {
330
+ const merged = mergeProfileArgs(args, deps.store);
331
+ // 信封模式 opt-in:三分支创建子 Agent 前,把信封骨架追加进子 persona(systemPrompt
332
+ // 影子段、模型可见),不触碰 descriptor;既有 persona 非空时以空行衔接追加。
333
+ if (args.envelope === true) {
334
+ const hasPersona = merged.persona !== undefined && merged.persona.trim() !== '';
335
+ merged.persona = hasPersona ? `${merged.persona}\n\n${ENVELOPE_SKELETON}` : ENVELOPE_SKELETON;
336
+ }
337
+ assertContinuableEnabled(deps, args);
338
+ applyWhitelistGate(trace, merged, whitelist, parentComposed);
339
+ // Cost guard(运行时推导;硬上限始终生效,llm 能力核验由 allowFailOpen 门控)。
340
+ await applyCostGuardGate(trace, merged, parent, deps);
341
+ // 交集闸(三分支统一闸序,见 dispatch-gates.mjs);continuable 的 effectiveAllow
342
+ // 同时用于 request 组装。
343
+ const effectiveAllow = applyIntersectionGate(trace, mode, intersectionInput, merged, parentToolNames);
344
+ recordApprovalGate(trace);
345
+ const meta = buildMeta(args, merged, parent);
346
+ const request = buildRequest(args, merged, parent, exec.signal);
347
+ // 结果回收默认剪枝:在 textFrom 前复用宿主 toolResultPruner.pruneContent 预剪;
348
+ // pruner 缺失时 pruneBlocks 回退为不剪(剪枝是增强、非硬依赖)。
349
+ const pruneResultOutput = (blocks) => pruneBlocks(blocks, deps.getService('toolResultPruner'));
350
+ logDispatchDecision(deps.logger, args, merged, parent);
351
+ // 总预算守卫:前/后台在 execute 入口占并发额度(continuable 不占),结算处 release
352
+ // + recordTokens。guardHandle 传入分支用于释放与父 sessionId 记账。
353
+ guardHandle = applyBudgetGate(deps, parent, args, trace);
354
+ // 派发台账静态元数据(双栏 cfg/task 指纹),分支结算处补 child_id+outcome 写盘。
355
+ const base = deps.evoLedger.baseEntry({ parent, args, merged, mode });
356
+ recordEscapeAllow(deps, parent, merged, trace);
357
+ if (args.continuable === true) return await runContinuable(args, merged, meta, parent, exec, deps, trace, effectiveAllow, base);
358
+ if (args.run_in_background === true) return await runBackground(args, merged, meta, request, parent, deps, pruneResultOutput, t0, trace, guardHandle, base);
359
+ return await runForeground(meta, request, parent, deps, pruneResultOutput, t0, trace, guardHandle, base);
360
+ } catch (error) {
361
+ guardHandle.release();
362
+ recordFailure(deps.ledger, parent, trace);
363
+ throw error;
337
364
  }
338
- await assertPresetWhitelist(parent, merged);
339
- // Cost guard(运行时推导;硬上限始终生效,llm 能力核验由 allowFailOpen 门控;
340
- // llm 目录读取走共享 catalog 快照)。
341
- await assertCostGuard(parent, merged, deps.store.getAllowFailOpen(), deps.logger, deps.catalog);
342
- const meta = buildMeta(args, merged, parent);
343
- const request = buildRequest(args, merged, parent, exec.signal);
344
- // 结果回收默认剪枝:在 textFrom(result.output) 之前复用宿主
345
- // toolResultPruner.pruneContent 预剪。`pruneResultOutput` 每次现取
346
- // ctx.get('toolResultPruner') 以反映服务就绪状态;pruner 缺失时
347
- // pruneBlocks 回退为不剪(剪枝是增强、非硬依赖)。envelope:true 时信封
348
- // 骨架已在上方追加进 merged.persona(不改变此处的剪枝回收路径)。
349
- const pruneResultOutput = (blocks) => pruneBlocks(blocks, deps.getService('toolResultPruner'));
350
- logDispatchDecision(deps.logger, args, merged, parent);
351
- if (args.continuable === true) return runContinuable(args, merged, meta, parent, exec, deps);
352
- if (args.run_in_background === true) return runBackground(args, meta, request, parent, deps, pruneResultOutput, t0);
353
- return runForeground(meta, request, parent, deps, pruneResultOutput, t0);
354
365
  }
355
366
 
356
- export function createDispatchTool({ register, store, getEnabled, getService, logger, subagents, catalog }) {
357
- const deps = { store, getEnabled, getService, logger, subagents, catalog };
367
+ export function createDispatchTool({ register, store, getEnabled, getService, logger, subagents, catalog, ledger, guard, evoLedger, getEscapeSet }) {
368
+ const deps = { store, getEnabled, getService, logger, subagents, catalog, ledger, guard, evoLedger, getEscapeSet };
358
369
  const dispatchTool = defineTool({
359
370
  name: 'dispatch',
360
- 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 提示)。',
371
+ description: '派发子任务给派生子 Agent,可逐个覆盖其 preset、model、provider、推理档位、persona、工具白名单、token 预算或递归深度。前台等待结果;run_in_background: true 启动后台任务(单轮即弃);continuable: true 启动持久子 Agent,后续轮次经 send_message 工具延续对话。前瞻:continuable 模式忽略 preset 换用与 reasoningEffort(结果以 ignored 提示)。',
361
372
  parameters: DISPATCH_PARAMETERS,
362
- output: { schema: DISPATCH_OUTPUT_SCHEMA, render: DISPATCH_RENDER },
373
+ output: {
374
+ schema: DISPATCH_OUTPUT_SCHEMA,
375
+ render: DISPATCH_RENDER,
376
+ // 决策轨迹经 presentationMeta 投影进会话块 meta(客户端账本数据源),不进 render 行。
377
+ presentationMeta: (_args, value) => value.decisionTrace,
378
+ },
363
379
  isConcurrencySafe: () => true,
364
380
  execute: (args, exec) => runDispatch(args, exec, deps),
365
381
  });
366
- // 共享一致性规则 lock: the closed oneOf result schema must carry an identical
367
- // shared meta key set across all three branches. Fires only at apply time; a
368
- // future meta-field add that forgets one 分支 throws here (once), so the
369
- // model-side schema never silently rejects a分支.
382
+ // 共享一致性规则锁:闭合 oneOf 结果 schema 的三个分支必须携带同一套共享
383
+ // 元数据键集。只在 apply 时触发;未来加元数据字段时若漏掉某个分支,在这里
384
+ // throw 一次(而不是让模型侧 schema 静默拒绝某个分支)。
370
385
  assertResultSchemaConsistency(dispatchTool.output.schema);
371
- // Register the tool only while enabled; unregister it the moment the switch
372
- // turns off so it disappears from the model's tool list without a restart.
386
+ // 只在启用时注册工具;开关一关立即注销,使工具无需重启就从模型的工具列表消失。
373
387
  let disposeTool;
374
388
  function syncTool() {
375
389
  if (getEnabled() && disposeTool === undefined) {
@@ -377,7 +391,10 @@ export function createDispatchTool({ register, store, getEnabled, getService, lo
377
391
  } else if (!getEnabled() && disposeTool !== undefined) {
378
392
  const dispose = disposeTool;
379
393
  disposeTool = undefined;
380
- dispose();
394
+ if (typeof dispose === 'function') dispose();
395
+ // 禁用:级联取消在途派发 + 清空并发/token 记账(重开后记账从零起步)。
396
+ deps.guard.cancelAll();
397
+ deps.guard.reset();
381
398
  }
382
399
  }
383
400
  syncTool();
@@ -387,7 +404,7 @@ export function createDispatchTool({ register, store, getEnabled, getService, lo
387
404
  if (disposeTool !== undefined) {
388
405
  const dispose = disposeTool;
389
406
  disposeTool = undefined;
390
- dispose();
407
+ if (typeof dispose === 'function') dispose();
391
408
  }
392
409
  }
393
410
  };