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.
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/index.mjs +23 -6
- package/lib/client.js +113 -18
- package/lib/core/catalog-cache.mjs +235 -0
- package/lib/core/catalog.mjs +2 -2
- package/lib/core/cost-guard.mjs +45 -31
- package/lib/core/delegation.mjs +26 -6
- package/lib/core/dispatch-schema.mjs +71 -0
- package/lib/core/dispatch-tool.mjs +99 -76
- package/lib/core/http-routes.mjs +48 -127
- package/lib/core/profile-provider.mjs +6 -5
- package/lib/core/profiles-store.mjs +3 -3
- package/lib/core/pure.mjs +33 -2
- package/lib/core/shims.mjs +87 -1
- package/package.json +3 -2
package/lib/core/cost-guard.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
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
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
45
|
+
// ④ provider 注册校验(目录非空时才能判定「不在目录」)。providers 来自 catalog 快照。
|
|
46
|
+
function assertProviderRegistered(dir, profile) {
|
|
45
47
|
if (typeof profile.provider !== 'string' || profile.provider.length === 0) return;
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
93
|
-
if (llm === undefined
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/core/delegation.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
55
|
-
//
|
|
56
|
-
envelope: { type: 'boolean', description: '
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 提示)。',
|