dsh-subagent-profile 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
- // lib/core/cost-guard.mjs — 运行时推导的 cost guard,从 index.mjs 逐字拆出。
1
+ // lib/core/cost-guard.mjs — 运行时推导的 cost guard,从 index.mjs 逐字拆出。
2
2
  // 仅引用 lib/core/pure.mjs 的 assertHardLimits;无 @deepseek-ai 依赖。
3
-
3
+ //
4
4
  // 运行时推导的 cost guard,两部分:
5
5
  // ① always-on hard caps (assertHardLimits, in lib/core/pure.mjs) — maxTokens /
6
6
  // maxDepth are hard delegation caps, independent of the `llm` service, so
@@ -13,11 +13,15 @@
13
13
  // fail-open compat (warn + skip; v1 数据迁移中) or fail-loud reject. A
14
14
  // profile that requests none of provider/model/reasoningEffort has nothing
15
15
  // to verify and always passes (valid in a headless deployment).
16
+ //
17
+ // 目录读取改走共享 catalog 快照(lib/core/catalog-cache.mjs):assertCostGuard 的
18
+ // `catalog` 参数是缓存实例,`catalog.getSnapshot(llm)` 返回缓存过的 providers /
19
+ // modelsByProvider。缓存只提速目录解析、不复检安全门——provider/model/effort 的
20
+ // 校验逻辑与 fail-loud 文案逐字不变;reasoningEffort 走直连 resolveCallConfig
21
+ // (校验调用,非目录读取,不入缓存)。
16
22
  // Used by both the provider's authoritative check and the dispatch tool's
17
23
  // pre-check. `allowFailOpen`/`logger` are injected because this function is
18
24
  // module-scoped and cannot reach the apply closure's `allowFailOpen`/`ctx.logger`.
19
- //
20
- // ①-⑥ 校验段按行门抽为模块级私有纯函数(行为逐字不变)。
21
25
 
22
26
  import { assertHardLimits } from './pure.mjs';
23
27
 
@@ -29,22 +33,19 @@ function needsLlmCheck(profile) {
29
33
  );
30
34
  }
31
35
 
32
- // ③ 目录为空检测:llm 存在但其 provider 目录为空(无发现能力)→ 无法核验。
33
- // 读取失败同样视为空目录(保守,随后走 allowFailOpen 门)。
34
- async function isLlmDirectoryEmpty(llm) {
35
- try {
36
- const providers = await llm.listProviders();
37
- return (providers ?? []).length === 0;
38
- } catch {
39
- return true;
36
+ // llm 缺失或目录为空时统一走 allowFailOpen 门(fail-open warn 或 fail-loud 拒绝)。
37
+ function handleUnverifiable(allowFailOpen, logger) {
38
+ if (allowFailOpen === true) {
39
+ logger.warn('llm 不可用:fail-open 兼容模式(v1 数据迁移中,建议保存一次配置以升级到 fail-loud)');
40
+ return;
40
41
  }
42
+ throw new Error('dispatch: 模型能力不可验证:fail-loud 拒绝(可在配置中显式开启兼容模式)');
41
43
  }
42
44
 
43
- // ④ provider 注册校验(目录非空时才能判定「不在目录」)。
44
- async function assertProviderRegistered(llm, profile) {
45
+ // ④ provider 注册校验(目录非空时才能判定「不在目录」)。providers 来自 catalog 快照。
46
+ function assertProviderRegistered(dir, profile) {
45
47
  if (typeof profile.provider !== 'string' || profile.provider.length === 0) return;
46
- const providers = await llm.listProviders();
47
- if (!(providers ?? []).some((provider) => provider && provider.id === profile.provider)) {
48
+ if (!(dir.providers ?? []).some((provider) => provider && provider.id === profile.provider)) {
48
49
  throw new Error(`dispatch: provider "${profile.provider}" is not a registered provider`);
49
50
  }
50
51
  }
@@ -56,13 +57,25 @@ async function assertProviderRegistered(llm, profile) {
56
57
  // per-provider 空目录。A non-empty catalog that does not advertise the model
57
58
  // fails loud. An unverifiable lookup (listModels(undefined) when no provider
58
59
  // is known) becomes a clean fail-loud error instead of leaking "undefined".
59
- async function assertModelAdvertised(llm, profile, effectiveProvider) {
60
+ // modelsByProvider 来自快照;快照未覆盖的 provider(undefined / 不在名册)
61
+ // 走直连 listModels 兜底,保持原 fail-loud 文案逐字。
62
+ async function assertModelAdvertised(llm, profile, effectiveProvider, modelsByProvider) {
60
63
  if (typeof profile.model !== 'string' || profile.model.length === 0) return;
64
+ const cached = modelsByProvider !== undefined ? modelsByProvider[String(effectiveProvider)] : undefined;
61
65
  let models;
62
- try {
63
- models = await llm.listModels(effectiveProvider);
64
- } catch (error) {
65
- throw new Error(`dispatch: cannot validate model "${profile.model}" without a provider: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
66
+ let modelsError;
67
+ if (cached !== undefined) {
68
+ if (cached.ok === true) models = cached.models;
69
+ else modelsError = cached.error;
70
+ } else {
71
+ try {
72
+ models = await llm.listModels(effectiveProvider);
73
+ } catch (error) {
74
+ modelsError = error;
75
+ }
76
+ }
77
+ if (modelsError !== undefined) {
78
+ throw new Error(`dispatch: cannot validate model "${profile.model}" without a provider: ${modelsError instanceof Error ? modelsError.message : String(modelsError)}`, { cause: modelsError });
66
79
  }
67
80
  const listed = models ?? [];
68
81
  const known = listed.length > 0 && listed.some((model) => model && (model.id === profile.model || model.name === profile.model));
@@ -81,7 +94,7 @@ async function assertReasoningSupported(llm, profile, effectiveProvider, effecti
81
94
  }
82
95
  }
83
96
 
84
- export async function assertCostGuard(parent, profile, allowFailOpen, logger) {
97
+ export async function assertCostGuard(parent, profile, allowFailOpen, logger, catalog) {
85
98
  // ① 硬上限 always-on(不依赖 llm)。
86
99
  assertHardLimits(profile.maxTokens, profile.maxDepth);
87
100
 
@@ -89,20 +102,21 @@ export async function assertCostGuard(parent, profile, allowFailOpen, logger) {
89
102
  if (!needsLlmCheck(profile)) return;
90
103
 
91
104
  const llm = parent.ctx.get('llm');
92
- // ③ llm 缺失或目录为空 → 无法核验:按 allowFailOpen 决定 fail-open / fail-loud。
93
- if (llm === undefined || await isLlmDirectoryEmpty(llm)) {
94
- if (allowFailOpen === true) {
95
- logger.warn('llm 不可用:fail-open 兼容模式(v1 数据迁移中,建议保存一次配置以升级到 fail-loud)');
96
- return;
97
- }
98
- throw new Error('dispatch: 模型能力不可验证:fail-loud 拒绝(可在配置中显式开启兼容模式)');
105
+ // ③ llm 缺失 → 无法核验:按 allowFailOpen 决定 fail-open / fail-loud。
106
+ if (llm === undefined) return handleUnverifiable(allowFailOpen, logger);
107
+
108
+ // llm 存在:目录读取走共享 catalog 快照(缓存只提速目录解析,校验逻辑不变)。
109
+ const snapshot = await catalog.getSnapshot(llm);
110
+ const dir = snapshot.llm;
111
+ if (dir.providersError !== undefined || dir.providers.length === 0) {
112
+ return handleUnverifiable(allowFailOpen, logger);
99
113
  }
100
114
 
101
115
  // ④ provider 注册校验。
102
- await assertProviderRegistered(llm, profile);
116
+ assertProviderRegistered(dir, profile);
103
117
  const effectiveProvider = profile.provider !== undefined ? profile.provider : parent.options.provider;
104
118
  const effectiveModel = profile.model !== undefined ? profile.model : parent.options.model;
105
119
  // ⑤ model 校验 + ⑥ reasoningEffort 校验。
106
- await assertModelAdvertised(llm, profile, effectiveProvider);
120
+ await assertModelAdvertised(llm, profile, effectiveProvider, dir.modelsByProvider);
107
121
  await assertReasoningSupported(llm, profile, effectiveProvider, effectiveModel);
108
122
  }
@@ -1,4 +1,4 @@
1
- // lib/core/delegation.mjs — background one-shot settling + delegation metadata
1
+ // lib/core/delegation.mjs — background one-shot settling + delegation metadata
2
2
  // assembly, moved from index.mjs. Local lib references only: imports
3
3
  // stopReasonError / withPartialText / textFrom from lib/core/pure.mjs (the shipped
4
4
  // shims.readResult is NOT used by settleStart — it is used only by the
@@ -14,19 +14,39 @@ import { stopReasonError, withPartialText, textFrom } from './pure.mjs';
14
14
  // `prune` is the result-recycle pre-clipper: the caller (dispatch
15
15
  // execute) injects a closure that calls the host toolResultPruner.pruneContent
16
16
  // before textFrom; defaulting to identity keeps the background path safe when no
17
- // pruner is available.
18
- export async function settleStart(start, signal, meta, prune = (blocks) => blocks) {
17
+ // pruner is available. `t0` is the dispatch execute entry timestamp; the settled
18
+ // outcome carries `elapsedMs = now - t0` and the underlying `stopReason` (the
19
+ // shipped terminal vocabulary). Defaulting t0 to now keeps direct callers (tests)
20
+ // working without threading a timestamp.
21
+ export async function settleStart(start, signal, meta, prune = (blocks) => blocks, measureChild = () => undefined, t0 = Date.now()) {
19
22
  let run;
20
23
  try {
21
24
  run = await start;
22
25
  const result = await run.result;
23
26
  const failure = stopReasonError(result);
24
27
  if (failure !== undefined) {
25
- return { status: result.stopReason === 'aborted' ? 'killed' : 'failed', detail: withPartialText(failure, result.output), ...meta };
28
+ return {
29
+ status: result.stopReason === 'aborted' ? 'killed' : 'failed',
30
+ detail: withPartialText(failure, result.output),
31
+ ...meta,
32
+ elapsedMs: Date.now() - t0,
33
+ stopReason: result.stopReason
34
+ };
26
35
  }
27
- return { status: 'completed', output: textFrom(prune(result.output)), ...meta };
36
+ // 仅 completed 结算时测量(非 completed 走上方失败分支,不测);在 dispose
37
+ // 之前读子 session。measureChild 缺失/失败返回 undefined → 省略字段(fail-soft)。
38
+ const childTotalTokens = measureChild(run.localAgent?.session);
39
+ const metaOut = {
40
+ ...meta,
41
+ ...(childTotalTokens !== undefined ? { childTotalTokens } : {}),
42
+ elapsedMs: Date.now() - t0,
43
+ stopReason: 'completed'
44
+ };
45
+ return { status: 'completed', output: textFrom(prune(result.output)), ...metaOut };
28
46
  } catch (error) {
29
- return signal.aborted ? { status: 'killed', ...meta } : { status: 'failed', detail: String(error), ...meta };
47
+ return signal.aborted
48
+ ? { status: 'killed', ...meta, elapsedMs: Date.now() - t0, stopReason: 'aborted' }
49
+ : { status: 'failed', detail: String(error), ...meta, elapsedMs: Date.now() - t0, stopReason: 'error' };
30
50
  } finally {
31
51
  // Release the child handle no matter how the result settled — run.result
32
52
  // rejecting must not leak the subagent (same discipline as the foreground
@@ -0,0 +1,71 @@
1
+ // lib/core/dispatch-schema.mjs — dispatch 工具的 output 结果 schema(closed oneOf),
2
+ // 从 dispatch-tool.mjs 拆出的纯数据声明(拆出时同步新增 elapsedMs/stopReason 共享
3
+ // 字段)。三个分支(background / continuable / foreground)共享同一套元数据键集
4
+ // (判别键 kind/jobId/subagentId/output 除外),由 pure.mjs 的
5
+ // assertResultSchemaConsistency 在 apply 时锁定——任一分支漏补共享字段即 throw。
6
+ // 纯数据、零依赖。
7
+
8
+ export const DISPATCH_OUTPUT_SCHEMA = {
9
+ // Observability metadata on every result. OneOf covers the
10
+ // background variant (kind/jobId) and the foreground variant (output),
11
+ // both closed and both carrying the effective delegation values.
12
+ // `ignored` must appear in ALL three branches (with the shared `preset`
13
+ // / `provider` / `model` / `reasoningEffort` / `profile`), keeping the
14
+ // closed oneOf consistent — assertResultSchemaConsistency(dispatchTool
15
+ // .output.schema) in apply() fires if any 分支 忘补该字段.
16
+ oneOf: [
17
+ {
18
+ type: 'object',
19
+ additionalProperties: false,
20
+ properties: {
21
+ kind: { type: 'string', required: true, const: 'background' },
22
+ jobId: { type: 'string', required: true },
23
+ profile: { type: 'string' },
24
+ preset: { type: 'string' },
25
+ provider: { type: 'string' },
26
+ model: { type: 'string' },
27
+ reasoningEffort: { type: 'string' },
28
+ tokenTier: { type: 'string', enum: ['cheap', 'balanced', 'premium'], description: '成本/深度分层(估算口径,非计费)。' },
29
+ childTotalTokens: { type: 'number', description: '子 Agent 会话 token 估算总量(宿主启发式估算,非计费 usage token);后台结算经 job 结果携带,dispatch 结果不带。' },
30
+ elapsedMs: { type: 'number', description: '派发耗时(毫秒,execute 入口到结算时刻);后台结算经 job 结果携带,dispatch 结果不带。' },
31
+ stopReason: { type: 'string', enum: ['completed', 'max-tokens', 'aborted', 'refusal', 'error'], description: '子 Agent 结束原因;后台结算经 job 结果携带,dispatch 结果不带。' },
32
+ ignored: { type: 'array', items: { type: 'string' } }
33
+ }
34
+ },
35
+ {
36
+ type: 'object',
37
+ additionalProperties: false,
38
+ properties: {
39
+ kind: { type: 'string', required: true, const: 'continuable' },
40
+ subagentId: { type: 'string', required: true },
41
+ profile: { type: 'string' },
42
+ preset: { type: 'string' },
43
+ provider: { type: 'string' },
44
+ model: { type: 'string' },
45
+ reasoningEffort: { type: 'string' },
46
+ tokenTier: { type: 'string', enum: ['cheap', 'balanced', 'premium'], description: '成本/深度分层(估算口径,非计费)。' },
47
+ childTotalTokens: { type: 'number', description: '子 Agent 会话 token 估算总量(宿主启发式估算,非计费 usage token);仅前台/后台结算时携带,continuable 实际省略。' },
48
+ elapsedMs: { type: 'number', description: '派发耗时(毫秒,execute 入口到结算时刻);continuable 不结算,实际省略。' },
49
+ stopReason: { type: 'string', enum: ['completed', 'max-tokens', 'aborted', 'refusal', 'error'], description: '子 Agent 结束原因;continuable 不结算,实际省略。' },
50
+ ignored: { type: 'array', items: { type: 'string' } }
51
+ }
52
+ },
53
+ {
54
+ type: 'object',
55
+ additionalProperties: false,
56
+ properties: {
57
+ output: { type: 'string', required: true },
58
+ profile: { type: 'string' },
59
+ preset: { type: 'string' },
60
+ provider: { type: 'string' },
61
+ model: { type: 'string' },
62
+ reasoningEffort: { type: 'string' },
63
+ tokenTier: { type: 'string', enum: ['cheap', 'balanced', 'premium'], description: '成本/深度分层(估算口径,非计费)。' },
64
+ childTotalTokens: { type: 'number', description: '子 Agent 会话 token 估算总量(宿主启发式估算,非计费 usage token);仅前台 completed 结算时携带。' },
65
+ elapsedMs: { type: 'number', description: '派发耗时(毫秒,execute 入口到结算时刻);前台 completed 结算时携带。' },
66
+ stopReason: { type: 'string', enum: ['completed', 'max-tokens', 'aborted', 'refusal', 'error'], description: '子 Agent 结束原因;前台 completed 结算时携带。' },
67
+ ignored: { type: 'array', items: { type: 'string' } }
68
+ }
69
+ }
70
+ ]
71
+ };
@@ -1,4 +1,4 @@
1
- // lib/core/dispatch-tool.mjs — `dispatch` 工具(defineTool schema + execute)、
1
+ // lib/core/dispatch-tool.mjs — `dispatch` 工具(defineTool schema + execute)、
2
2
  // 结果 schema 一致性锁与 syncTool 注册/注销逻辑,从 index.mjs 逐字拆出。
3
3
  // 仅引用 lib + shims;无 @deepseek-ai 依赖(shims 是唯一入口)。
4
4
  //
@@ -17,14 +17,16 @@
17
17
  // routes (/set-enabled), dispose runs on plugin teardown.
18
18
  //
19
19
  // execute 按预检段 + 前台/后台/continuable 三分支拆为模块级私有函数;
20
- // parameters/output 声明为纯数据,驻留模块级常量(与数据表同性质,不受函数
21
- // 行门约束)——工厂保持装配态,行为逐字不变。
20
+ // parameters 声明为纯数据,驻留模块级常量(与数据表同性质,不受函数行门约束);
21
+ // output 结果 schema 已拆至 lib/core/dispatch-schema.mjs(守文件行门)——
22
+ // 工厂保持装配态;除各任务明示的新增行为外,执行路径零漂移。
22
23
 
23
24
  import { defineTool } from './shims.mjs';
24
25
  import { computeContinuableAllow, textFrom, stopReasonError, withPartialText, pruneBlocks, assertResultSchemaConsistency } from './pure.mjs';
25
26
  import { resolveWhitelist } from './whitelist.mjs';
26
27
  import { assertCostGuard } from './cost-guard.mjs';
27
28
  import { settleStart } from './delegation.mjs';
29
+ import { DISPATCH_OUTPUT_SCHEMA } from './dispatch-schema.mjs';
28
30
 
29
31
  // --- 工具声明纯数据(从工厂提到模块级;defineTool 只读不改)----------------------
30
32
 
@@ -34,6 +36,7 @@ const DISPATCH_PARAMETERS = {
34
36
  model: { type: 'string', description: 'Explicit model override for the child.' },
35
37
  provider: { type: 'string', description: 'Explicit provider override for the child.' },
36
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。' },
37
40
  persona: { type: 'string', description: 'Persona text shadowing the child deployment:persona section.' },
38
41
  toolFilter: {
39
42
  type: 'object',
@@ -50,65 +53,18 @@ const DISPATCH_PARAMETERS = {
50
53
  maxDepth: { type: 'number', description: 'Absolute delegation-depth cap for this child.' },
51
54
  run_in_background: { type: 'boolean', description: '异步 one-shot:走 jobs.start 包 start(),返回 jobId;仍单轮即弃,非 continuable' },
52
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.' },
53
- // 信封模式 = opt-in(仅「中段即交付物」的任务用)。当前**仅预留**:工具
54
- // schema 暴露此参数作字段契约,execute 不消费它(结构化信封回收尚未启用)。
55
- // 见 execute 内注释。
56
- envelope: { type: 'boolean', description: '预留:结构化信封回收(V2.0 中期启用,当前不生效)' },
56
+ // 信封模式 = opt-in(仅「中段即交付物」的任务用):true 时把信封骨架追加进
57
+ // 子 persona(systemPrompt 影子段,模型可见),子 Agent 按结构化信封汇报;
58
+ // 默认 false 走结果剪枝回收。
59
+ envelope: { type: 'boolean', description: 'true 时子 Agent 按结构化信封汇报(简短结论 + 结构分项 + 关键发现落点),适合中段即交付物的长任务;默认 false 走剪枝回收' },
57
60
  prompt: { type: 'string', required: true, description: 'The complete, self-contained task for the child (it does not see this conversation).' }
58
61
  };
59
62
 
60
- const DISPATCH_OUTPUT_SCHEMA = {
61
- // Observability metadata on every result. OneOf covers the
62
- // background variant (kind/jobId) and the foreground variant (output),
63
- // both closed and both carrying the effective delegation values.
64
- // `ignored` must appear in ALL three branches (with the shared `preset`
65
- // / `provider` / `model` / `reasoningEffort` / `profile`), keeping the
66
- // closed oneOf consistent — assertResultSchemaConsistency(dispatchTool
67
- // .output.schema) in apply() fires if any 分支 忘补该字段.
68
- oneOf: [
69
- {
70
- type: 'object',
71
- additionalProperties: false,
72
- properties: {
73
- kind: { type: 'string', required: true, const: 'background' },
74
- jobId: { type: 'string', required: true },
75
- profile: { type: 'string' },
76
- preset: { type: 'string' },
77
- provider: { type: 'string' },
78
- model: { type: 'string' },
79
- reasoningEffort: { type: 'string' },
80
- ignored: { type: 'array', items: { type: 'string' } }
81
- }
82
- },
83
- {
84
- type: 'object',
85
- additionalProperties: false,
86
- properties: {
87
- kind: { type: 'string', required: true, const: 'continuable' },
88
- subagentId: { type: 'string', required: true },
89
- profile: { type: 'string' },
90
- preset: { type: 'string' },
91
- provider: { type: 'string' },
92
- model: { type: 'string' },
93
- reasoningEffort: { type: 'string' },
94
- ignored: { type: 'array', items: { type: 'string' } }
95
- }
96
- },
97
- {
98
- type: 'object',
99
- additionalProperties: false,
100
- properties: {
101
- output: { type: 'string', required: true },
102
- profile: { type: 'string' },
103
- preset: { type: 'string' },
104
- provider: { type: 'string' },
105
- model: { type: 'string' },
106
- reasoningEffort: { type: 'string' },
107
- ignored: { type: 'array', items: { type: 'string' } }
108
- }
109
- }
110
- ]
111
- };
63
+ // 信封骨架:envelope:true 时追加进子 persona 的结构化汇报契约。三分支共享此
64
+ // 常量;骨架只进 persona(systemPrompt 影子段、模型可见),不进 descriptor
65
+ // (model-hidden、仅 session 记录),故 execute 在三分支创建子 Agent 前统一
66
+ // 追加到 merged.persona。
67
+ const ENVELOPE_SKELETON = '完成前按以下骨架输出:## 结论(1-2 句)\n## 结构分项(逐项)\n## 关键发现落点(文件路径/数据位置)';
112
68
 
113
69
  // continuable 丢弃 preset 换用与 reasoningEffort —— 渲染行把 `ignored`
114
70
  // 列表回显出来(`reasoningEffort=<值>(ignored)`,再加 ignored 项明细),让模型
@@ -117,11 +73,23 @@ const DISPATCH_RENDER = (_args, value) => {
117
73
  const ignored = value.ignored !== undefined && value.ignored.length > 0
118
74
  ? `(ignored: ${value.ignored.join(', ')})`
119
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
+ : '';
120
88
  const text = value.kind === 'background'
121
- ? `[dispatch] background job ${value.jobId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}`
89
+ ? `[dispatch] background job ${value.jobId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}${tier}`
122
90
  : value.kind === 'continuable'
123
- ? `[dispatch] started subagent ${value.subagentId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}`
124
- : `[dispatch] profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}\n\n${value.output}`;
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}`;
125
93
  return [{ type: 'text', text }];
126
94
  };
127
95
 
@@ -131,7 +99,7 @@ const DISPATCH_RENDER = (_args, value) => {
131
99
  function mergeProfileArgs(args, store) {
132
100
  const base = args.profile !== undefined ? store.resolveProfile(args.profile) : {};
133
101
  const merged = { ...base };
134
- for (const key of ['preset', 'model', 'provider', 'reasoningEffort', 'persona', 'toolFilter', 'maxTokens', 'maxDepth']) {
102
+ for (const key of ['preset', 'model', 'provider', 'reasoningEffort', 'persona', 'toolFilter', 'maxTokens', 'maxDepth', 'tokenTier']) {
135
103
  if (args[key] !== undefined) merged[key] = args[key];
136
104
  }
137
105
  return merged;
@@ -158,7 +126,8 @@ function buildMeta(args, merged, parent) {
158
126
  preset: merged.preset ?? 'inherit',
159
127
  provider: merged.provider ?? parent.options.provider ?? '(parent)',
160
128
  model: merged.model ?? parent.options.model ?? '(parent)',
161
- reasoningEffort: merged.reasoningEffort ?? '(default)'
129
+ reasoningEffort: merged.reasoningEffort ?? '(default)',
130
+ tokenTier: merged.tokenTier ?? 'balanced'
162
131
  };
163
132
  }
164
133
 
@@ -262,6 +231,7 @@ async function runContinuable(args, merged, meta, parent, exec, deps) {
262
231
  provider: meta.provider,
263
232
  model: meta.model,
264
233
  reasoningEffort: meta.reasoningEffort,
234
+ tokenTier: meta.tokenTier,
265
235
  ignored: ['preset', 'reasoningEffort']
266
236
  };
267
237
  }
@@ -271,7 +241,7 @@ async function runContinuable(args, merged, meta, parent, exec, deps) {
271
241
  // Background one-shot (job) path — jobs.start wraps start() with a native
272
242
  // AbortController (a Node global in a bundle; the dynamic-plugin sandbox needed
273
243
  // the hand-rolled shim instead); still one turn, not continuable.
274
- async function runBackground(args, meta, request, parent, deps, pruneResultOutput) {
244
+ async function runBackground(args, meta, request, parent, deps, pruneResultOutput, t0) {
275
245
  const jobs = deps.getService('jobs');
276
246
  if (jobs === undefined) {
277
247
  throw new Error('dispatch: background jobs unavailable (load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs)');
@@ -284,20 +254,51 @@ async function runBackground(args, meta, request, parent, deps, pruneResultOutpu
284
254
  const controller = new AbortController();
285
255
  return {
286
256
  cancel: (reason) => controller.abort(reason ?? 'dispatch: background subagent task killed'),
287
- done: settleStart(deps.subagents.start('profile', { ...request, signal: controller.signal }), controller.signal, meta, pruneResultOutput)
257
+ done: settleStart(
258
+ deps.subagents.start('profile', { ...request, signal: controller.signal }),
259
+ controller.signal,
260
+ meta,
261
+ pruneResultOutput,
262
+ (session) => measureChildTokens(deps.getService, session),
263
+ t0
264
+ )
288
265
  };
289
266
  }
290
267
  });
291
268
  return { kind: 'background', jobId, ...meta };
292
269
  }
293
270
 
271
+ // childTotalTokens:子 Agent 会话的宿主启发式估算 token 总量(surface 口径),
272
+ // 非 provider 计费 usage token。仅 completed 结算时测量;tokenMeter 服务缺失、
273
+ // measure 非函数、返回缺 totalTokens 或抛错时均返回 undefined(fail-soft),
274
+ // 使测量绝不阻断派发结算。
275
+ function measureChildTokens(getService, session) {
276
+ if (session === undefined || session === null) return undefined;
277
+ const meter = getService('tokenMeter');
278
+ if (meter === undefined || typeof meter.measure !== 'function') return undefined;
279
+ try {
280
+ const measured = meter.measure(session);
281
+ return measured !== null && typeof measured === 'object' && typeof measured.totalTokens === 'number'
282
+ ? measured.totalTokens
283
+ : undefined;
284
+ } catch {
285
+ return undefined;
286
+ }
287
+ }
288
+
294
289
  // Foreground: collect, always release the handle (dispose even when
295
290
  // run.result rejects), then fail loud on a non-completed stop reason.
296
- async function runForeground(meta, request, parent, deps, pruneResultOutput) {
291
+ async function runForeground(meta, request, parent, deps, pruneResultOutput, t0) {
297
292
  const run = await deps.subagents.start('profile', request);
298
293
  let result;
294
+ let childTotalTokens;
299
295
  try {
300
296
  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
+ }
301
302
  } finally {
302
303
  await run.dispose().catch(() => {});
303
304
  }
@@ -305,33 +306,55 @@ async function runForeground(meta, request, parent, deps, pruneResultOutput) {
305
306
  // partial output text (withPartialText style).
306
307
  const failure = stopReasonError(result);
307
308
  if (failure !== undefined) throw new Error(withPartialText(failure, result.output));
308
- return { output: textFrom(pruneResultOutput(result.output)), ...meta };
309
+ // 结算 meta:elapsedMs 从 execute 入口 t0 计;stopReason 取底层结算值
310
+ // (此分支已达 return 必为 completed)。childTotalTokens 仍仅测量时携带。
311
+ const metaOut = {
312
+ ...meta,
313
+ ...(childTotalTokens !== undefined ? { childTotalTokens } : {}),
314
+ elapsedMs: Date.now() - t0,
315
+ stopReason: result.stopReason
316
+ };
317
+ return { output: textFrom(pruneResultOutput(result.output)), ...metaOut };
309
318
  }
310
319
 
311
320
  // execute 主体:预检段 + 三分支分派,行为逐字不变。
312
321
  async function runDispatch(args, exec, deps) {
313
322
  const parent = exec.agent;
314
323
  if (!parent) throw new Error('dispatch requires calling agent');
324
+ // 派发耗时基准:execute 入口记 t0,前台/后台各自在结算处算 elapsedMs。
325
+ // continuable 不结算、不携带,故 t0 仅穿线到前台/后台。
326
+ const t0 = Date.now();
315
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;
337
+ }
316
338
  await assertPresetWhitelist(parent, merged);
317
- // Cost guard(运行时推导;硬上限始终生效,llm 能力核验由 allowFailOpen 门控)。
318
- await assertCostGuard(parent, merged, deps.store.getAllowFailOpen(), deps.logger);
339
+ // Cost guard(运行时推导;硬上限始终生效,llm 能力核验由 allowFailOpen 门控;
340
+ // llm 目录读取走共享 catalog 快照)。
341
+ await assertCostGuard(parent, merged, deps.store.getAllowFailOpen(), deps.logger, deps.catalog);
319
342
  const meta = buildMeta(args, merged, parent);
320
343
  const request = buildRequest(args, merged, parent, exec.signal);
321
344
  // 结果回收默认剪枝:在 textFrom(result.output) 之前复用宿主
322
345
  // toolResultPruner.pruneContent 预剪。`pruneResultOutput` 每次现取
323
346
  // ctx.get('toolResultPruner') 以反映服务就绪状态;pruner 缺失时
324
- // pruneBlocks 回退为不剪(剪枝是增强、非硬依赖)。envelope 参数虽已在
325
- // 工具 schema 暴露(预留),但 execute **不消费**它。
347
+ // pruneBlocks 回退为不剪(剪枝是增强、非硬依赖)。envelope:true 时信封
348
+ // 骨架已在上方追加进 merged.persona(不改变此处的剪枝回收路径)。
326
349
  const pruneResultOutput = (blocks) => pruneBlocks(blocks, deps.getService('toolResultPruner'));
327
350
  logDispatchDecision(deps.logger, args, merged, parent);
328
351
  if (args.continuable === true) return runContinuable(args, merged, meta, parent, exec, deps);
329
- if (args.run_in_background === true) return runBackground(args, meta, request, parent, deps, pruneResultOutput);
330
- return runForeground(meta, request, parent, deps, pruneResultOutput);
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);
331
354
  }
332
355
 
333
- export function createDispatchTool({ register, store, getEnabled, getService, logger, subagents }) {
334
- const deps = { store, getEnabled, getService, logger, subagents };
356
+ export function createDispatchTool({ register, store, getEnabled, getService, logger, subagents, catalog }) {
357
+ const deps = { store, getEnabled, getService, logger, subagents, catalog };
335
358
  const dispatchTool = defineTool({
336
359
  name: 'dispatch',
337
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 提示)。',