dsh-subagent-profile 0.2.0 → 0.3.0
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 +20 -8
- package/README.zh.md +20 -8
- package/index.mjs +160 -1386
- package/lib/client.js +776 -695
- package/lib/core/catalog.mjs +83 -0
- package/lib/core/cost-guard.mjs +108 -0
- package/lib/core/delegation.mjs +61 -0
- package/lib/core/dispatch-tool.mjs +371 -0
- package/lib/core/http-routes.mjs +328 -0
- package/lib/core/intersection.mjs +27 -0
- package/lib/core/presets-sync.mjs +136 -0
- package/lib/core/profile-provider.mjs +241 -0
- package/lib/core/profiles-store.mjs +226 -0
- package/lib/{pure.mjs → core/pure.mjs} +114 -109
- package/lib/{shims.mjs → core/shims.mjs} +9 -9
- package/lib/core/whitelist.mjs +22 -0
- package/package.json +9 -5
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// lib/core/catalog.mjs — settings-page data tables moved verbatim from index.mjs
|
|
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:
|
|
4
|
+
// it reads ctx.tools/agentPresets (non-pure), so it is not part of this module.
|
|
5
|
+
|
|
6
|
+
// Tool-name → 中文说明 map, shown beside the raw tool name in the toolFilter
|
|
7
|
+
// picker. Tools absent here fall back to their raw name.
|
|
8
|
+
export const TOOL_ZH = {
|
|
9
|
+
'bash': '终端命令',
|
|
10
|
+
'pwsh': 'PowerShell 命令',
|
|
11
|
+
'read': '读取文件',
|
|
12
|
+
'write': '写入文件',
|
|
13
|
+
'edit': '编辑文件',
|
|
14
|
+
'grep': '搜索文件内容',
|
|
15
|
+
'glob': '查找文件',
|
|
16
|
+
'web_search': '网页搜索',
|
|
17
|
+
'browser_navigate': '浏览器打开网址',
|
|
18
|
+
'browser_snapshot': '浏览器页面快照',
|
|
19
|
+
'browser_click': '浏览器点击',
|
|
20
|
+
'browser_type': '浏览器输入',
|
|
21
|
+
'browser_scroll': '浏览器滚动',
|
|
22
|
+
'browser_back': '浏览器后退',
|
|
23
|
+
'browser_forward': '浏览器前进',
|
|
24
|
+
'browser_press': '浏览器按键',
|
|
25
|
+
'browser_reload': '浏览器刷新',
|
|
26
|
+
'browser_wait': '浏览器等待',
|
|
27
|
+
'browser_get_text': '读取页面文本',
|
|
28
|
+
'dispatch': '派发子 Agent',
|
|
29
|
+
'subagent': '派生子 Agent',
|
|
30
|
+
'subagent_fork': '派生子 Agent(继承上下文)',
|
|
31
|
+
'send_message': '给子 Agent 发消息',
|
|
32
|
+
'interrupt_agent': '中断子 Agent',
|
|
33
|
+
'list_agents': '列出子 Agent',
|
|
34
|
+
'todo_write': '任务清单',
|
|
35
|
+
'create_goal': '创建目标',
|
|
36
|
+
'get_goal': '查看目标',
|
|
37
|
+
'update_goal': '更新目标',
|
|
38
|
+
'workflow': '编排多 Agent 工作流',
|
|
39
|
+
'ralph': 'Ralph 迭代',
|
|
40
|
+
'ask_user_question': '询问用户',
|
|
41
|
+
'skill': '加载技能',
|
|
42
|
+
'describe_image': '描述图片',
|
|
43
|
+
'read_image': '读取图片',
|
|
44
|
+
'modlens_read_image': '读取图片(modlens)',
|
|
45
|
+
'ssh_list': '列出 SSH 主机',
|
|
46
|
+
'ssh_exec': 'SSH 执行命令',
|
|
47
|
+
'ssh_upload': 'SSH 上传',
|
|
48
|
+
'ssh_download': 'SSH 下载',
|
|
49
|
+
'ssh_tunnel': 'SSH 隧道',
|
|
50
|
+
'ssh_cluster': 'SSH 集群执行',
|
|
51
|
+
'exit_plan_mode': '退出计划模式',
|
|
52
|
+
'incident_resolved': '标记事故已解决',
|
|
53
|
+
'dsh_rollback': '回滚 DSH',
|
|
54
|
+
'dsh_snapshot': 'DSH 快照',
|
|
55
|
+
'job_list': '列出后台任务',
|
|
56
|
+
'job_output': '读取后台任务输出',
|
|
57
|
+
'job_kill': '终止后台任务',
|
|
58
|
+
'str_replace_editor': '文本编辑',
|
|
59
|
+
'cordis_inspect_list': '列出 Cordis 服务',
|
|
60
|
+
'cordis_inspect_query': '查询 Cordis 服务',
|
|
61
|
+
'cordis_inspect_self': '查看自身 Cordis 服务',
|
|
62
|
+
'cordis_define': '定义 Cordis 服务',
|
|
63
|
+
'cordis_run': '运行 Cordis 服务',
|
|
64
|
+
'cordis_stop': '停止 Cordis 服务',
|
|
65
|
+
'cordis_undefine': '取消定义 Cordis 服务',
|
|
66
|
+
'run_code': '运行代码',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Tool-name → 功能分类 map,覆盖 DSH 官方核心工具(固定集合)。插件工具
|
|
70
|
+
// 走前缀提取(见 categoryOf),自建预设用 preset 名。
|
|
71
|
+
export const TOOL_CATEGORY = {
|
|
72
|
+
'read': '文件', 'write': '文件', 'edit': '文件', 'grep': '文件', 'glob': '文件', 'str_replace_editor': '文件',
|
|
73
|
+
'bash': '终端', 'pwsh': '终端',
|
|
74
|
+
'web_search': '网络',
|
|
75
|
+
'todo_write': '任务', 'create_goal': '任务', 'get_goal': '任务', 'update_goal': '任务',
|
|
76
|
+
'subagent': '子 Agent', 'subagent_fork': '子 Agent', 'send_message': '子 Agent', 'interrupt_agent': '子 Agent', 'list_agents': '子 Agent',
|
|
77
|
+
'workflow': '工作流', 'ralph': '工作流',
|
|
78
|
+
'ask_user_question': '交互', 'skill': '交互',
|
|
79
|
+
'read_image': '图片', 'describe_image': '图片',
|
|
80
|
+
'cordis_inspect_list': 'Cordis', 'cordis_inspect_query': 'Cordis', 'cordis_inspect_self': 'Cordis',
|
|
81
|
+
'cordis_define': 'Cordis', 'cordis_run': 'Cordis', 'cordis_stop': 'Cordis', 'cordis_undefine': 'Cordis',
|
|
82
|
+
'exit_plan_mode': '计划',
|
|
83
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// lib/core/cost-guard.mjs — 运行时推导的 cost guard,从 index.mjs 逐字拆出。
|
|
2
|
+
// 仅引用 lib/core/pure.mjs 的 assertHardLimits;无 @deepseek-ai 依赖。
|
|
3
|
+
|
|
4
|
+
// 运行时推导的 cost guard,两部分:
|
|
5
|
+
// ① always-on hard caps (assertHardLimits, in lib/core/pure.mjs) — maxTokens /
|
|
6
|
+
// maxDepth are hard delegation caps, independent of the `llm` service, so
|
|
7
|
+
// they must NOT stop applying when `llm` is absent(历史教训:早期版本在
|
|
8
|
+
// llm 缺失时直接 return,连带跳过了硬上限,属缺陷——硬上限永远前置)。
|
|
9
|
+
// ② llm capability — validates provider / model / reasoningEffort against the
|
|
10
|
+
// live provider directory. When the `llm` service is absent OR its provider
|
|
11
|
+
// directory is empty (an adapter without discovery), capability cannot be
|
|
12
|
+
// verified: per `allowFailOpen`(迁移开关)either
|
|
13
|
+
// fail-open compat (warn + skip; v1 数据迁移中) or fail-loud reject. A
|
|
14
|
+
// profile that requests none of provider/model/reasoningEffort has nothing
|
|
15
|
+
// to verify and always passes (valid in a headless deployment).
|
|
16
|
+
// Used by both the provider's authoritative check and the dispatch tool's
|
|
17
|
+
// pre-check. `allowFailOpen`/`logger` are injected because this function is
|
|
18
|
+
// module-scoped and cannot reach the apply closure's `allowFailOpen`/`ctx.logger`.
|
|
19
|
+
//
|
|
20
|
+
// ①-⑥ 校验段按行门抽为模块级私有纯函数(行为逐字不变)。
|
|
21
|
+
|
|
22
|
+
import { assertHardLimits } from './pure.mjs';
|
|
23
|
+
|
|
24
|
+
// ② 仅当 profile 请求了需核验能力面的字段时才进入 llm 校验(无头/headless 部署下
|
|
25
|
+
// persona-only / toolFilter-only 的 profile 合法,不应被 fail-loud 拒绝)。
|
|
26
|
+
function needsLlmCheck(profile) {
|
|
27
|
+
return ['provider', 'model', 'reasoningEffort'].some(
|
|
28
|
+
(key) => typeof profile[key] === 'string' && profile[key].length > 0
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
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;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ④ provider 注册校验(目录非空时才能判定「不在目录」)。
|
|
44
|
+
async function assertProviderRegistered(llm, profile) {
|
|
45
|
+
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
|
+
throw new Error(`dispatch: provider "${profile.provider}" is not a registered provider`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ⑤ model 校验。resolveModelInfo does not reject unknown models (catalog
|
|
53
|
+
// membership is advisory), so validate against the advertised catalog
|
|
54
|
+
// instead. An EMPTY catalog (adapter without discovery) cannot be verified
|
|
55
|
+
// and is skipped — 目录级空集已在 ③ 走 allowFailOpen 分支,此处仅兜底
|
|
56
|
+
// per-provider 空目录。A non-empty catalog that does not advertise the model
|
|
57
|
+
// fails loud. An unverifiable lookup (listModels(undefined) when no provider
|
|
58
|
+
// is known) becomes a clean fail-loud error instead of leaking "undefined".
|
|
59
|
+
async function assertModelAdvertised(llm, profile, effectiveProvider) {
|
|
60
|
+
if (typeof profile.model !== 'string' || profile.model.length === 0) return;
|
|
61
|
+
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
|
+
}
|
|
67
|
+
const listed = models ?? [];
|
|
68
|
+
const known = listed.length > 0 && listed.some((model) => model && (model.id === profile.model || model.name === profile.model));
|
|
69
|
+
if (listed.length > 0 && !known) {
|
|
70
|
+
throw new Error(`dispatch: model "${profile.model}" is not advertised by provider "${String(effectiveProvider)}"`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ⑥ reasoningEffort 校验。
|
|
75
|
+
async function assertReasoningSupported(llm, profile, effectiveProvider, effectiveModel) {
|
|
76
|
+
if (typeof profile.reasoningEffort !== 'string' || profile.reasoningEffort.length === 0) return;
|
|
77
|
+
try {
|
|
78
|
+
await llm.resolveCallConfig({ provider: effectiveProvider, model: effectiveModel, reasoningEffort: profile.reasoningEffort });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
throw new Error(`dispatch: reasoningEffort "${profile.reasoningEffort}" is not supported by provider "${String(effectiveProvider)}" model "${String(effectiveModel)}": ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function assertCostGuard(parent, profile, allowFailOpen, logger) {
|
|
85
|
+
// ① 硬上限 always-on(不依赖 llm)。
|
|
86
|
+
assertHardLimits(profile.maxTokens, profile.maxDepth);
|
|
87
|
+
|
|
88
|
+
// ② 无 llm 能力面字段 → 无可核验,直接通过(headless 部署合法)。
|
|
89
|
+
if (!needsLlmCheck(profile)) return;
|
|
90
|
+
|
|
91
|
+
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 拒绝(可在配置中显式开启兼容模式)');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ④ provider 注册校验。
|
|
102
|
+
await assertProviderRegistered(llm, profile);
|
|
103
|
+
const effectiveProvider = profile.provider !== undefined ? profile.provider : parent.options.provider;
|
|
104
|
+
const effectiveModel = profile.model !== undefined ? profile.model : parent.options.model;
|
|
105
|
+
// ⑤ model 校验 + ⑥ reasoningEffort 校验。
|
|
106
|
+
await assertModelAdvertised(llm, profile, effectiveProvider);
|
|
107
|
+
await assertReasoningSupported(llm, profile, effectiveProvider, effectiveModel);
|
|
108
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// lib/core/delegation.mjs — background one-shot settling + delegation metadata
|
|
2
|
+
// assembly, moved from index.mjs. Local lib references only: imports
|
|
3
|
+
// stopReasonError / withPartialText / textFrom from lib/core/pure.mjs (the shipped
|
|
4
|
+
// shims.readResult is NOT used by settleStart — it is used only by the
|
|
5
|
+
// foreground result closure in lib/core/profile-provider.mjs); no @deepseek-ai
|
|
6
|
+
// dependency.
|
|
7
|
+
|
|
8
|
+
import { stopReasonError, withPartialText, textFrom } from './pure.mjs';
|
|
9
|
+
|
|
10
|
+
// Settle one background one-shot run into a job outcome with the same
|
|
11
|
+
// observability metadata the foreground path reports. Non-completed stop reasons
|
|
12
|
+
// become failed (aborted => killed, shipped vocabulary) with partial output
|
|
13
|
+
// attached; hard failures never reject the job.
|
|
14
|
+
// `prune` is the result-recycle pre-clipper: the caller (dispatch
|
|
15
|
+
// execute) injects a closure that calls the host toolResultPruner.pruneContent
|
|
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) {
|
|
19
|
+
let run;
|
|
20
|
+
try {
|
|
21
|
+
run = await start;
|
|
22
|
+
const result = await run.result;
|
|
23
|
+
const failure = stopReasonError(result);
|
|
24
|
+
if (failure !== undefined) {
|
|
25
|
+
return { status: result.stopReason === 'aborted' ? 'killed' : 'failed', detail: withPartialText(failure, result.output), ...meta };
|
|
26
|
+
}
|
|
27
|
+
return { status: 'completed', output: textFrom(prune(result.output)), ...meta };
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return signal.aborted ? { status: 'killed', ...meta } : { status: 'failed', detail: String(error), ...meta };
|
|
30
|
+
} finally {
|
|
31
|
+
// Release the child handle no matter how the result settled — run.result
|
|
32
|
+
// rejecting must not leak the subagent (same discipline as the foreground
|
|
33
|
+
// try/finally).
|
|
34
|
+
if (run !== undefined) await run.dispose().catch(() => {});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Verbatim from the shipped SUBAGENT_DELEGATION_CONTEXT.
|
|
39
|
+
export const DELEGATION_CONTEXT = 'You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it.';
|
|
40
|
+
|
|
41
|
+
// Provider start 内 CUSTOM childSessionMeta 对象的纯组装,从 provider start
|
|
42
|
+
// 抽出的可安全移动部分。取值侧仍留在 provider start(调用方
|
|
43
|
+
// 注入):cwd / parentSession 取自 parent.session.header,parentComposed /
|
|
44
|
+
// swapPreset 由 agentPresets.composedPreset 与 profile.preset 算出,childDepth
|
|
45
|
+
// 来自 resolveChildDepth。hasPresets=false(rosterless)时 agentPreset 整个省略
|
|
46
|
+
// (非 rosterless 才记录——语义与原始内联对象逐字一致)。
|
|
47
|
+
export function buildDispatchMeta({ cwd, hasPresets, swapPreset, preset, parentComposed, parentSession, childDepth }) {
|
|
48
|
+
return {
|
|
49
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
50
|
+
...(hasPresets
|
|
51
|
+
? swapPreset
|
|
52
|
+
? { agentPreset: preset }
|
|
53
|
+
: parentComposed !== undefined
|
|
54
|
+
? { agentPreset: parentComposed }
|
|
55
|
+
: {}
|
|
56
|
+
: {}),
|
|
57
|
+
parentSession,
|
|
58
|
+
origin: 'subagent',
|
|
59
|
+
delegationDepth: childDepth
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// lib/core/dispatch-tool.mjs — `dispatch` 工具(defineTool schema + execute)、
|
|
2
|
+
// 结果 schema 一致性锁与 syncTool 注册/注销逻辑,从 index.mjs 逐字拆出。
|
|
3
|
+
// 仅引用 lib + shims;无 @deepseek-ai 依赖(shims 是唯一入口)。
|
|
4
|
+
//
|
|
5
|
+
// Injection: every apply-closure / ctx dependency is an explicit parameter —
|
|
6
|
+
// register ctx.tools.register (the tool is registered/unregistered by
|
|
7
|
+
// syncTool, so the settings switch can remove it at runtime),
|
|
8
|
+
// store the profile store (resolveProfile for the base profile,
|
|
9
|
+
// getAllowFailOpen for the cost guard),
|
|
10
|
+
// getEnabled reads the apply-closure `enabled` flag (continuable fail-loud
|
|
11
|
+
// gate + syncTool registration condition),
|
|
12
|
+
// getService request-time service getter (ctx.get('toolResultPruner') /
|
|
13
|
+
// ctx.get('jobs') are read per call, never at apply time),
|
|
14
|
+
// logger ctx.logger (decision-level dispatch log + continuable warns),
|
|
15
|
+
// subagents ctx.subagents (start / startContinuable drive the child).
|
|
16
|
+
// The factory returns { syncTool, dispose }: syncTool is handed to the HTTP
|
|
17
|
+
// routes (/set-enabled), dispose runs on plugin teardown.
|
|
18
|
+
//
|
|
19
|
+
// execute 按预检段 + 前台/后台/continuable 三分支拆为模块级私有函数;
|
|
20
|
+
// parameters/output 声明为纯数据,驻留模块级常量(与数据表同性质,不受函数
|
|
21
|
+
// 行门约束)——工厂保持装配态,行为逐字不变。
|
|
22
|
+
|
|
23
|
+
import { defineTool } from './shims.mjs';
|
|
24
|
+
import { computeContinuableAllow, textFrom, stopReasonError, withPartialText, pruneBlocks, assertResultSchemaConsistency } from './pure.mjs';
|
|
25
|
+
import { resolveWhitelist } from './whitelist.mjs';
|
|
26
|
+
import { assertCostGuard } from './cost-guard.mjs';
|
|
27
|
+
import { settleStart } from './delegation.mjs';
|
|
28
|
+
|
|
29
|
+
// --- 工具声明纯数据(从工厂提到模块级;defineTool 只读不改)----------------------
|
|
30
|
+
|
|
31
|
+
const DISPATCH_PARAMETERS = {
|
|
32
|
+
profile: { type: 'string', description: 'Optional profile id from the profile registry (built-ins: swap-standard, researcher, plus any you define in the settings page); omit to inherit the parent preset and tools as-is.' },
|
|
33
|
+
preset: { type: 'string', description: 'Explicit target preset override; must be a system-trust preset of this runtime.' },
|
|
34
|
+
model: { type: 'string', description: 'Explicit model override for the child.' },
|
|
35
|
+
provider: { type: 'string', description: 'Explicit provider override for the child.' },
|
|
36
|
+
reasoningEffort: { type: 'string', description: 'Explicit reasoning-effort override injected into every child request.' },
|
|
37
|
+
persona: { type: 'string', description: 'Persona text shadowing the child deployment:persona section.' },
|
|
38
|
+
toolFilter: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
// DSL 对象参数默认拒绝未知键,toolFilter 必须闭合其 schema,
|
|
41
|
+
// 否则 defineTool 在 apply 时 throw、插件加载失败。
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
description: 'Extra tool whitelist intersection for the child (intersected with the parent tool set).',
|
|
44
|
+
properties: {
|
|
45
|
+
allow: { type: 'array', items: { type: 'string' }, description: 'When present, only these tool names are kept.' },
|
|
46
|
+
deny: { type: 'array', items: { type: 'string' }, description: 'These tool names are always removed.' }
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
maxTokens: { type: 'number', description: 'Explicit max-tokens budget for the child.' },
|
|
50
|
+
maxDepth: { type: 'number', description: 'Absolute delegation-depth cap for this child.' },
|
|
51
|
+
run_in_background: { type: 'boolean', description: '异步 one-shot:走 jobs.start 包 start(),返回 jobId;仍单轮即弃,非 continuable' },
|
|
52
|
+
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 中期启用,当前不生效)' },
|
|
57
|
+
prompt: { type: 'string', required: true, description: 'The complete, self-contained task for the child (it does not see this conversation).' }
|
|
58
|
+
};
|
|
59
|
+
|
|
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
|
+
};
|
|
112
|
+
|
|
113
|
+
// continuable 丢弃 preset 换用与 reasoningEffort —— 渲染行把 `ignored`
|
|
114
|
+
// 列表回显出来(`reasoningEffort=<值>(ignored)`,再加 ignored 项明细),让模型
|
|
115
|
+
// 「看见」被丢弃项;background/foreground 无忽略项时该后缀为空。
|
|
116
|
+
const DISPATCH_RENDER = (_args, value) => {
|
|
117
|
+
const ignored = value.ignored !== undefined && value.ignored.length > 0
|
|
118
|
+
? `(ignored: ${value.ignored.join(', ')})`
|
|
119
|
+
: '';
|
|
120
|
+
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}`
|
|
122
|
+
: 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}`;
|
|
125
|
+
return [{ type: 'text', text }];
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// --- execute 预检段(从 execute 拆出;行为逐字不变)-----------------------------
|
|
129
|
+
|
|
130
|
+
// Resolve the base profile (side channel), then overlay explicit args.
|
|
131
|
+
function mergeProfileArgs(args, store) {
|
|
132
|
+
const base = args.profile !== undefined ? store.resolveProfile(args.profile) : {};
|
|
133
|
+
const merged = { ...base };
|
|
134
|
+
for (const key of ['preset', 'model', 'provider', 'reasoningEffort', 'persona', 'toolFilter', 'maxTokens', 'maxDepth']) {
|
|
135
|
+
if (args[key] !== undefined) merged[key] = args[key];
|
|
136
|
+
}
|
|
137
|
+
return merged;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Pre-check: explicit concrete preset must be in the runtime-derived whitelist;
|
|
141
|
+
// a preset equal to the parent's composed preset is rewritten to
|
|
142
|
+
// 'inherit' (no swap).
|
|
143
|
+
async function assertPresetWhitelist(parent, merged) {
|
|
144
|
+
if (typeof merged.preset !== 'string' || merged.preset === 'inherit') return;
|
|
145
|
+
const whitelist = new Set(await resolveWhitelist(parent.ctx.get('agentPresets')));
|
|
146
|
+
if (!whitelist.has(merged.preset)) {
|
|
147
|
+
throw new Error(`dispatch: preset "${merged.preset}" is not in the target-preset whitelist`);
|
|
148
|
+
}
|
|
149
|
+
const parentPresets = parent.ctx.get('agentPresets');
|
|
150
|
+
const parentComposed = parentPresets !== undefined ? parentPresets.composedPreset(parent.ctx) : undefined;
|
|
151
|
+
if (merged.preset === parentComposed) merged.preset = 'inherit';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Effective delegation values for observability.
|
|
155
|
+
function buildMeta(args, merged, parent) {
|
|
156
|
+
return {
|
|
157
|
+
profile: args.profile ?? '(inline)',
|
|
158
|
+
preset: merged.preset ?? 'inherit',
|
|
159
|
+
provider: merged.provider ?? parent.options.provider ?? '(parent)',
|
|
160
|
+
model: merged.model ?? parent.options.model ?? '(parent)',
|
|
161
|
+
reasoningEffort: merged.reasoningEffort ?? '(default)'
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Assemble the foreground/background request (continuable builds its own below).
|
|
166
|
+
function buildRequest(args, merged, parent, signal) {
|
|
167
|
+
return {
|
|
168
|
+
label: String(args.prompt ?? '').slice(0, 60),
|
|
169
|
+
prompt: [{ type: 'text', text: args.prompt }],
|
|
170
|
+
parent,
|
|
171
|
+
signal,
|
|
172
|
+
profile: merged,
|
|
173
|
+
...(merged.persona !== undefined ? { persona: merged.persona } : {}),
|
|
174
|
+
...(merged.toolFilter !== undefined ? { toolFilter: merged.toolFilter } : {}),
|
|
175
|
+
...(merged.maxDepth !== undefined ? { maxDepth: merged.maxDepth } : {})
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Decision-level log: resolved effective delegation inputs, after the cost
|
|
180
|
+
// guard and after request assembly, before dispatch.
|
|
181
|
+
function logDispatchDecision(logger, args, merged, parent) {
|
|
182
|
+
logger.info('[dsh-subagent-profile] dispatch:', JSON.stringify({
|
|
183
|
+
profile: args.profile ?? '(inline)',
|
|
184
|
+
preset: merged.preset ?? 'inherit',
|
|
185
|
+
provider: merged.provider ?? parent.options.provider ?? '(parent)',
|
|
186
|
+
model: merged.model ?? parent.options.model ?? '(parent)',
|
|
187
|
+
reasoningEffort: merged.reasoningEffort ?? '(default)',
|
|
188
|
+
maxDepth: merged.maxDepth ?? null,
|
|
189
|
+
background: args.run_in_background === true,
|
|
190
|
+
continuable: args.continuable === true
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --- continuable 分支(从 execute 拆出)------------------------------------------
|
|
195
|
+
|
|
196
|
+
// 预加工 toolFilter 为闭集 allow(父工具集 − run_code − deny):continuable
|
|
197
|
+
// 走宿主 applyChildComposition→tools.restrict,prepareContinuable 返回 {},
|
|
198
|
+
// 插件侧无法重算父∩子交集,故在此把 allow 预加工为闭集传到 request。
|
|
199
|
+
// 假设:continuable 继承父预设(preset swap 被忽略)⇒ 子工具集 ≈ 父工具集;
|
|
200
|
+
// 失效条件:任何导致子工具集与父工具集不一致的宿主行为变化,父集都可能含
|
|
201
|
+
// 子集上不存在之工具 → tools.restrict 抛「未知工具」→ 本缓解自动降级为
|
|
202
|
+
// fail-loud(保守安全)。
|
|
203
|
+
function buildContinuableRequest(args, merged, parent) {
|
|
204
|
+
const parentNames = new Set(parent.ctx.tools.schemas(parent).map((schema) => schema.name));
|
|
205
|
+
const effectiveAllow = computeContinuableAllow(parentNames, merged.toolFilter);
|
|
206
|
+
const hasAgentOptions = merged.provider !== undefined || merged.model !== undefined || merged.maxTokens !== undefined;
|
|
207
|
+
return {
|
|
208
|
+
prompt: [{ type: 'text', text: args.prompt }],
|
|
209
|
+
parent,
|
|
210
|
+
...(hasAgentOptions ? { agentOptions: {
|
|
211
|
+
...(merged.provider !== undefined ? { provider: merged.provider } : {}),
|
|
212
|
+
...(merged.model !== undefined ? { model: merged.model } : {}),
|
|
213
|
+
...(merged.maxTokens !== undefined ? { maxTokens: merged.maxTokens } : {})
|
|
214
|
+
} } : {}),
|
|
215
|
+
...(merged.persona !== undefined ? { persona: merged.persona } : {}),
|
|
216
|
+
// 恒传闭集 allow(覆盖原 merged.toolFilter 透传);空集在
|
|
217
|
+
// computeContinuableAllow 内 fail-loud。
|
|
218
|
+
toolFilter: { allow: effectiveAllow },
|
|
219
|
+
...(merged.maxDepth !== undefined ? { maxDepth: merged.maxDepth } : {})
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Continuable (durable) path — startContinuable publishes a persistent child
|
|
224
|
+
// and returns its durable id; the official send_message tool drives later
|
|
225
|
+
// turns. Treated first so a caller asking for both background and continuable
|
|
226
|
+
// gets the continuable child.
|
|
227
|
+
async function runContinuable(args, merged, meta, parent, exec, deps) {
|
|
228
|
+
// provider `start` 的 !enabled 检查只拦
|
|
229
|
+
// `start`,不拦 `startContinuable` —— 这里显式补上。当前 syncTool 会在
|
|
230
|
+
// 禁用时注销 dispatch 工具(间接门),此处是防御性兜底:禁用后
|
|
231
|
+
// dispatch(continuable:true) 必须 fail-loud,不得静默派生子树。
|
|
232
|
+
if (!deps.getEnabled()) {
|
|
233
|
+
throw new Error('dispatch: 插件已禁用(设置 → 子 Agent 方案 重新启用)');
|
|
234
|
+
}
|
|
235
|
+
if (args.run_in_background === true) {
|
|
236
|
+
deps.logger.warn('[dsh-subagent-profile] dispatch: both continuable and run_in_background are true; continuable takes precedence');
|
|
237
|
+
}
|
|
238
|
+
// 已知降级:continuable 标准路径不支持 preset swap 和 reasoningEffort(subagent 包的 SubagentStartRequest 无 preset 字段、AgentOptions 无 reasoningEffort 字段)
|
|
239
|
+
if (merged.preset !== undefined && merged.preset !== 'inherit') {
|
|
240
|
+
deps.logger.warn(`[dsh-subagent-profile] continuable mode cannot swap preset; ignoring "${merged.preset}" (child inherits the parent preset)`);
|
|
241
|
+
}
|
|
242
|
+
if (merged.reasoningEffort !== undefined) {
|
|
243
|
+
deps.logger.warn(`[dsh-subagent-profile] continuable mode cannot set reasoningEffort; ignoring "${merged.reasoningEffort}"`);
|
|
244
|
+
}
|
|
245
|
+
const continuableRequest = buildContinuableRequest(args, merged, parent);
|
|
246
|
+
const { childId } = await deps.subagents.startContinuable({
|
|
247
|
+
provider: 'profile',
|
|
248
|
+
label: String(args.prompt ?? '').slice(0, 60),
|
|
249
|
+
request: continuableRequest,
|
|
250
|
+
signal: exec.signal
|
|
251
|
+
});
|
|
252
|
+
// Continuable drops the profile's preset swap and reasoningEffort (the child
|
|
253
|
+
// inherits the parent preset), so the observability meta must report what
|
|
254
|
+
// actually took effect, not the requested-but-ignored values.
|
|
255
|
+
// 可见性修复:`reasoningEffort` 回显**请求值**(经 meta.reasoningEffort),
|
|
256
|
+
// `preset:'inherit'` 是真实生效值;`ignored` 明确列出被丢弃项。
|
|
257
|
+
return {
|
|
258
|
+
kind: 'continuable',
|
|
259
|
+
subagentId: childId,
|
|
260
|
+
profile: meta.profile,
|
|
261
|
+
preset: 'inherit',
|
|
262
|
+
provider: meta.provider,
|
|
263
|
+
model: meta.model,
|
|
264
|
+
reasoningEffort: meta.reasoningEffort,
|
|
265
|
+
ignored: ['preset', 'reasoningEffort']
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// --- 后台 / 前台分支(从 execute 拆出)--------------------------------------------
|
|
270
|
+
|
|
271
|
+
// Background one-shot (job) path — jobs.start wraps start() with a native
|
|
272
|
+
// AbortController (a Node global in a bundle; the dynamic-plugin sandbox needed
|
|
273
|
+
// the hand-rolled shim instead); still one turn, not continuable.
|
|
274
|
+
async function runBackground(args, meta, request, parent, deps, pruneResultOutput) {
|
|
275
|
+
const jobs = deps.getService('jobs');
|
|
276
|
+
if (jobs === undefined) {
|
|
277
|
+
throw new Error('dispatch: background jobs unavailable (load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs)');
|
|
278
|
+
}
|
|
279
|
+
const jobId = jobs.start({
|
|
280
|
+
kind: 'subagent',
|
|
281
|
+
label: String(args.prompt ?? '').slice(0, 60),
|
|
282
|
+
owner: parent,
|
|
283
|
+
run: () => {
|
|
284
|
+
const controller = new AbortController();
|
|
285
|
+
return {
|
|
286
|
+
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)
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
return { kind: 'background', jobId, ...meta };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Foreground: collect, always release the handle (dispose even when
|
|
295
|
+
// run.result rejects), then fail loud on a non-completed stop reason.
|
|
296
|
+
async function runForeground(meta, request, parent, deps, pruneResultOutput) {
|
|
297
|
+
const run = await deps.subagents.start('profile', request);
|
|
298
|
+
let result;
|
|
299
|
+
try {
|
|
300
|
+
result = await run.result;
|
|
301
|
+
} finally {
|
|
302
|
+
await run.dispose().catch(() => {});
|
|
303
|
+
}
|
|
304
|
+
// A non-'completed' stop reason is a failure; attach the child's
|
|
305
|
+
// partial output text (withPartialText style).
|
|
306
|
+
const failure = stopReasonError(result);
|
|
307
|
+
if (failure !== undefined) throw new Error(withPartialText(failure, result.output));
|
|
308
|
+
return { output: textFrom(pruneResultOutput(result.output)), ...meta };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// execute 主体:预检段 + 三分支分派,行为逐字不变。
|
|
312
|
+
async function runDispatch(args, exec, deps) {
|
|
313
|
+
const parent = exec.agent;
|
|
314
|
+
if (!parent) throw new Error('dispatch requires calling agent');
|
|
315
|
+
const merged = mergeProfileArgs(args, deps.store);
|
|
316
|
+
await assertPresetWhitelist(parent, merged);
|
|
317
|
+
// Cost guard(运行时推导;硬上限始终生效,llm 能力核验由 allowFailOpen 门控)。
|
|
318
|
+
await assertCostGuard(parent, merged, deps.store.getAllowFailOpen(), deps.logger);
|
|
319
|
+
const meta = buildMeta(args, merged, parent);
|
|
320
|
+
const request = buildRequest(args, merged, parent, exec.signal);
|
|
321
|
+
// 结果回收默认剪枝:在 textFrom(result.output) 之前复用宿主
|
|
322
|
+
// toolResultPruner.pruneContent 预剪。`pruneResultOutput` 每次现取
|
|
323
|
+
// ctx.get('toolResultPruner') 以反映服务就绪状态;pruner 缺失时
|
|
324
|
+
// pruneBlocks 回退为不剪(剪枝是增强、非硬依赖)。envelope 参数虽已在
|
|
325
|
+
// 工具 schema 暴露(预留),但 execute **不消费**它。
|
|
326
|
+
const pruneResultOutput = (blocks) => pruneBlocks(blocks, deps.getService('toolResultPruner'));
|
|
327
|
+
logDispatchDecision(deps.logger, args, merged, parent);
|
|
328
|
+
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);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function createDispatchTool({ register, store, getEnabled, getService, logger, subagents }) {
|
|
334
|
+
const deps = { store, getEnabled, getService, logger, subagents };
|
|
335
|
+
const dispatchTool = defineTool({
|
|
336
|
+
name: 'dispatch',
|
|
337
|
+
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 提示)。',
|
|
338
|
+
parameters: DISPATCH_PARAMETERS,
|
|
339
|
+
output: { schema: DISPATCH_OUTPUT_SCHEMA, render: DISPATCH_RENDER },
|
|
340
|
+
isConcurrencySafe: () => true,
|
|
341
|
+
execute: (args, exec) => runDispatch(args, exec, deps),
|
|
342
|
+
});
|
|
343
|
+
// 共享一致性规则 lock: the closed oneOf result schema must carry an identical
|
|
344
|
+
// shared meta key set across all three branches. Fires only at apply time; a
|
|
345
|
+
// future meta-field add that forgets one 分支 throws here (once), so the
|
|
346
|
+
// model-side schema never silently rejects a分支.
|
|
347
|
+
assertResultSchemaConsistency(dispatchTool.output.schema);
|
|
348
|
+
// Register the tool only while enabled; unregister it the moment the switch
|
|
349
|
+
// turns off so it disappears from the model's tool list without a restart.
|
|
350
|
+
let disposeTool;
|
|
351
|
+
function syncTool() {
|
|
352
|
+
if (getEnabled() && disposeTool === undefined) {
|
|
353
|
+
disposeTool = register(dispatchTool);
|
|
354
|
+
} else if (!getEnabled() && disposeTool !== undefined) {
|
|
355
|
+
const dispose = disposeTool;
|
|
356
|
+
disposeTool = undefined;
|
|
357
|
+
dispose();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
syncTool();
|
|
361
|
+
return {
|
|
362
|
+
syncTool,
|
|
363
|
+
dispose() {
|
|
364
|
+
if (disposeTool !== undefined) {
|
|
365
|
+
const dispose = disposeTool;
|
|
366
|
+
disposeTool = undefined;
|
|
367
|
+
dispose();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
}
|