dsh-subagent-profile 0.3.2 → 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,106 +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, collectChildUsage } 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
- // 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
37
  // --- execute 预检段(从 execute 拆出;行为逐字不变)-----------------------------
103
38
 
104
- // Resolve the base profile (side channel), then overlay explicit args.
39
+ // 解析基方案(旁路),再叠加显式参数。
105
40
  function mergeProfileArgs(args, store) {
106
41
  const base = args.profile !== undefined ? store.resolveProfile(args.profile) : {};
107
42
  const merged = { ...base };
@@ -111,21 +46,7 @@ function mergeProfileArgs(args, store) {
111
46
  return merged;
112
47
  }
113
48
 
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.
49
+ // 生效的委派值(供可观测元数据)。
129
50
  function buildMeta(args, merged, parent) {
130
51
  return {
131
52
  profile: args.profile ?? '(inline)',
@@ -137,10 +58,10 @@ function buildMeta(args, merged, parent) {
137
58
  };
138
59
  }
139
60
 
140
- // Assemble the foreground/background request (continuable builds its own below).
61
+ // 组装前台/后台请求(continuable 在下方自建)。
141
62
  function buildRequest(args, merged, parent, signal) {
142
63
  return {
143
- label: String(args.prompt ?? '').slice(0, 60),
64
+ label: dispatchLabel(args, merged),
144
65
  prompt: [{ type: 'text', text: args.prompt }],
145
66
  parent,
146
67
  signal,
@@ -151,8 +72,7 @@ function buildRequest(args, merged, parent, signal) {
151
72
  };
152
73
  }
153
74
 
154
- // Decision-level log: resolved effective delegation inputs, after the cost
155
- // guard and after request assembly, before dispatch.
75
+ // 决策级日志:经 cost guard 与请求组装之后、派发之前的生效委派输入。
156
76
  function logDispatchDecision(logger, args, merged, parent) {
157
77
  logger.info('[dsh-subagent-profile] dispatch:', JSON.stringify({
158
78
  profile: args.profile ?? '(inline)',
@@ -174,10 +94,9 @@ function logDispatchDecision(logger, args, merged, parent) {
174
94
  // 假设:continuable 继承父预设(preset swap 被忽略)⇒ 子工具集 ≈ 父工具集;
175
95
  // 失效条件:任何导致子工具集与父工具集不一致的宿主行为变化,父集都可能含
176
96
  // 子集上不存在之工具 → 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);
97
+ // fail-loud(保守安全)。effectiveAllow 由调用方(runDispatch 交集闸处)算好
98
+ // 传入——同一结果同时用于 request 组装与决策轨迹记录,不重复计算。
99
+ function buildContinuableRequest(args, merged, parent, effectiveAllow) {
181
100
  const hasAgentOptions = merged.provider !== undefined || merged.model !== undefined || merged.maxTokens !== undefined;
182
101
  return {
183
102
  prompt: [{ type: 'text', text: args.prompt }],
@@ -195,15 +114,12 @@ function buildContinuableRequest(args, merged, parent) {
195
114
  };
196
115
  }
197
116
 
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,不得静默派生子树。
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。
207
123
  if (!deps.getEnabled()) {
208
124
  throw new Error('dispatch: 插件已禁用(设置 → 子 Agent 方案 重新启用)');
209
125
  }
@@ -217,19 +133,21 @@ async function runContinuable(args, merged, meta, parent, exec, deps) {
217
133
  if (merged.reasoningEffort !== undefined) {
218
134
  deps.logger.warn(`[dsh-subagent-profile] continuable mode cannot set reasoningEffort; ignoring "${merged.reasoningEffort}"`);
219
135
  }
220
- const continuableRequest = buildContinuableRequest(args, merged, parent);
136
+ const continuableRequest = buildContinuableRequest(args, merged, parent, effectiveAllow);
221
137
  const { childId } = await deps.subagents.startContinuable({
222
138
  provider: 'profile',
223
- label: String(args.prompt ?? '').slice(0, 60),
139
+ label: dispatchLabel(args, merged),
224
140
  request: continuableRequest,
225
141
  signal: exec.signal
226
142
  });
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.
143
+ trackContinuableInflight(deps.guard, deps.subagents, childId, parent.session?.header?.id);
144
+ // Continuable 丢弃方案的 preset swap reasoningEffort(子继承父预设),
145
+ // 故可观测 meta 必须报告实际生效值,而非被忽略的请求值。
230
146
  // 可见性修复:`reasoningEffort` 回显**请求值**(经 meta.reasoningEffort),
231
147
  // `preset:'inherit'` 是真实生效值;`ignored` 明确列出被丢弃项。
232
- return {
148
+ // 信任标注不适用:continuable 只回 subagentId、无文本 output(后续经宿主的
149
+ // send_message 流转),没有可加前缀的回收文本,故此处不加 trustLabel。
150
+ const out = {
233
151
  kind: 'continuable',
234
152
  subagentId: childId,
235
153
  profile: meta.profile,
@@ -240,21 +158,33 @@ async function runContinuable(args, merged, meta, parent, exec, deps) {
240
158
  tokenTier: meta.tokenTier,
241
159
  ignored: ['preset', 'reasoningEffort']
242
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;
243
168
  }
244
169
 
245
170
  // --- 后台 / 前台分支(从 execute 拆出)--------------------------------------------
246
171
 
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) {
172
+ // 后台 one-shotjob)路径:jobs.start start() 并提供原生
173
+ // AbortController;仍是一轮即弃,非 continuable。
174
+ async function runBackground(args, merged, meta, request, parent, deps, pruneResultOutput, t0, trace, guardHandle, base) {
251
175
  const jobs = deps.getService('jobs');
252
176
  if (jobs === undefined) {
253
- 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,或改用前台派发)');
254
178
  }
255
- 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({
256
186
  kind: 'subagent',
257
- label: String(args.prompt ?? '').slice(0, 60),
187
+ label: dispatchLabel(args, merged),
258
188
  owner: parent,
259
189
  run: () => {
260
190
  const controller = new AbortController();
@@ -266,18 +196,23 @@ async function runBackground(args, meta, request, parent, deps, pruneResultOutpu
266
196
  meta,
267
197
  pruneResultOutput,
268
198
  (session) => measureChildTokens(deps.getService, session),
269
- t0
199
+ t0,
200
+ deps.logger,
201
+ onSettled
270
202
  )
271
203
  };
272
204
  }
273
205
  });
274
- 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;
275
212
  }
276
213
 
277
- // childTotalTokens:子 Agent 会话的宿主启发式估算 token 总量(surface 口径),
278
- // provider 计费 usage token。仅 completed 结算时测量;tokenMeter 服务缺失、
279
- // measure 非函数、返回缺 totalTokens 或抛错时均返回 undefined(fail-soft),
280
- // 使测量绝不阻断派发结算。
214
+ // childTotalTokens:宿主 tokenMeter 启发式估算(surface 口径,非计费 usage)。
215
+ // completed 结算时测量;服务缺失/非函数/抛错均返回 undefined(fail-soft)。
281
216
  function measureChildTokens(getService, session) {
282
217
  if (session === undefined || session === null) return undefined;
283
218
  const meter = getService('tokenMeter');
@@ -292,92 +227,163 @@ function measureChildTokens(getService, session) {
292
227
  }
293
228
  }
294
229
 
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) {
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) {
298
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);
299
247
  let result;
300
- let childTotalTokens;
301
- let childUsage;
248
+ let measurement;
302
249
  try {
303
250
  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
- }
251
+ if (result.stopReason === 'completed') measurement = measureForegroundRun(deps, run);
310
252
  } finally {
253
+ if (result === undefined) finishForegroundLedger(deps.evoLedger, base, run.id, { stopReason: 'error', output: [] }, [], Date.now() - t0, undefined);
311
254
  await run.dispose().catch(() => {});
255
+ guardHandle.finish(run.id, measurement?.childTotalTokens); // untrack + recordTokens + release
312
256
  }
313
- // completed 视为失败,附子输出部分文本。
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);
314
264
  const failure = stopReasonError(result);
315
265
  if (failure !== undefined) throw new Error(withPartialText(failure, result.output));
316
- // 结算 meta:elapsedMs 自 t0;stopReason 取底层值(此分支必 completed);
317
- // childTotalTokens / childUsage 仅可测量时携带。
318
266
  const metaOut = {
319
267
  ...meta,
320
268
  ...(childTotalTokens !== undefined ? { childTotalTokens } : {}),
321
269
  ...(childUsage !== undefined ? { childUsage } : {}),
322
- elapsedMs: Date.now() - t0,
270
+ elapsedMs,
323
271
  stopReason: result.stopReason
324
272
  };
325
- 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 };
326
315
  }
327
316
 
328
- // execute 主体:预检段 + 三分支分派,行为逐字不变。
317
+ // execute 主体:预检段 + 三分支分派。任一闸 fail 先记 fail 闸再 rethrow 原错误
318
+ // 对象,失败 trace 进进程内台账(deps.ledger)。
329
319
  async function runDispatch(args, exec, deps) {
330
320
  const parent = exec.agent;
331
- if (!parent) throw new Error('dispatch requires calling agent');
321
+ if (!parent) throw new Error('dispatch: 缺少调用 Agent(值需来自模型/编排者会话,请勿直接调用本工具)');
332
322
  // 派发耗时基准:execute 入口记 t0,前台/后台各自在结算处算 elapsedMs。
333
- // continuable 不结算、不携带,故 t0 仅穿线到前台/后台。
334
323
  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;
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;
345
364
  }
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
365
  }
363
366
 
364
- export function createDispatchTool({ register, store, getEnabled, getService, logger, subagents, catalog }) {
365
- 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 };
366
369
  const dispatchTool = defineTool({
367
370
  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 提示)。',
371
+ description: '派发子任务给派生子 Agent,可逐个覆盖其 presetmodelprovider、推理档位、persona、工具白名单、token 预算或递归深度。前台等待结果;run_in_background: true 启动后台任务(单轮即弃);continuable: true 启动持久子 Agent,后续轮次经 send_message 工具延续对话。前瞻:continuable 模式忽略 preset 换用与 reasoningEffort(结果以 ignored 提示)。',
369
372
  parameters: DISPATCH_PARAMETERS,
370
- 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
+ },
371
379
  isConcurrencySafe: () => true,
372
380
  execute: (args, exec) => runDispatch(args, exec, deps),
373
381
  });
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分支.
382
+ // 共享一致性规则锁:闭合 oneOf 结果 schema 的三个分支必须携带同一套共享
383
+ // 元数据键集。只在 apply 时触发;未来加元数据字段时若漏掉某个分支,在这里
384
+ // throw 一次(而不是让模型侧 schema 静默拒绝某个分支)。
378
385
  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.
386
+ // 只在启用时注册工具;开关一关立即注销,使工具无需重启就从模型的工具列表消失。
381
387
  let disposeTool;
382
388
  function syncTool() {
383
389
  if (getEnabled() && disposeTool === undefined) {
@@ -385,7 +391,10 @@ export function createDispatchTool({ register, store, getEnabled, getService, lo
385
391
  } else if (!getEnabled() && disposeTool !== undefined) {
386
392
  const dispose = disposeTool;
387
393
  disposeTool = undefined;
388
- dispose();
394
+ if (typeof dispose === 'function') dispose();
395
+ // 禁用:级联取消在途派发 + 清空并发/token 记账(重开后记账从零起步)。
396
+ deps.guard.cancelAll();
397
+ deps.guard.reset();
389
398
  }
390
399
  }
391
400
  syncTool();
@@ -395,7 +404,7 @@ export function createDispatchTool({ register, store, getEnabled, getService, lo
395
404
  if (disposeTool !== undefined) {
396
405
  const dispose = disposeTool;
397
406
  disposeTool = undefined;
398
- dispose();
407
+ if (typeof dispose === 'function') dispose();
399
408
  }
400
409
  }
401
410
  };