dsh-subagent-profile 0.3.0 → 0.3.2

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.
@@ -0,0 +1,235 @@
1
+ // lib/core/catalog-cache.mjs — 进程级共享 catalog 快照:一次拉取 llm 目录
2
+ // (providers + 每 provider 的 models + 每模型 reasoning-effort 等级)、
3
+ // system-trust 预设名册与完整工具目录,按 TTL 缓存后同时喂给设置页 /options
4
+ // 三路由与 dispatch 的 cost guard。TTL 过期自动重拉;同 key 在途 Promise 去重;
5
+ // /options/refresh 手动清缓存兜底。
6
+ //
7
+ // 依赖边界(无 @deepseek-ai 依赖):仅 import 纯数据表
8
+ // lib/core/catalog.mjs(工具名 → 中文/分类,零依赖)。服务经注入 getter 传入:
9
+ // getLlm () => ctx.get('llm') (headless 下可为 undefined)
10
+ // getAgentPresets () => ctx.get('agentPresets')
11
+ // getTools () => ctx.tools
12
+ // cost guard 校验的是「父 Agent 的 llm 实例」;同一进程内父 ctx 与插件 ctx 读到的
13
+ // 是同一个 host llm 服务,故 getSnapshot 接受可选 llm 覆盖(父实例)时仍命中同一条目。
14
+ //
15
+ // 缓存只提速目录解析,不复检安全门:provider/model/reasoningEffort 的校验逻辑与
16
+ // fail-loud 文案留在 lib/core/cost-guard.mjs(逐字不变),此处仅提供目录数据。
17
+
18
+ import { TOOL_ZH, TOOL_CATEGORY } from './catalog.mjs';
19
+
20
+ const DEFAULT_TTL_MS = 60000;
21
+
22
+ // --- 目录构建(无缓存;行为与 http-routes 原 collector 逐字一致)----------------
23
+
24
+ // llm 目录:providers + 每 provider models + 每模型 efforts。listProviders 失败
25
+ // 记入 providersError(cost guard 据此判「目录为空」);每 provider 的 listModels
26
+ // 失败单独记录——cost guard 需要区分「空目录短路」与「listModels 抛错」的 fail-loud 文案。
27
+ async function collectLlmDirectory(llm) {
28
+ const models = [];
29
+ const efforts = Object.create(null);
30
+ const modelsByProvider = Object.create(null);
31
+ if (llm === undefined || typeof llm.listProviders !== 'function') {
32
+ return { providersError: undefined, providers: [], modelsByProvider, models, efforts };
33
+ }
34
+ let providers;
35
+ let providersError;
36
+ try {
37
+ providers = (await llm.listProviders()) ?? [];
38
+ } catch (error) {
39
+ providersError = error instanceof Error ? error : new Error(String(error));
40
+ return { providersError, providers: [], modelsByProvider, models, efforts };
41
+ }
42
+ for (const provider of providers) {
43
+ const providerId = provider && provider.id;
44
+ if (typeof providerId !== 'string') continue;
45
+ let entry;
46
+ try {
47
+ const modelList = await llm.listModels(providerId);
48
+ entry = { ok: true, models: modelList ?? [] };
49
+ } catch (error) {
50
+ entry = { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
51
+ }
52
+ modelsByProvider[providerId] = entry;
53
+ if (entry.ok !== true) continue; // 该 provider 目录拉取失败 → 跳过其 UI 目录
54
+ for (const model of entry.models) {
55
+ if (!model || typeof model.id !== 'string') continue;
56
+ models.push({ provider: providerId, providerName: provider.name ?? providerId, id: model.id, name: model.name ?? model.id });
57
+ try {
58
+ const info = await llm.resolveModelInfo(providerId, model.id);
59
+ const effortsList = info && info.reasoning && Array.isArray(info.reasoning.efforts) ? info.reasoning.efforts : [];
60
+ efforts[model.id] = effortsList.map((effort) => ({
61
+ id: effort.id,
62
+ name: effort.name ?? effort.id,
63
+ ...(effort.description !== undefined ? { description: effort.description } : {})
64
+ }));
65
+ } catch { /* 精确模型查询可能拒绝;跳过其 efforts */ }
66
+ }
67
+ }
68
+ return { providersError: undefined, providers, modelsByProvider, models, efforts };
69
+ }
70
+
71
+ // System-trust 预设名册(agentPresets 可选,fail-soft)。
72
+ async function collectSystemPresets(agentPresets) {
73
+ const presets = [];
74
+ if (agentPresets === undefined || typeof agentPresets.list !== 'function') return presets;
75
+ try {
76
+ const list = await agentPresets.list();
77
+ for (const preset of (list ?? [])) {
78
+ if (preset && preset.trust === 'system') {
79
+ presets.push({ id: preset.id, name: preset.name ?? preset.id });
80
+ }
81
+ }
82
+ } catch { /* presets roster unavailable; leave empty */ }
83
+ return presets;
84
+ }
85
+
86
+ // 完整工具目录 = global 层(部署插件)+ 每个 preset 的 standing scope
87
+ // (agent.cordis.yml 工具行)。工具按其来源打标:'global' 或 preset id——分组完全
88
+ // 动态,源自运行时名册。整体失败 fail-soft(返回空目录 + 告警),不破坏设置页。
89
+ async function collectToolsDirectory(getTools, getAgentPresets, logger) {
90
+ try {
91
+ const tools = [];
92
+ const seen = new Set();
93
+ const OFFICIAL_PRESETS = ['standard', 'code', 'minimal', 'cordis'];
94
+ const layerOf = (source) => {
95
+ if (source === 'global') return 'plugin';
96
+ if (OFFICIAL_PRESETS.includes(source)) return 'core';
97
+ return 'custom';
98
+ };
99
+ const groupOf = (name, source) => {
100
+ const layer = layerOf(source);
101
+ if (layer === 'core') return TOOL_CATEGORY[name] ?? '其他';
102
+ if (layer === 'plugin') return name.includes('_') ? name.split('_')[0] : name;
103
+ return source;
104
+ };
105
+ const push = (schemas, source) => {
106
+ for (const s of (Array.isArray(schemas) ? schemas : [])) {
107
+ if (!s || typeof s.name !== 'string' || s.name === 'run_code' || seen.has(s.name)) continue;
108
+ seen.add(s.name);
109
+ tools.push({ name: s.name, description: typeof s.description === 'string' ? s.description : '', zh: TOOL_ZH[s.name] ?? '', source, layer: layerOf(source), group: groupOf(s.name, source) });
110
+ }
111
+ };
112
+ const toolsService = typeof getTools === 'function' ? getTools() : undefined;
113
+ if (toolsService && typeof toolsService.schemas === 'function') {
114
+ push(toolsService.schemas(), 'global');
115
+ const agentPresets = typeof getAgentPresets === 'function' ? getAgentPresets() : undefined;
116
+ if (agentPresets !== undefined && typeof agentPresets.list === 'function' && typeof agentPresets.standingKeyFor === 'function') {
117
+ const presets = await agentPresets.list();
118
+ for (const preset of (presets ?? [])) {
119
+ if (!preset || typeof preset.id !== 'string') continue;
120
+ try {
121
+ push(toolsService.schemas(await agentPresets.standingKeyFor(preset.id)), preset.id);
122
+ } catch { /* 单个 preset 的 standing scope 不可用;跳过 */ }
123
+ }
124
+ }
125
+ }
126
+ return tools;
127
+ } catch (error) {
128
+ if (logger !== undefined && typeof logger.warn === 'function') {
129
+ logger.warn('[dsh-subagent-profile] tools directory failed:', error instanceof Error ? error.message : String(error));
130
+ }
131
+ return [];
132
+ }
133
+ }
134
+
135
+ // 构建一份完整快照(无缓存)。`llm` 可选:cost guard 用它传入父 Agent 的 llm 实例。
136
+ export async function getCatalogSnapshot({ getLlm, getAgentPresets, getTools, llm, logger }) {
137
+ const effectiveLlm = llm !== undefined ? llm : (typeof getLlm === 'function' ? getLlm() : undefined);
138
+ const agentPresets = typeof getAgentPresets === 'function' ? getAgentPresets() : undefined;
139
+ const dir = await collectLlmDirectory(effectiveLlm);
140
+ const presets = await collectSystemPresets(agentPresets);
141
+ const tools = await collectToolsDirectory(getTools, getAgentPresets, logger);
142
+ return {
143
+ llm: { providersError: dir.providersError, providers: dir.providers, modelsByProvider: dir.modelsByProvider },
144
+ models: dir.models,
145
+ efforts: dir.efforts,
146
+ presets,
147
+ tools,
148
+ };
149
+ }
150
+
151
+ // --- TTL 缓存(按 llm 实例分键;同 key 在途去重;hit/miss 计数走 host logger)----
152
+
153
+ const NO_LLM = Symbol('catalog-cache:no-llm');
154
+
155
+ function makeAudit(logger) {
156
+ if (logger !== undefined && typeof logger.info === 'function') {
157
+ return (outcome, hits, misses) => logger.info(`[dsh-subagent-profile] catalog cache ${outcome} (hit=${hits}, miss=${misses})`);
158
+ }
159
+ return () => {};
160
+ }
161
+
162
+ // 命中/未命中判定 + TTL 过期重拉;并发 cache-miss 复用同一条在途 Promise(只拉一次)。
163
+ async function resolveSnapshot(state, llm) {
164
+ // 无参时按注入的 getLlm() 取 llm 实例作 key——保证 /options 侧(无参)与
165
+ // cost guard 侧(传父实例)在同一 host llm 实例下命中同一条目。
166
+ const effectiveLlm = llm !== undefined ? llm : (typeof state.getLlm === 'function' ? state.getLlm() : undefined);
167
+ const key = effectiveLlm !== undefined ? effectiveLlm : NO_LLM;
168
+ const at = state.clock();
169
+ const entry = state.entries.get(key);
170
+ if (entry !== undefined) {
171
+ if (entry.snapshot !== undefined && entry.expiresAt > at) {
172
+ state.hits += 1;
173
+ state.audit('hit', state.hits, state.misses);
174
+ return entry.snapshot;
175
+ }
176
+ if (entry.inflight !== undefined) {
177
+ state.hits += 1;
178
+ state.audit('hit', state.hits, state.misses);
179
+ return entry.inflight;
180
+ }
181
+ }
182
+ state.misses += 1;
183
+ state.audit('miss', state.hits, state.misses);
184
+ const inflight = getCatalogSnapshot({
185
+ getLlm: state.getLlm,
186
+ getAgentPresets: state.getAgentPresets,
187
+ getTools: state.getTools,
188
+ llm: effectiveLlm,
189
+ logger: state.logger,
190
+ });
191
+ const fresh = { expiresAt: at + state.ttl, snapshot: undefined, inflight };
192
+ state.entries.set(key, fresh);
193
+ try {
194
+ const snapshot = await inflight;
195
+ fresh.snapshot = snapshot;
196
+ fresh.inflight = undefined;
197
+ // 目录错误态不缓存:providersError 意味着 listProviders 瞬时失败或目录为空,
198
+ // 缓存会让瞬时故障自愈延迟一个 TTL;每次重试既保持安全门保守又恢复更快。
199
+ if (snapshot.llm.providersError !== undefined) {
200
+ state.entries.delete(key);
201
+ }
202
+ return snapshot;
203
+ } catch (error) {
204
+ fresh.inflight = undefined;
205
+ state.entries.delete(key);
206
+ throw error;
207
+ }
208
+ }
209
+
210
+ function invalidateSnapshot(state, llm) {
211
+ if (llm !== undefined) state.entries.delete(llm);
212
+ else state.entries.clear();
213
+ }
214
+
215
+ // 工厂返回 { getSnapshot, invalidate, stats }。getSnapshot(llm?) 接受可选 llm 覆盖;
216
+ // 无参时走注入的 getLlm()。stats() 供审计/测试读取 hit/miss 计数。
217
+ export function createCatalogCache({ getLlm, getAgentPresets, getTools, logger, ttlMs, now }) {
218
+ const state = {
219
+ getLlm,
220
+ getAgentPresets,
221
+ getTools,
222
+ logger,
223
+ ttl: typeof ttlMs === 'number' && ttlMs > 0 ? ttlMs : DEFAULT_TTL_MS,
224
+ clock: typeof now === 'function' ? now : () => Date.now(),
225
+ entries: new Map(),
226
+ hits: 0,
227
+ misses: 0,
228
+ audit: makeAudit(logger),
229
+ };
230
+ return {
231
+ getSnapshot: (llm) => resolveSnapshot(state, llm),
232
+ invalidate: (llm) => invalidateSnapshot(state, llm),
233
+ stats: () => ({ hits: state.hits, misses: state.misses }),
234
+ };
235
+ }
@@ -1,6 +1,6 @@
1
- // lib/core/catalog.mjs — settings-page data tables moved verbatim from index.mjs
1
+ // lib/core/catalog.mjs — settings-page data tables moved verbatim from index.mjs
2
2
  // (import-free, no @deepseek-ai dependency — node builtins only, none used
3
- // here). The /options tool-directory builder itself lives in lib/core/http-routes.mjs:
3
+ // here). The /options tool-directory builder itself lives in lib/core/catalog-cache.mjs:
4
4
  // it reads ctx.tools/agentPresets (non-pure), so it is not part of this module.
5
5
 
6
6
  // Tool-name → 中文说明 map, shown beside the raw tool name in the toolFilter
@@ -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
@@ -7,6 +7,52 @@
7
7
 
8
8
  import { stopReasonError, withPartialText, textFrom } from './pure.mjs';
9
9
 
10
+ // 子 Agent 会话的真实计费 token 五段分解:累加 session.events 里每条
11
+ // assistant/message 事件携带的 provider usage(inputTokens / outputTokens /
12
+ // cacheReadTokens / cacheWriteTokens / reasoningTokens)。这是宿主 dsh-llm
13
+ // assembler 产出、dsh-session-stats 同源读取的真实计费口径,与
14
+ // childTotalTokens(tokenMeter 对 surface 的字符密度启发式估算)并存:前者是
15
+ // provider 实际报告的用量,后者是估算值,二者语义不同、互不替代。任何字段缺失
16
+ // 按 0 累加;可选字段(缓存读/写/推理)全程无一条事件报告时省略该键;无任何
17
+ // 事件带 usage 时返回 undefined(调用方不写 meta,向前兼容)。非 object / 非
18
+ // number 一律忽略(fail-soft),使累加绝不阻断派发结算。
19
+ //
20
+ // 只累加 assistant/message:assistant/chunk 的 usage 是同一 step 的早期分片、
21
+ // 随后会被该 step 的 message 事件替换——两者都累加会重复计数(若未来改成
22
+ // 前缀匹配或累加 chunk,此守卫必须同步改并补测试)。
23
+ export function collectChildUsage(session) {
24
+ if (session === undefined || session === null) return undefined;
25
+ const events = session.events;
26
+ if (!Array.isArray(events)) return undefined;
27
+ const totals = { inputTokens: 0, outputTokens: 0 };
28
+ const optionalTotals = { cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 };
29
+ const seenOptional = new Set();
30
+ let anyUsage = false;
31
+ for (const event of events) {
32
+ if (event === null || typeof event !== 'object' || event.type !== 'assistant/message') continue;
33
+ const usage = event.data?.usage;
34
+ if (usage === null || typeof usage !== 'object' || Array.isArray(usage)) continue;
35
+ anyUsage = true;
36
+ for (const key of ['inputTokens', 'outputTokens']) {
37
+ const value = usage[key];
38
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) totals[key] += value;
39
+ }
40
+ for (const key of ['cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens']) {
41
+ const value = usage[key];
42
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
43
+ optionalTotals[key] += value;
44
+ seenOptional.add(key);
45
+ }
46
+ }
47
+ }
48
+ if (!anyUsage) return undefined;
49
+ const out = { inputTokens: totals.inputTokens, outputTokens: totals.outputTokens };
50
+ for (const key of ['cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens']) {
51
+ if (seenOptional.has(key)) out[key] = optionalTotals[key];
52
+ }
53
+ return out;
54
+ }
55
+
10
56
  // Settle one background one-shot run into a job outcome with the same
11
57
  // observability metadata the foreground path reports. Non-completed stop reasons
12
58
  // become failed (aborted => killed, shipped vocabulary) with partial output
@@ -14,19 +60,43 @@ import { stopReasonError, withPartialText, textFrom } from './pure.mjs';
14
60
  // `prune` is the result-recycle pre-clipper: the caller (dispatch
15
61
  // execute) injects a closure that calls the host toolResultPruner.pruneContent
16
62
  // 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) {
63
+ // pruner is available. `t0` is the dispatch execute entry timestamp; the settled
64
+ // outcome carries `elapsedMs = now - t0` and the underlying `stopReason` (the
65
+ // shipped terminal vocabulary). Defaulting t0 to now keeps direct callers (tests)
66
+ // working without threading a timestamp.
67
+ export async function settleStart(start, signal, meta, prune = (blocks) => blocks, measureChild = () => undefined, t0 = Date.now()) {
19
68
  let run;
20
69
  try {
21
70
  run = await start;
22
71
  const result = await run.result;
23
72
  const failure = stopReasonError(result);
24
73
  if (failure !== undefined) {
25
- return { status: result.stopReason === 'aborted' ? 'killed' : 'failed', detail: withPartialText(failure, result.output), ...meta };
74
+ return {
75
+ status: result.stopReason === 'aborted' ? 'killed' : 'failed',
76
+ detail: withPartialText(failure, result.output),
77
+ ...meta,
78
+ elapsedMs: Date.now() - t0,
79
+ stopReason: result.stopReason
80
+ };
26
81
  }
27
- return { status: 'completed', output: textFrom(prune(result.output)), ...meta };
82
+ // completed 结算时测量(非 completed 走上方失败分支,不测);在 dispose
83
+ // 之前读子 session,保证测量拿到完整事件流。measureChild 缺失/失败返回
84
+ // undefined → 省略字段(fail-soft);childUsage 同样只在可累加时携带。
85
+ const childSession = run.localAgent?.session;
86
+ const childTotalTokens = measureChild(childSession);
87
+ const childUsage = collectChildUsage(childSession);
88
+ const metaOut = {
89
+ ...meta,
90
+ ...(childTotalTokens !== undefined ? { childTotalTokens } : {}),
91
+ ...(childUsage !== undefined ? { childUsage } : {}),
92
+ elapsedMs: Date.now() - t0,
93
+ stopReason: 'completed'
94
+ };
95
+ return { status: 'completed', output: textFrom(prune(result.output)), ...metaOut };
28
96
  } catch (error) {
29
- return signal.aborted ? { status: 'killed', ...meta } : { status: 'failed', detail: String(error), ...meta };
97
+ return signal.aborted
98
+ ? { status: 'killed', ...meta, elapsedMs: Date.now() - t0, stopReason: 'aborted' }
99
+ : { status: 'failed', detail: String(error), ...meta, elapsedMs: Date.now() - t0, stopReason: 'error' };
30
100
  } finally {
31
101
  // Release the child handle no matter how the result settled — run.result
32
102
  // rejecting must not leak the subagent (same discipline as the foreground
@@ -0,0 +1,107 @@
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
+ childUsage: {
31
+ type: 'object',
32
+ additionalProperties: false,
33
+ description: '真实计费 token 五段分解(缓存读/写为可选);后台结算经 job 结果携带,dispatch 结果不带。',
34
+ properties: {
35
+ inputTokens: { type: 'number' },
36
+ outputTokens: { type: 'number' },
37
+ cacheReadTokens: { type: 'number' },
38
+ cacheWriteTokens: { type: 'number' },
39
+ reasoningTokens: { type: 'number' }
40
+ }
41
+ },
42
+ elapsedMs: { type: 'number', description: '派发耗时(毫秒,execute 入口到结算时刻);后台结算经 job 结果携带,dispatch 结果不带。' },
43
+ stopReason: { type: 'string', enum: ['completed', 'max-tokens', 'aborted', 'refusal', 'error'], description: '子 Agent 结束原因;后台结算经 job 结果携带,dispatch 结果不带。' },
44
+ ignored: { type: 'array', items: { type: 'string' } }
45
+ }
46
+ },
47
+ {
48
+ type: 'object',
49
+ additionalProperties: false,
50
+ properties: {
51
+ kind: { type: 'string', required: true, const: 'continuable' },
52
+ subagentId: { type: 'string', required: true },
53
+ profile: { type: 'string' },
54
+ preset: { type: 'string' },
55
+ provider: { type: 'string' },
56
+ model: { type: 'string' },
57
+ reasoningEffort: { type: 'string' },
58
+ tokenTier: { type: 'string', enum: ['cheap', 'balanced', 'premium'], description: '成本/深度分层(估算口径,非计费)。' },
59
+ childTotalTokens: { type: 'number', description: '子 Agent 会话 token 估算总量(宿主启发式估算,非计费 usage token);仅前台/后台结算时携带,continuable 实际省略。' },
60
+ childUsage: {
61
+ type: 'object',
62
+ additionalProperties: false,
63
+ description: '真实计费 token 五段分解(缓存读/写为可选);仅前台/后台 completed 结算时携带,continuable 实际省略。',
64
+ properties: {
65
+ inputTokens: { type: 'number' },
66
+ outputTokens: { type: 'number' },
67
+ cacheReadTokens: { type: 'number' },
68
+ cacheWriteTokens: { type: 'number' },
69
+ reasoningTokens: { type: 'number' }
70
+ }
71
+ },
72
+ elapsedMs: { type: 'number', description: '派发耗时(毫秒,execute 入口到结算时刻);continuable 不结算,实际省略。' },
73
+ stopReason: { type: 'string', enum: ['completed', 'max-tokens', 'aborted', 'refusal', 'error'], description: '子 Agent 结束原因;continuable 不结算,实际省略。' },
74
+ ignored: { type: 'array', items: { type: 'string' } }
75
+ }
76
+ },
77
+ {
78
+ type: 'object',
79
+ additionalProperties: false,
80
+ properties: {
81
+ output: { type: 'string', required: true },
82
+ profile: { type: 'string' },
83
+ preset: { type: 'string' },
84
+ provider: { type: 'string' },
85
+ model: { type: 'string' },
86
+ reasoningEffort: { type: 'string' },
87
+ tokenTier: { type: 'string', enum: ['cheap', 'balanced', 'premium'], description: '成本/深度分层(估算口径,非计费)。' },
88
+ childTotalTokens: { type: 'number', description: '子 Agent 会话 token 估算总量(宿主启发式估算,非计费 usage token);仅前台 completed 结算时携带。' },
89
+ childUsage: {
90
+ type: 'object',
91
+ additionalProperties: false,
92
+ description: '真实计费 token 五段分解(缓存读/写为可选);前台 completed 结算时携带。',
93
+ properties: {
94
+ inputTokens: { type: 'number' },
95
+ outputTokens: { type: 'number' },
96
+ cacheReadTokens: { type: 'number' },
97
+ cacheWriteTokens: { type: 'number' },
98
+ reasoningTokens: { type: 'number' }
99
+ }
100
+ },
101
+ elapsedMs: { type: 'number', description: '派发耗时(毫秒,execute 入口到结算时刻);前台 completed 结算时携带。' },
102
+ stopReason: { type: 'string', enum: ['completed', 'max-tokens', 'aborted', 'refusal', 'error'], description: '子 Agent 结束原因;前台 completed 结算时携带。' },
103
+ ignored: { type: 'array', items: { type: 'string' } }
104
+ }
105
+ }
106
+ ]
107
+ };