dsh-subagent-profile 0.2.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 +20 -8
- package/README.zh.md +20 -8
- package/index.mjs +177 -1386
- package/lib/client.js +871 -695
- package/lib/core/catalog-cache.mjs +235 -0
- package/lib/core/catalog.mjs +83 -0
- package/lib/core/cost-guard.mjs +122 -0
- package/lib/core/delegation.mjs +81 -0
- package/lib/core/dispatch-schema.mjs +71 -0
- package/lib/core/dispatch-tool.mjs +394 -0
- package/lib/core/http-routes.mjs +249 -0
- package/lib/core/intersection.mjs +27 -0
- package/lib/core/presets-sync.mjs +136 -0
- package/lib/core/profile-provider.mjs +242 -0
- package/lib/core/profiles-store.mjs +226 -0
- package/lib/{pure.mjs → core/pure.mjs} +146 -110
- package/lib/{shims.mjs → core/shims.mjs} +95 -9
- package/lib/core/whitelist.mjs +22 -0
- package/package.json +10 -5
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// lib/core/catalog-cache.mjs — 进程级共享 catalog 快照:一次拉取 llm 目录
|
|
2
|
+
// (providers + 每 provider 的 models + 每模型 reasoning-effort 等级)、
|
|
3
|
+
// system-trust 预设名册与完整工具目录,按 TTL 缓存后同时喂给设置页 /options
|
|
4
|
+
// 三路由与 dispatch 的 cost guard。TTL 过期自动重拉;同 key 在途 Promise 去重;
|
|
5
|
+
// /options/refresh 手动清缓存兜底。
|
|
6
|
+
//
|
|
7
|
+
// 依赖边界(无 @deepseek-ai 依赖):仅 import 纯数据表
|
|
8
|
+
// lib/core/catalog.mjs(工具名 → 中文/分类,零依赖)。服务经注入 getter 传入:
|
|
9
|
+
// getLlm () => ctx.get('llm') (headless 下可为 undefined)
|
|
10
|
+
// getAgentPresets () => ctx.get('agentPresets')
|
|
11
|
+
// getTools () => ctx.tools
|
|
12
|
+
// cost guard 校验的是「父 Agent 的 llm 实例」;同一进程内父 ctx 与插件 ctx 读到的
|
|
13
|
+
// 是同一个 host llm 服务,故 getSnapshot 接受可选 llm 覆盖(父实例)时仍命中同一条目。
|
|
14
|
+
//
|
|
15
|
+
// 缓存只提速目录解析,不复检安全门:provider/model/reasoningEffort 的校验逻辑与
|
|
16
|
+
// fail-loud 文案留在 lib/core/cost-guard.mjs(逐字不变),此处仅提供目录数据。
|
|
17
|
+
|
|
18
|
+
import { TOOL_ZH, TOOL_CATEGORY } from './catalog.mjs';
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TTL_MS = 60000;
|
|
21
|
+
|
|
22
|
+
// --- 目录构建(无缓存;行为与 http-routes 原 collector 逐字一致)----------------
|
|
23
|
+
|
|
24
|
+
// llm 目录:providers + 每 provider models + 每模型 efforts。listProviders 失败
|
|
25
|
+
// 记入 providersError(cost guard 据此判「目录为空」);每 provider 的 listModels
|
|
26
|
+
// 失败单独记录——cost guard 需要区分「空目录短路」与「listModels 抛错」的 fail-loud 文案。
|
|
27
|
+
async function collectLlmDirectory(llm) {
|
|
28
|
+
const models = [];
|
|
29
|
+
const efforts = Object.create(null);
|
|
30
|
+
const modelsByProvider = Object.create(null);
|
|
31
|
+
if (llm === undefined || typeof llm.listProviders !== 'function') {
|
|
32
|
+
return { providersError: undefined, providers: [], modelsByProvider, models, efforts };
|
|
33
|
+
}
|
|
34
|
+
let providers;
|
|
35
|
+
let providersError;
|
|
36
|
+
try {
|
|
37
|
+
providers = (await llm.listProviders()) ?? [];
|
|
38
|
+
} catch (error) {
|
|
39
|
+
providersError = error instanceof Error ? error : new Error(String(error));
|
|
40
|
+
return { providersError, providers: [], modelsByProvider, models, efforts };
|
|
41
|
+
}
|
|
42
|
+
for (const provider of providers) {
|
|
43
|
+
const providerId = provider && provider.id;
|
|
44
|
+
if (typeof providerId !== 'string') continue;
|
|
45
|
+
let entry;
|
|
46
|
+
try {
|
|
47
|
+
const modelList = await llm.listModels(providerId);
|
|
48
|
+
entry = { ok: true, models: modelList ?? [] };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
entry = { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
|
|
51
|
+
}
|
|
52
|
+
modelsByProvider[providerId] = entry;
|
|
53
|
+
if (entry.ok !== true) continue; // 该 provider 目录拉取失败 → 跳过其 UI 目录
|
|
54
|
+
for (const model of entry.models) {
|
|
55
|
+
if (!model || typeof model.id !== 'string') continue;
|
|
56
|
+
models.push({ provider: providerId, providerName: provider.name ?? providerId, id: model.id, name: model.name ?? model.id });
|
|
57
|
+
try {
|
|
58
|
+
const info = await llm.resolveModelInfo(providerId, model.id);
|
|
59
|
+
const effortsList = info && info.reasoning && Array.isArray(info.reasoning.efforts) ? info.reasoning.efforts : [];
|
|
60
|
+
efforts[model.id] = effortsList.map((effort) => ({
|
|
61
|
+
id: effort.id,
|
|
62
|
+
name: effort.name ?? effort.id,
|
|
63
|
+
...(effort.description !== undefined ? { description: effort.description } : {})
|
|
64
|
+
}));
|
|
65
|
+
} catch { /* 精确模型查询可能拒绝;跳过其 efforts */ }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { providersError: undefined, providers, modelsByProvider, models, efforts };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// System-trust 预设名册(agentPresets 可选,fail-soft)。
|
|
72
|
+
async function collectSystemPresets(agentPresets) {
|
|
73
|
+
const presets = [];
|
|
74
|
+
if (agentPresets === undefined || typeof agentPresets.list !== 'function') return presets;
|
|
75
|
+
try {
|
|
76
|
+
const list = await agentPresets.list();
|
|
77
|
+
for (const preset of (list ?? [])) {
|
|
78
|
+
if (preset && preset.trust === 'system') {
|
|
79
|
+
presets.push({ id: preset.id, name: preset.name ?? preset.id });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch { /* presets roster unavailable; leave empty */ }
|
|
83
|
+
return presets;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 完整工具目录 = global 层(部署插件)+ 每个 preset 的 standing scope
|
|
87
|
+
// (agent.cordis.yml 工具行)。工具按其来源打标:'global' 或 preset id——分组完全
|
|
88
|
+
// 动态,源自运行时名册。整体失败 fail-soft(返回空目录 + 告警),不破坏设置页。
|
|
89
|
+
async function collectToolsDirectory(getTools, getAgentPresets, logger) {
|
|
90
|
+
try {
|
|
91
|
+
const tools = [];
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
const OFFICIAL_PRESETS = ['standard', 'code', 'minimal', 'cordis'];
|
|
94
|
+
const layerOf = (source) => {
|
|
95
|
+
if (source === 'global') return 'plugin';
|
|
96
|
+
if (OFFICIAL_PRESETS.includes(source)) return 'core';
|
|
97
|
+
return 'custom';
|
|
98
|
+
};
|
|
99
|
+
const groupOf = (name, source) => {
|
|
100
|
+
const layer = layerOf(source);
|
|
101
|
+
if (layer === 'core') return TOOL_CATEGORY[name] ?? '其他';
|
|
102
|
+
if (layer === 'plugin') return name.includes('_') ? name.split('_')[0] : name;
|
|
103
|
+
return source;
|
|
104
|
+
};
|
|
105
|
+
const push = (schemas, source) => {
|
|
106
|
+
for (const s of (Array.isArray(schemas) ? schemas : [])) {
|
|
107
|
+
if (!s || typeof s.name !== 'string' || s.name === 'run_code' || seen.has(s.name)) continue;
|
|
108
|
+
seen.add(s.name);
|
|
109
|
+
tools.push({ name: s.name, description: typeof s.description === 'string' ? s.description : '', zh: TOOL_ZH[s.name] ?? '', source, layer: layerOf(source), group: groupOf(s.name, source) });
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const toolsService = typeof getTools === 'function' ? getTools() : undefined;
|
|
113
|
+
if (toolsService && typeof toolsService.schemas === 'function') {
|
|
114
|
+
push(toolsService.schemas(), 'global');
|
|
115
|
+
const agentPresets = typeof getAgentPresets === 'function' ? getAgentPresets() : undefined;
|
|
116
|
+
if (agentPresets !== undefined && typeof agentPresets.list === 'function' && typeof agentPresets.standingKeyFor === 'function') {
|
|
117
|
+
const presets = await agentPresets.list();
|
|
118
|
+
for (const preset of (presets ?? [])) {
|
|
119
|
+
if (!preset || typeof preset.id !== 'string') continue;
|
|
120
|
+
try {
|
|
121
|
+
push(toolsService.schemas(await agentPresets.standingKeyFor(preset.id)), preset.id);
|
|
122
|
+
} catch { /* 单个 preset 的 standing scope 不可用;跳过 */ }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return tools;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (logger !== undefined && typeof logger.warn === 'function') {
|
|
129
|
+
logger.warn('[dsh-subagent-profile] tools directory failed:', error instanceof Error ? error.message : String(error));
|
|
130
|
+
}
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 构建一份完整快照(无缓存)。`llm` 可选:cost guard 用它传入父 Agent 的 llm 实例。
|
|
136
|
+
export async function getCatalogSnapshot({ getLlm, getAgentPresets, getTools, llm, logger }) {
|
|
137
|
+
const effectiveLlm = llm !== undefined ? llm : (typeof getLlm === 'function' ? getLlm() : undefined);
|
|
138
|
+
const agentPresets = typeof getAgentPresets === 'function' ? getAgentPresets() : undefined;
|
|
139
|
+
const dir = await collectLlmDirectory(effectiveLlm);
|
|
140
|
+
const presets = await collectSystemPresets(agentPresets);
|
|
141
|
+
const tools = await collectToolsDirectory(getTools, getAgentPresets, logger);
|
|
142
|
+
return {
|
|
143
|
+
llm: { providersError: dir.providersError, providers: dir.providers, modelsByProvider: dir.modelsByProvider },
|
|
144
|
+
models: dir.models,
|
|
145
|
+
efforts: dir.efforts,
|
|
146
|
+
presets,
|
|
147
|
+
tools,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- TTL 缓存(按 llm 实例分键;同 key 在途去重;hit/miss 计数走 host logger)----
|
|
152
|
+
|
|
153
|
+
const NO_LLM = Symbol('catalog-cache:no-llm');
|
|
154
|
+
|
|
155
|
+
function makeAudit(logger) {
|
|
156
|
+
if (logger !== undefined && typeof logger.info === 'function') {
|
|
157
|
+
return (outcome, hits, misses) => logger.info(`[dsh-subagent-profile] catalog cache ${outcome} (hit=${hits}, miss=${misses})`);
|
|
158
|
+
}
|
|
159
|
+
return () => {};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 命中/未命中判定 + TTL 过期重拉;并发 cache-miss 复用同一条在途 Promise(只拉一次)。
|
|
163
|
+
async function resolveSnapshot(state, llm) {
|
|
164
|
+
// 无参时按注入的 getLlm() 取 llm 实例作 key——保证 /options 侧(无参)与
|
|
165
|
+
// cost guard 侧(传父实例)在同一 host llm 实例下命中同一条目。
|
|
166
|
+
const effectiveLlm = llm !== undefined ? llm : (typeof state.getLlm === 'function' ? state.getLlm() : undefined);
|
|
167
|
+
const key = effectiveLlm !== undefined ? effectiveLlm : NO_LLM;
|
|
168
|
+
const at = state.clock();
|
|
169
|
+
const entry = state.entries.get(key);
|
|
170
|
+
if (entry !== undefined) {
|
|
171
|
+
if (entry.snapshot !== undefined && entry.expiresAt > at) {
|
|
172
|
+
state.hits += 1;
|
|
173
|
+
state.audit('hit', state.hits, state.misses);
|
|
174
|
+
return entry.snapshot;
|
|
175
|
+
}
|
|
176
|
+
if (entry.inflight !== undefined) {
|
|
177
|
+
state.hits += 1;
|
|
178
|
+
state.audit('hit', state.hits, state.misses);
|
|
179
|
+
return entry.inflight;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
state.misses += 1;
|
|
183
|
+
state.audit('miss', state.hits, state.misses);
|
|
184
|
+
const inflight = getCatalogSnapshot({
|
|
185
|
+
getLlm: state.getLlm,
|
|
186
|
+
getAgentPresets: state.getAgentPresets,
|
|
187
|
+
getTools: state.getTools,
|
|
188
|
+
llm: effectiveLlm,
|
|
189
|
+
logger: state.logger,
|
|
190
|
+
});
|
|
191
|
+
const fresh = { expiresAt: at + state.ttl, snapshot: undefined, inflight };
|
|
192
|
+
state.entries.set(key, fresh);
|
|
193
|
+
try {
|
|
194
|
+
const snapshot = await inflight;
|
|
195
|
+
fresh.snapshot = snapshot;
|
|
196
|
+
fresh.inflight = undefined;
|
|
197
|
+
// 目录错误态不缓存:providersError 意味着 listProviders 瞬时失败或目录为空,
|
|
198
|
+
// 缓存会让瞬时故障自愈延迟一个 TTL;每次重试既保持安全门保守又恢复更快。
|
|
199
|
+
if (snapshot.llm.providersError !== undefined) {
|
|
200
|
+
state.entries.delete(key);
|
|
201
|
+
}
|
|
202
|
+
return snapshot;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
fresh.inflight = undefined;
|
|
205
|
+
state.entries.delete(key);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function invalidateSnapshot(state, llm) {
|
|
211
|
+
if (llm !== undefined) state.entries.delete(llm);
|
|
212
|
+
else state.entries.clear();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 工厂返回 { getSnapshot, invalidate, stats }。getSnapshot(llm?) 接受可选 llm 覆盖;
|
|
216
|
+
// 无参时走注入的 getLlm()。stats() 供审计/测试读取 hit/miss 计数。
|
|
217
|
+
export function createCatalogCache({ getLlm, getAgentPresets, getTools, logger, ttlMs, now }) {
|
|
218
|
+
const state = {
|
|
219
|
+
getLlm,
|
|
220
|
+
getAgentPresets,
|
|
221
|
+
getTools,
|
|
222
|
+
logger,
|
|
223
|
+
ttl: typeof ttlMs === 'number' && ttlMs > 0 ? ttlMs : DEFAULT_TTL_MS,
|
|
224
|
+
clock: typeof now === 'function' ? now : () => Date.now(),
|
|
225
|
+
entries: new Map(),
|
|
226
|
+
hits: 0,
|
|
227
|
+
misses: 0,
|
|
228
|
+
audit: makeAudit(logger),
|
|
229
|
+
};
|
|
230
|
+
return {
|
|
231
|
+
getSnapshot: (llm) => resolveSnapshot(state, llm),
|
|
232
|
+
invalidate: (llm) => invalidateSnapshot(state, llm),
|
|
233
|
+
stats: () => ({ hits: state.hits, misses: state.misses }),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -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/catalog-cache.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,122 @@
|
|
|
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
|
+
//
|
|
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
|
+
// (校验调用,非目录读取,不入缓存)。
|
|
22
|
+
// Used by both the provider's authoritative check and the dispatch tool's
|
|
23
|
+
// pre-check. `allowFailOpen`/`logger` are injected because this function is
|
|
24
|
+
// module-scoped and cannot reach the apply closure's `allowFailOpen`/`ctx.logger`.
|
|
25
|
+
|
|
26
|
+
import { assertHardLimits } from './pure.mjs';
|
|
27
|
+
|
|
28
|
+
// ② 仅当 profile 请求了需核验能力面的字段时才进入 llm 校验(无头/headless 部署下
|
|
29
|
+
// persona-only / toolFilter-only 的 profile 合法,不应被 fail-loud 拒绝)。
|
|
30
|
+
function needsLlmCheck(profile) {
|
|
31
|
+
return ['provider', 'model', 'reasoningEffort'].some(
|
|
32
|
+
(key) => typeof profile[key] === 'string' && profile[key].length > 0
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
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;
|
|
41
|
+
}
|
|
42
|
+
throw new Error('dispatch: 模型能力不可验证:fail-loud 拒绝(可在配置中显式开启兼容模式)');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ④ provider 注册校验(目录非空时才能判定「不在目录」)。providers 来自 catalog 快照。
|
|
46
|
+
function assertProviderRegistered(dir, profile) {
|
|
47
|
+
if (typeof profile.provider !== 'string' || profile.provider.length === 0) return;
|
|
48
|
+
if (!(dir.providers ?? []).some((provider) => provider && provider.id === profile.provider)) {
|
|
49
|
+
throw new Error(`dispatch: provider "${profile.provider}" is not a registered provider`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ⑤ model 校验。resolveModelInfo does not reject unknown models (catalog
|
|
54
|
+
// membership is advisory), so validate against the advertised catalog
|
|
55
|
+
// instead. An EMPTY catalog (adapter without discovery) cannot be verified
|
|
56
|
+
// and is skipped — 目录级空集已在 ③ 走 allowFailOpen 分支,此处仅兜底
|
|
57
|
+
// per-provider 空目录。A non-empty catalog that does not advertise the model
|
|
58
|
+
// fails loud. An unverifiable lookup (listModels(undefined) when no provider
|
|
59
|
+
// is known) becomes a clean fail-loud error instead of leaking "undefined".
|
|
60
|
+
// modelsByProvider 来自快照;快照未覆盖的 provider(undefined / 不在名册)
|
|
61
|
+
// 走直连 listModels 兜底,保持原 fail-loud 文案逐字。
|
|
62
|
+
async function assertModelAdvertised(llm, profile, effectiveProvider, modelsByProvider) {
|
|
63
|
+
if (typeof profile.model !== 'string' || profile.model.length === 0) return;
|
|
64
|
+
const cached = modelsByProvider !== undefined ? modelsByProvider[String(effectiveProvider)] : undefined;
|
|
65
|
+
let models;
|
|
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 });
|
|
79
|
+
}
|
|
80
|
+
const listed = models ?? [];
|
|
81
|
+
const known = listed.length > 0 && listed.some((model) => model && (model.id === profile.model || model.name === profile.model));
|
|
82
|
+
if (listed.length > 0 && !known) {
|
|
83
|
+
throw new Error(`dispatch: model "${profile.model}" is not advertised by provider "${String(effectiveProvider)}"`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ⑥ reasoningEffort 校验。
|
|
88
|
+
async function assertReasoningSupported(llm, profile, effectiveProvider, effectiveModel) {
|
|
89
|
+
if (typeof profile.reasoningEffort !== 'string' || profile.reasoningEffort.length === 0) return;
|
|
90
|
+
try {
|
|
91
|
+
await llm.resolveCallConfig({ provider: effectiveProvider, model: effectiveModel, reasoningEffort: profile.reasoningEffort });
|
|
92
|
+
} catch (error) {
|
|
93
|
+
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 });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function assertCostGuard(parent, profile, allowFailOpen, logger, catalog) {
|
|
98
|
+
// ① 硬上限 always-on(不依赖 llm)。
|
|
99
|
+
assertHardLimits(profile.maxTokens, profile.maxDepth);
|
|
100
|
+
|
|
101
|
+
// ② 无 llm 能力面字段 → 无可核验,直接通过(headless 部署合法)。
|
|
102
|
+
if (!needsLlmCheck(profile)) return;
|
|
103
|
+
|
|
104
|
+
const llm = parent.ctx.get('llm');
|
|
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);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ④ provider 注册校验。
|
|
116
|
+
assertProviderRegistered(dir, profile);
|
|
117
|
+
const effectiveProvider = profile.provider !== undefined ? profile.provider : parent.options.provider;
|
|
118
|
+
const effectiveModel = profile.model !== undefined ? profile.model : parent.options.model;
|
|
119
|
+
// ⑤ model 校验 + ⑥ reasoningEffort 校验。
|
|
120
|
+
await assertModelAdvertised(llm, profile, effectiveProvider, dir.modelsByProvider);
|
|
121
|
+
await assertReasoningSupported(llm, profile, effectiveProvider, effectiveModel);
|
|
122
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
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. `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()) {
|
|
22
|
+
let run;
|
|
23
|
+
try {
|
|
24
|
+
run = await start;
|
|
25
|
+
const result = await run.result;
|
|
26
|
+
const failure = stopReasonError(result);
|
|
27
|
+
if (failure !== undefined) {
|
|
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
|
+
};
|
|
35
|
+
}
|
|
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 };
|
|
46
|
+
} catch (error) {
|
|
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' };
|
|
50
|
+
} finally {
|
|
51
|
+
// Release the child handle no matter how the result settled — run.result
|
|
52
|
+
// rejecting must not leak the subagent (same discipline as the foreground
|
|
53
|
+
// try/finally).
|
|
54
|
+
if (run !== undefined) await run.dispose().catch(() => {});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Verbatim from the shipped SUBAGENT_DELEGATION_CONTEXT.
|
|
59
|
+
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.';
|
|
60
|
+
|
|
61
|
+
// Provider start 内 CUSTOM childSessionMeta 对象的纯组装,从 provider start
|
|
62
|
+
// 抽出的可安全移动部分。取值侧仍留在 provider start(调用方
|
|
63
|
+
// 注入):cwd / parentSession 取自 parent.session.header,parentComposed /
|
|
64
|
+
// swapPreset 由 agentPresets.composedPreset 与 profile.preset 算出,childDepth
|
|
65
|
+
// 来自 resolveChildDepth。hasPresets=false(rosterless)时 agentPreset 整个省略
|
|
66
|
+
// (非 rosterless 才记录——语义与原始内联对象逐字一致)。
|
|
67
|
+
export function buildDispatchMeta({ cwd, hasPresets, swapPreset, preset, parentComposed, parentSession, childDepth }) {
|
|
68
|
+
return {
|
|
69
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
70
|
+
...(hasPresets
|
|
71
|
+
? swapPreset
|
|
72
|
+
? { agentPreset: preset }
|
|
73
|
+
: parentComposed !== undefined
|
|
74
|
+
? { agentPreset: parentComposed }
|
|
75
|
+
: {}
|
|
76
|
+
: {}),
|
|
77
|
+
parentSession,
|
|
78
|
+
origin: 'subagent',
|
|
79
|
+
delegationDepth: childDepth
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -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
|
+
};
|