dsh-subagent-profile 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +77 -40
  2. package/README.zh.md +111 -74
  3. package/docs/screenshots/dispatch-card.png +0 -0
  4. package/docs/screenshots/settings-page1.png +0 -0
  5. package/docs/screenshots/settings-page2.png +0 -0
  6. package/index.mjs +276 -81
  7. package/lib/client.js +3218 -166
  8. package/lib/core/adoption-reminder.mjs +48 -0
  9. package/lib/core/adoption-tracker.mjs +430 -0
  10. package/lib/core/background-ledger.mjs +71 -0
  11. package/lib/core/catalog-cache.mjs +45 -7
  12. package/lib/core/catalog.mjs +6 -6
  13. package/lib/core/cost-evidence.mjs +145 -0
  14. package/lib/core/cost-guard.mjs +71 -44
  15. package/lib/core/decision-trace.mjs +413 -0
  16. package/lib/core/delegation.mjs +111 -50
  17. package/lib/core/dispatch-gates.mjs +153 -0
  18. package/lib/core/dispatch-guard.mjs +156 -0
  19. package/lib/core/dispatch-schema.mjs +103 -14
  20. package/lib/core/dispatch-tool.mjs +220 -204
  21. package/lib/core/draft-gates.mjs +45 -0
  22. package/lib/core/drafts-store.mjs +45 -0
  23. package/lib/core/escape.mjs +130 -0
  24. package/lib/core/evolution-advice.mjs +224 -0
  25. package/lib/core/evolution-ledger.mjs +300 -0
  26. package/lib/core/evolution-summary.mjs +255 -0
  27. package/lib/core/http-routes.mjs +256 -72
  28. package/lib/core/intersection.mjs +6 -9
  29. package/lib/core/presets-sync.mjs +161 -43
  30. package/lib/core/prices.mjs +46 -0
  31. package/lib/core/profile-directory.mjs +139 -0
  32. package/lib/core/profile-provider.mjs +42 -39
  33. package/lib/core/profiles-store.mjs +103 -76
  34. package/lib/core/pure.mjs +110 -66
  35. package/lib/core/reminder-store.mjs +172 -0
  36. package/lib/core/shims.mjs +67 -76
  37. package/lib/core/whitelist.mjs +23 -17
  38. package/package.json +82 -83
  39. package/presets/orchestrator/agent.cordis.yml +59 -87
  40. package/presets/orchestrator/NOTICE +0 -3
@@ -0,0 +1,153 @@
1
+ // lib/core/dispatch-gates.mjs — dispatch 预检段各道闸的应用 + 决策轨迹记录,
2
+ // 从 dispatch-tool.mjs 拆出以守文件行门。闸逻辑与 fail-loud 文案逐字不变;
3
+ // 这里只「应用闸并就地记录 decisionTrace 的 pass/fail」,不改变任何派发行为。
4
+ //
5
+ // 依赖边界(无 @deepseek-ai 依赖):只 import lib/core 纯模块——cost-guard
6
+ // (assertCostGuard)、decision-trace(recordGate / hardLimitChecks)、pure
7
+ // (MAX_TOKENS / MAX_DEPTH)。
8
+
9
+ import { assertCostGuard } from './cost-guard.mjs';
10
+ import { recordGate, hardLimitChecks } from './decision-trace.mjs';
11
+ import { MAX_TOKENS, MAX_DEPTH, computeContinuableAllow } from './pure.mjs';
12
+
13
+ // 预检:显式具体 preset 必须在运行时推导的白名单内;与父 composed 预设相同的
14
+ // preset 改写为 'inherit'(不换装)。纯函数化:whitelist / parentComposed 由调用方
15
+ // 预检段 resolve 一次后注入(避免重复 list()),throw 语义与文案逐字不变。
16
+ function assertPresetWhitelist(merged, whitelist, parentComposed) {
17
+ if (typeof merged.preset !== 'string' || merged.preset === 'inherit') return;
18
+ if (!whitelist.has(merged.preset)) {
19
+ throw new Error(`dispatch: 预设 "${merged.preset}" 不在 system-trust 白名单(可在设置页改用受信任预设)`);
20
+ }
21
+ if (merged.preset === parentComposed) merged.preset = 'inherit';
22
+ }
23
+
24
+ // 白名单闸:预检段只 resolve 一次名单,结果同时喂本闸与 trace。'inherit' 改写
25
+ // 也如实记入 resolvedPreset;fail 时先记 fail 闸再 rethrow(文案不变)。
26
+ export function applyWhitelistGate(trace, merged, whitelist, parentComposed) {
27
+ const input = { requestedPreset: typeof merged.preset === 'string' ? merged.preset : undefined, systemTrustCandidates: [...whitelist] };
28
+ try {
29
+ assertPresetWhitelist(merged, whitelist, parentComposed);
30
+ recordGate(trace, { name: 'whitelist', input, output: { allowed: true, resolvedPreset: merged.preset ?? 'inherit' }, verdict: 'pass' });
31
+ } catch (error) {
32
+ recordGate(trace, { name: 'whitelist', input, output: { allowed: false, resolvedPreset: input.requestedPreset }, verdict: 'fail', reason: error.message });
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ // 成本闸:硬上限(maxTokens/maxDepth)在调 assertCostGuard 前自行预判(不重复
38
+ // throw,真 throw 仍在 assertHardLimits);llm 能力面各检查项经 assertCostGuard
39
+ // 的 onCheck 回调逐项回填。预判读 merged(profile 合并后的实际判定值)而非原始
40
+ // args——保证轨迹 check 与真闸判定口径一致(profile 携带超限值时两者必须同 fail)。
41
+ // fail 时先记 fail 闸再 rethrow(文案不变)。
42
+ export async function applyCostGuardGate(trace, merged, parent, deps) {
43
+ const allowFailOpen = deps.store.getAllowFailOpen();
44
+ const input = { provider: merged.provider, model: merged.model, reasoningEffort: merged.reasoningEffort, maxTokens: merged.maxTokens, maxDepth: merged.maxDepth, allowFailOpen };
45
+ // 决策输入快照补充:请求 model 的 reasoning-effort 支持档位(catalog 快照,
46
+ // 有缓存;拿不到 fail-soft 省略)——台账可见「该模型当时可选哪些档位」。
47
+ if (typeof merged.model === 'string' && merged.model !== '') {
48
+ try {
49
+ const snapshot = await deps.catalog.getSnapshot(parent.ctx.get('llm'));
50
+ const efforts = snapshot?.efforts?.[merged.model];
51
+ if (Array.isArray(efforts) && efforts.length > 0) {
52
+ input.modelEfforts = efforts.map((effort) => (effort && typeof effort.id === 'string' ? effort.id : String(effort))).slice(0, 6);
53
+ }
54
+ } catch { /* fail-soft:快照不可用不影响闸判定 */ }
55
+ }
56
+ const checks = hardLimitChecks(merged, MAX_TOKENS, MAX_DEPTH);
57
+ try {
58
+ await assertCostGuard(parent, merged, allowFailOpen, deps.logger, deps.catalog, (entry) => checks.push(entry));
59
+ recordGate(trace, { name: 'cost', input, output: { checks }, verdict: 'pass' });
60
+ } catch (error) {
61
+ recordGate(trace, { name: 'cost', input, output: { checks }, verdict: 'fail', reason: error.message });
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ // 禁用门(continuable 专属显式检查):provider start 的 !enabled 只拦 start、
67
+ // 不拦 startContinuable;syncTool 注销工具对 continuable 是间接门,故在此显式
68
+ // fail-loud。检查位于全部闸之前——与「禁用时不得静默派生子树」的既有语义一致,
69
+ // 且保证交集闸不会在禁用态先于本门抛其它文案。
70
+ export function assertContinuableEnabled(deps, args) {
71
+ if (args.continuable === true && !deps.getEnabled()) {
72
+ throw new Error('dispatch: 插件已禁用(设置 → 子 Agent 方案 重新启用)');
73
+ }
74
+ }
75
+
76
+ // 交集闸(三分支统一在 approval 之前记录,闸序一致):前/后台真交集由宿主在
77
+ // 子会话启动时计算(execute 返回之后),记 deferred;continuable 在此预加工
78
+ // 闭集 allow(空集 fail-loud),同一结果同时用于轨迹记录与 request 组装。
79
+ // 返回 continuable 的 effectiveAllow(非 continuable 返回 undefined)。
80
+ export function applyIntersectionGate(trace, mode, intersectionInput, merged, parentToolNames) {
81
+ if (mode !== 'continuable') {
82
+ recordGate(trace, { name: 'intersection', input: intersectionInput, output: { mode: 'deferred' }, verdict: 'pass', reason: '子工具交集由宿主在子会话启动时计算(restrictChildTools),结果体现在结算 stopReason/error' });
83
+ return undefined;
84
+ }
85
+ try {
86
+ const effectiveAllow = computeContinuableAllow(parentToolNames, merged.toolFilter);
87
+ // 工具集子节数据(continuable 可计算闭集与移除清单)。名单类数组超 8 项由
88
+ // recordGate 的 truncateLists 自动截断为 { values, truncated }(台账展示时认标记)。
89
+ const removedRaw = parentToolNames
90
+ .filter((name) => !effectiveAllow.includes(name))
91
+ .map((name) => ({ name, reason: name === 'run_code' ? '安全策略:子代理不执行代码' : '不在白名单' }));
92
+ const removedTools = removedRaw.length > 8 ? { values: removedRaw.slice(0, 8), truncated: true } : removedRaw;
93
+ recordGate(trace, { name: 'intersection', input: intersectionInput, output: { effectiveAllowCount: effectiveAllow.length, effectiveAllowNames: effectiveAllow, removedTools }, verdict: 'pass' });
94
+ return effectiveAllow;
95
+ } catch (error) {
96
+ recordGate(trace, { name: 'intersection', input: intersectionInput, output: {}, verdict: 'fail', reason: error.message });
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ // 总预算闸(并发上限 + 每父累计 token):前/后台在派发入口占并发额度(continuable
102
+ // 不占——持久会话启动、非在途 one-shot)。拒绝时记一道 budget fail 闸 + warn + 抛
103
+ // 可行动中文错误(只拦新增派发,数值内零影响);无父 sessionId 时 acquire 内部跳过
104
+ // 守卫。返回 { parentSessionId, finish }:finish(key, childTotalTokens) 是结算共用
105
+ // 钩子(untrack + recordTokens + release),前/后台 settle 时恰好调用一次。
106
+ export function applyBudgetGate(deps, parent, args, trace) {
107
+ if (args.continuable === true) return { parentSessionId: undefined, finish: () => {}, release: () => {} };
108
+ const parentSessionId = parent.session?.header?.id;
109
+ const budget = deps.guard.snapshot(parentSessionId);
110
+ const acquired = deps.guard.acquire(parentSessionId);
111
+ if (!acquired.ok) {
112
+ const message = acquired.reason === 'concurrency'
113
+ ? `dispatch: 并发派发数已达上限 ${acquired.limit},请等待在途任务完成`
114
+ : `dispatch: 本会话累计派发 token 已达上限 ${acquired.limit},请等待在途任务完成或开启新会话`;
115
+ recordGate(trace, {
116
+ name: 'budget',
117
+ input: { parentSessionId, current: budget, reason: acquired.reason, limit: acquired.limit },
118
+ output: { allowed: false },
119
+ verdict: 'fail',
120
+ reason: message,
121
+ });
122
+ deps.logger.warn(`[dsh-subagent-profile] ${message}`);
123
+ throw new Error(message);
124
+ }
125
+ // 通过也记闸(与 whitelist/cost/intersection 同口径:pass/fail 均记,台账时间线可
126
+ // 见预算检查项;无父 sessionId 时 acquire 跳过守卫,同样记 pass 并注明 skipped)。
127
+ recordGate(trace, {
128
+ name: 'budget',
129
+ input: { parentSessionId, current: budget },
130
+ output: { allowed: true, skipped: acquired.skipped === true },
131
+ verdict: 'pass',
132
+ });
133
+ return {
134
+ parentSessionId,
135
+ finish: (key, childTotalTokens) => {
136
+ deps.guard.untrack(key);
137
+ if (childTotalTokens !== undefined) deps.guard.recordTokens(parentSessionId, childTotalTokens);
138
+ acquired.release();
139
+ },
140
+ // 错误路径兜底:分支 throw 时 runDispatch 的 catch 幂等释放并发槽(不记账、不
141
+ // untrack——untrack 由正常结算路径负责;acquire 的 release 本身幂等)。
142
+ release: () => acquired.release(),
143
+ };
144
+ }
145
+
146
+ // 读取父工具名(失败软回退为空数组)。真实宿主 ctx.tools.schemas 恒存在;测试
147
+ // fake 父 Agent 可能缺 tools,故此处守卫,避免预检段破坏前/后台路径。
148
+ export function readParentToolNames(parent) {
149
+ const tools = parent.ctx?.tools;
150
+ if (tools === undefined || typeof tools.schemas !== 'function') return [];
151
+ const schemas = tools.schemas(parent);
152
+ return Array.isArray(schemas) ? schemas.map((schema) => schema.name) : [];
153
+ }
@@ -0,0 +1,156 @@
1
+ // lib/core/dispatch-guard.mjs — 派发总预算守卫(进程级并发上限 + 每父累计 token
2
+ // 记账)+ 在途登记表(级联取消)。import-free(仅 node 内置),工厂式参数注入、
3
+ // 无模块级单例:每次 apply 各持一份独立状态。
4
+ //
5
+ // 三职责:
6
+ // 1. 并发上限:同一父会话的在途 one-shot 派发数封顶(acquire/release,release 幂等,
7
+ // 计数归零删键);
8
+ // 2. 每父累计 token:子结算时的 childTotalTokens 独立进程级记账(不复用 profiles
9
+ // Map,避免与 registry 状态耦合),累计达到上限后该父会话后续派发被拒;
10
+ // 3. 在途登记:track/untrack/cancelAll(前台 run.dispose、后台 jobs.kill、
11
+ // continuable 宿主 interrupt),禁用/卸载时级联取消在途派发。
12
+ //
13
+ // 边界(后来开发者须知):
14
+ // * 并发上限 8 与 token 上限 200_000 为建议值,待实测后校准。
15
+ // * 守卫只拦「新增派发」(acquire 拒绝),不拦现有任何合法路径(数值内零影响)。
16
+ // * 父会话结束的记账释放不依赖宿主生命周期事件(插件侧无法可靠观测父会话结束):
17
+ // 释放依赖禁用/卸载清账(reset)+ 上限轮换兜底,见 index.mjs 的 syncTool 禁用
18
+ // 分支与 dispose 钩子。
19
+ // * continuable 子会话只可「中断当前 turn」(宿主 interrupt 为 fire-and-return
20
+ // 中断、非终止,子会话仍驻留),详见 dispatch-tool.mjs 的 in-flight 登记注释。
21
+ // * continuable 的 in-flight 登记无 untrack 点(持久子会话无结算时刻):登记只服务
22
+ // 禁用/卸载时的 interrupt 级联,条目随 reset 或进程生命周期释放——这是有意的
23
+ // fire-and-return 口径,勿按 one-shot 的结算-释放模式补 untrack。
24
+
25
+ function emptyKey(key) {
26
+ return key === undefined || key === null || key === '';
27
+ }
28
+
29
+ // acquire 主体:并发 +1;token 累计已达上限或并发已满时拒绝(不 +1)。无父
30
+ // sessionId 时跳过守卫(返回 ok + no-op release,数值内零影响)。
31
+ function acquireSlot(concurrency, tokenTotals, maxConcurrent, maxParentTokens, parentSessionId) {
32
+ if (emptyKey(parentSessionId)) {
33
+ return { ok: true, release: () => {}, skipped: true };
34
+ }
35
+ const current = concurrency.get(parentSessionId) ?? 0;
36
+ if (current >= maxConcurrent) {
37
+ return { ok: false, reason: 'concurrency', limit: maxConcurrent };
38
+ }
39
+ const tokens = tokenTotals.get(parentSessionId) ?? 0;
40
+ if (tokens >= maxParentTokens) {
41
+ return { ok: false, reason: 'tokens', limit: maxParentTokens };
42
+ }
43
+ concurrency.set(parentSessionId, current + 1);
44
+ let released = false;
45
+ return {
46
+ ok: true,
47
+ release: () => {
48
+ if (released) return;
49
+ released = true;
50
+ const next = (concurrency.get(parentSessionId) ?? 1) - 1;
51
+ if (next <= 0) concurrency.delete(parentSessionId);
52
+ else concurrency.set(parentSessionId, next);
53
+ },
54
+ };
55
+ }
56
+
57
+ // recordTokens 主体:结算时把子 childTotalTokens 记入父会话累计,返回累计是否已达
58
+ // 上限(>= 上限)。空 id / 已 dispose 的父 / 非有限非负数跳过并返回 false(fail-soft,
59
+ // 绝不阻断结算)。disposed 守卫是 H2 修复:父会话结束(resetParent)后后台任务才
60
+ // 结算时,若仍 set 会把 tokenTotals 键重新插回,导致 Map 随会话数无界增长。
61
+ function addTokens(tokenTotals, disposed, maxParentTokens, parentSessionId, childTotalTokens) {
62
+ if (emptyKey(parentSessionId) || disposed.has(parentSessionId)) return false;
63
+ if (typeof childTotalTokens !== 'number' || !Number.isFinite(childTotalTokens) || childTotalTokens < 0) return false;
64
+ const next = (tokenTotals.get(parentSessionId) ?? 0) + childTotalTokens;
65
+ tokenTotals.set(parentSessionId, next);
66
+ return next >= maxParentTokens;
67
+ }
68
+
69
+ // track/untrack 主体:登记一个在途派发(key=前台 run.id / 后台 jobId / continuable
70
+ // childId)。cancel 可能为 undefined(continuable 宿主无 interrupt 时注明边界)。
71
+ function registerInflight(inflight, key, cancel) {
72
+ if (emptyKey(key)) return;
73
+ inflight.set(key, typeof cancel === 'function' ? cancel : undefined);
74
+ }
75
+
76
+ function dropInflight(inflight, key) {
77
+ if (emptyKey(key)) return;
78
+ inflight.delete(key);
79
+ }
80
+
81
+ // cancelAll 主体:级联取消全部在途派发(禁用/卸载)。每项 cancel 恰好调用一次并清空
82
+ // 登记表;cancel 缺失或抛错时 warn(fail-soft,绝不因单项失败阻断整体取消)。
83
+ function cancelInflight(inflight, warn, reason) {
84
+ const entries = [...inflight.entries()];
85
+ inflight.clear();
86
+ for (const [key, cancel] of entries) {
87
+ if (typeof cancel !== 'function') continue;
88
+ try {
89
+ cancel(reason);
90
+ } catch (error) {
91
+ warn(`dispatch: 取消在途派发 ${key} 失败(${error instanceof Error ? error.message : String(error)})`);
92
+ }
93
+ }
94
+ }
95
+
96
+ // 按父释放(见 index.mjs 的 agent/disposed 挂钩):父会话逻辑结束(Agent 注册
97
+ // fiber 卸载)时删该父的并发计数与 token 累计键。在途登记表键为子标识
98
+ // (run.id/jobId/childId)不含父 id,不在此清——在途子随自身结算或禁用/卸载级联
99
+ // 取消自然收敛。
100
+ export function createDispatchGuard({ maxConcurrent = 8, maxParentTokens = 200000, warn = () => {} } = {}) {
101
+ const concurrency = new Map();
102
+ const tokenTotals = new Map();
103
+ const inflight = new Map();
104
+ // H2:已 dispose 的父会话 id 集合。resetParent(agent/disposed 挂钩)登记,防止
105
+ // 后台任务在父结束后结算时经 recordTokens 把 tokenTotals 键重插回来(无界增长)。
106
+ // 该集合按会话数线性增长(每会话一条小字符串),远小于原泄漏的每次结算一插。
107
+ const disposed = new Set();
108
+ return {
109
+ acquire: (parentSessionId) => acquireSlot(concurrency, tokenTotals, maxConcurrent, maxParentTokens, parentSessionId),
110
+ recordTokens: (parentSessionId, childTotalTokens) => addTokens(tokenTotals, disposed, maxParentTokens, parentSessionId, childTotalTokens),
111
+ track: (key, cancel) => registerInflight(inflight, key, cancel),
112
+ untrack: (key) => dropInflight(inflight, key),
113
+ cancelAll: (reason = 'dispatch: 插件已禁用或卸载,取消在途派发') => cancelInflight(inflight, warn, reason),
114
+ resetParent: (parentSessionId) => {
115
+ if (emptyKey(parentSessionId)) return;
116
+ disposed.add(parentSessionId);
117
+ concurrency.delete(parentSessionId);
118
+ tokenTotals.delete(parentSessionId);
119
+ },
120
+ snapshot: (parentSessionId) => ({
121
+ concurrency: typeof parentSessionId === 'string' && parentSessionId !== '' ? (concurrency.get(parentSessionId) ?? 0) : 0,
122
+ tokens: typeof parentSessionId === 'string' && parentSessionId !== '' ? (tokenTotals.get(parentSessionId) ?? 0) : 0,
123
+ maxConcurrent,
124
+ maxParentTokens,
125
+ }),
126
+ reset: () => {
127
+ concurrency.clear();
128
+ tokenTotals.clear();
129
+ inflight.clear();
130
+ disposed.clear();
131
+ },
132
+ };
133
+ }
134
+
135
+ // 在途登记 helper:前台 one-shot key=run.id,cancel=run.dispose(禁用/卸载级联取消)。
136
+ export function trackForegroundInflight(guard, run) {
137
+ guard.track(run.id, () => run.dispose().catch(() => {}));
138
+ }
139
+
140
+ // 在途登记 helper:后台 job key=jobId,cancel=jobs.kill(owner=parent 作 caller 授权;
141
+ // 未知 job 抛错、已结算 job 返回 already-finished——两者均由 guard.cancelAll 的
142
+ // fail-soft 兜底,级联取消不因单项失败中断)。
143
+ export function trackBackgroundInflight(guard, jobs, jobId, parent) {
144
+ guard.track(jobId, (reason) => {
145
+ if (typeof jobs.kill === 'function') jobs.kill(jobId, parent, reason);
146
+ });
147
+ }
148
+
149
+ // 在途登记 helper:continuable 子会话 key=childId,cancel=宿主 interrupt(fire-and-
150
+ // return 中断当前 turn,非终止——子会话仍驻留,后续 send_message 可唤醒;插件侧无法
151
+ // 终止持久子会话)。无 interrupt 能力时不登记取消(cancelAll 跳过)。
152
+ export function trackContinuableInflight(guard, subagents, childId, parentSessionId) {
153
+ if (typeof subagents.interrupt === 'function') {
154
+ guard.track(childId, () => subagents.interrupt(childId, { kind: 'user', parentSessionId }));
155
+ }
156
+ }
@@ -1,18 +1,70 @@
1
- // lib/core/dispatch-schema.mjs — dispatch 工具的 output 结果 schema(closed oneOf),
2
- // dispatch-tool.mjs 拆出的纯数据声明(拆出时同步新增 elapsedMs/stopReason 共享
3
- // 字段)。三个分支(background / continuable / foreground)共享同一套元数据键集
1
+ // lib/core/dispatch-schema.mjs — dispatch 工具的纯数据声明(零依赖):输入参数
2
+ // schema(DISPATCH_PARAMETERS)与 output 结果 schema(DISPATCH_OUTPUT_SCHEMA,
3
+ // closed oneOf),均从 dispatch-tool.mjs 拆出以守文件行门。
4
+ //
5
+ // 三个结果分支(background / continuable / foreground)共享同一套元数据键集
4
6
  // (判别键 kind/jobId/subagentId/output 除外),由 pure.mjs 的
5
7
  // assertResultSchemaConsistency 在 apply 时锁定——任一分支漏补共享字段即 throw。
6
- // 纯数据、零依赖。
8
+ // decisionTrace 是共享键:三分支都必须携带,但只锁「存在性」(深层结构由
9
+ // lib/core/decision-trace.mjs 的纯函数与单测保证),它经 output.presentationMeta
10
+ // 投影进会话块 meta 供客户端台账展示,不进模型可见面。
11
+
12
+ // 输入参数 schema(从 dispatch-tool.mjs 逐字移入;defineTool 只读不改)。
13
+ export const DISPATCH_PARAMETERS = {
14
+ // 参数轴归属与约束(能力轴 / 预算轴 / 防护 / 模式):
15
+ // 能力轴 preset→whitelist 闸;model/provider→llm 目录核验;persona→仅 shadow
16
+ // order 0(不进任何闸);toolFilter→工具交集闸(只减不增)。
17
+ // 预算轴 reasoningEffort→resolveCallConfig;maxTokens→MAX_TOKENS 硬上限;
18
+ // tokenTier→档位元数据(估算口径)。
19
+ // 防护 maxDepth→MAX_DEPTH 硬上限。
20
+ // 模式字段(run_in_background/continuable/envelope)是派发方式选择,不入配置覆盖。
21
+ // 新增字段时须按上列轴归类,并同步键集测试(percall-spec)的 WHITELIST_KEYS。
22
+ // per-call 规范化清单闭合:宿主 dsh-tools 的 DSL 参数解析**默认拒绝
23
+ // 未知键**(2026-08 实测:参数级 additionalProperties 只接受 value schema 对象、
24
+ // 不接受 boolean,故无法显式声明;键集闭合由 percall-spec 测试「键集恰为 14 键」
25
+ // 锁定,mergeProfileArgs 只透传白名单 9 键,未知字段到不了子请求)。
26
+ profile: { type: 'string', description: '容器选择(profile 注册表 id:内建 swap-standard/researcher,或设置页自建);省略则该 subagent 原样继承父预设与工具集,不加配置覆盖。' },
27
+ preset: { type: 'string', description: '能力轴·目标预设强制覆盖;必须是本运行时 system-trust 预设(白名单内的受信任预设),否则 fail-loud 拒绝;与父 composed 预设相同时改写为 inherit(不换装)。' },
28
+ model: { type: 'string', description: '能力轴·子 Agent 显式模型覆盖,per-call 优先于 profile;必须在该 provider 目录中已发布,否则 fail-loud 拒绝(无发现能力的空目录 adapter 无法校验时按兼容模式开关决定)。' },
29
+ provider: { type: 'string', description: '能力轴·子 Agent 显式 provider 覆盖,per-call 优先于 profile;必须已注册在其 provider 目录中,否则 fail-loud 拒绝。' },
30
+ reasoningEffort: { type: 'string', description: '预算轴·注入每个子请求的推理档位覆盖,per-call 优先于 profile;经 resolveCallConfig 校验该档受显式 provider/model 支持,否则 fail-loud 拒绝;continuable 模式忽略本字段(子会话不支持)。' },
31
+ tokenTier: { type: 'string', enum: ['cheap', 'balanced', 'premium'], description: '预算轴·成本/深度档位(估算口径,非计费):cheap 省 token、balanced 均衡、premium 高成本。覆盖 profile 的 tokenTier;缺省 balanced。' },
32
+ persona: { type: 'string', description: '能力轴·覆盖子 Agent 的部署 persona 段(只 shadow order 0 的 persona 影子段,不进 descriptor);per-call 优先于 profile。' },
33
+ toolFilter: {
34
+ type: 'object',
35
+ // 能力轴·工具白名单交集:只减不增,不能给子 Agent 添加任何父集没有的工具。
36
+ // DSL 对象参数默认拒绝未知键,toolFilter 必须闭合其 schema,
37
+ // 否则 defineTool 在 apply 时 throw、插件加载失败。
38
+ additionalProperties: false,
39
+ description: '能力轴·给子工具集额外做白名单交集(与父工具集求交,只减不增):不能给子 Agent 添加任何父集没有的工具。',
40
+ properties: {
41
+ allow: { type: 'array', items: { type: 'string' }, description: '提供时,仅保留这些工具名。' },
42
+ deny: { type: 'array', items: { type: 'string' }, description: '这些工具名总是被移除。' }
43
+ }
44
+ },
45
+ maxTokens: { type: 'number', description: '预算轴·子 Agent 显式 token 预算覆盖,per-call 优先于 profile;超过部署硬上限(MAX_TOKENS)fail-loud 拒绝。' },
46
+ maxDepth: { type: 'number', description: '防护·子 Agent 显式绝对递归深度上限覆盖,per-call 优先于 profile;超过部署硬上限(MAX_DEPTH)fail-loud 拒绝。' },
47
+ run_in_background: { type: 'boolean', description: '模式选择·异步 one-shot:走 jobs.start 包 start(),返回 jobId;仍单轮即弃,非 continuable。不入配置覆盖。' },
48
+ continuable: { type: 'boolean', description: '模式选择·启动持久可 continuation 的 subagent 而非 one-shot:立即返回 subagentId 并经 send_message 工具在后续轮次延续对话;默认 false。不入配置覆盖;与 run_in_background 同真时本字段优先,忽略 preset 换用与 reasoningEffort。' },
49
+ // 信封模式 = opt-in(仅「中段即交付物」的任务用):true 时把信封骨架追加进
50
+ // 子 persona(systemPrompt 影子段,模型可见),子 Agent 按结构化信封汇报;
51
+ // 默认 false 走结果剪枝回收。
52
+ envelope: { type: 'boolean', description: '模式选择·true 时子 Agent 按结构化信封汇报(简短结论 + 结构分项 + 关键发现落点),适合中段即交付物的长任务;默认 false 走剪枝回收。不入配置覆盖。' },
53
+ prompt: { type: 'string', required: true, description: '任务文本·该 subagent 完成的自包含任务(它看不到当前对话)。' }
54
+ };
55
+
56
+ // 已知 per-call 参数键集(单一事实来源):decisionTrace 的 requested 快照据此把
57
+ // 未知字段记录为 unknown_keys(fail-visible,不静默吞)。宿主 dsh-tools DSL 对
58
+ // 参数默认拒绝未知键,此处是插件侧决策轨迹的可观测兜底。
59
+ export const DISPATCH_PARAMETER_KEYS = Object.freeze(Object.keys(DISPATCH_PARAMETERS));
7
60
 
8
61
  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 分支 忘补该字段.
62
+ // 每个结果上的可观测元数据。oneOf 覆盖 background 变体(kind/jobId)与
63
+ // foreground 变体(output),两者闭合且都携带生效的委派值。`ignored` 必须
64
+ // 出现在全部三个分支(连同共享的 `preset` / `provider` / `model` /
65
+ // `reasoningEffort` / `profile`),保持闭合 oneOf 一致——
66
+ // apply() assertResultSchemaConsistency(dispatchTool.output.schema)
67
+ // 任一分支漏补该字段即 throw。
16
68
  oneOf: [
17
69
  {
18
70
  type: 'object',
@@ -41,7 +93,8 @@ export const DISPATCH_OUTPUT_SCHEMA = {
41
93
  },
42
94
  elapsedMs: { type: 'number', description: '派发耗时(毫秒,execute 入口到结算时刻);后台结算经 job 结果携带,dispatch 结果不带。' },
43
95
  stopReason: { type: 'string', enum: ['completed', 'max-tokens', 'aborted', 'refusal', 'error'], description: '子 Agent 结束原因;后台结算经 job 结果携带,dispatch 结果不带。' },
44
- ignored: { type: 'array', items: { type: 'string' } }
96
+ ignored: { type: 'array', items: { type: 'string' } },
97
+ decisionTrace: { type: 'object', additionalProperties: true, description: '派发决策轨迹(供客户端台账展示,不进模型可见面)' }
45
98
  }
46
99
  },
47
100
  {
@@ -71,7 +124,8 @@ export const DISPATCH_OUTPUT_SCHEMA = {
71
124
  },
72
125
  elapsedMs: { type: 'number', description: '派发耗时(毫秒,execute 入口到结算时刻);continuable 不结算,实际省略。' },
73
126
  stopReason: { type: 'string', enum: ['completed', 'max-tokens', 'aborted', 'refusal', 'error'], description: '子 Agent 结束原因;continuable 不结算,实际省略。' },
74
- ignored: { type: 'array', items: { type: 'string' } }
127
+ ignored: { type: 'array', items: { type: 'string' } },
128
+ decisionTrace: { type: 'object', additionalProperties: true, description: '派发决策轨迹(供客户端台账展示,不进模型可见面)' }
75
129
  }
76
130
  },
77
131
  {
@@ -100,8 +154,43 @@ export const DISPATCH_OUTPUT_SCHEMA = {
100
154
  },
101
155
  elapsedMs: { type: 'number', description: '派发耗时(毫秒,execute 入口到结算时刻);前台 completed 结算时携带。' },
102
156
  stopReason: { type: 'string', enum: ['completed', 'max-tokens', 'aborted', 'refusal', 'error'], description: '子 Agent 结束原因;前台 completed 结算时携带。' },
103
- ignored: { type: 'array', items: { type: 'string' } }
157
+ ignored: { type: 'array', items: { type: 'string' } },
158
+ decisionTrace: { type: 'object', additionalProperties: true, description: '派发决策轨迹(供客户端台账展示,不进模型可见面)' }
104
159
  }
105
160
  }
106
161
  ]
107
162
  };
163
+
164
+ // 结果 render 行(从 dispatch-tool.mjs 逐字移入以守文件行门):continuable 丢弃
165
+ // preset 换用与 reasoningEffort —— 渲染行把 `ignored` 列表回显出来
166
+ // (`reasoningEffort=<值>(ignored)`,再加 ignored 项明细),让模型「看见」被丢弃项;
167
+ // background/foreground 无忽略项时该后缀为空。
168
+ export const DISPATCH_RENDER = (_args, value) => {
169
+ const ignored = value.ignored !== undefined && value.ignored.length > 0
170
+ ? `(ignored: ${value.ignored.join(', ')})`
171
+ : '';
172
+ const tier = typeof value.tokenTier === 'string' && value.tokenTier !== ''
173
+ ? ` · tokenTier=${value.tokenTier}`
174
+ : '';
175
+ const tokens = typeof value.childTotalTokens === 'number'
176
+ ? ` · childTotalTokens=${value.childTotalTokens}`
177
+ : '';
178
+ const elapsed = typeof value.elapsedMs === 'number'
179
+ ? ` · elapsedMs=${value.elapsedMs}`
180
+ : '';
181
+ const stopReason = typeof value.stopReason === 'string' && value.stopReason !== ''
182
+ ? ` · stopReason=${value.stopReason}`
183
+ : '';
184
+ // childUsage 是嵌套对象,render 行是扁平的 key=value,故 JSON 序列化为单段
185
+ // 供 client 侧 parseDispatchText 解析回对象(五段分解)。仅前台 completed 结算
186
+ // 携带;后台结算经 job 结果携带,continuable 不携带,故该段只在 foreground 行。
187
+ const usage = value.childUsage !== undefined && value.childUsage !== null && typeof value.childUsage === 'object'
188
+ ? ` · childUsage=${JSON.stringify(value.childUsage)}`
189
+ : '';
190
+ const text = value.kind === 'background'
191
+ ? `[dispatch] background job ${value.jobId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}${tier}`
192
+ : value.kind === 'continuable'
193
+ ? `[dispatch] started subagent ${value.subagentId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}${tier}`
194
+ : `[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}`;
195
+ return [{ type: 'text', text }];
196
+ };