dsh-subagent-profile 0.4.0 → 0.5.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.
@@ -3,13 +3,13 @@
3
3
  // syncTool、catalog、logger;写路由各抽为模块级处理函数,工厂返回 scope.effect 设置函数。
4
4
 
5
5
  import { sanitizeProfile } from './pure.mjs';
6
- import { BUILTIN_SEEDS } from './profiles-store.mjs';
6
+ import { BUILTIN_SEEDS, assertProfileDescription } from './profiles-store.mjs';
7
7
  import { detectVersions } from './shims.mjs';
8
8
  import { readSummaries } from './evolution-advice.mjs';
9
9
  import { profileStatsFromSummaries } from './profile-directory.mjs';
10
10
  import { costSummaryFromSummaries } from './cost-evidence.mjs';
11
11
  import { json, readBody, persistOk } from './http-helpers.mjs';
12
- import { handleEvolutionAssets, handleEvolutionApply, handleEvolutionRegenerate, handleAdviceSaveProfile, handleSettingsCandidate } from './evolution-routes.mjs';
12
+ import { handleEvolutionAssets, handleEvolutionApply, handleEvolutionRollback, handleEvolutionRegenerate, handleAdviceSaveProfile, handleSettingsCandidate } from './evolution-routes.mjs';
13
13
 
14
14
  // 只有 loopback 接口可以驱动设置 HTTP 路由。
15
15
  const LOOPBACKS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
@@ -61,7 +61,9 @@ async function handleList(deps, res) {
61
61
  adviceDetails = result.adviceDetails !== null && typeof result.adviceDetails === 'object' ? result.adviceDetails : {};
62
62
  }
63
63
  } catch { /* 统计/建议是增量展示;失败不影响 /list 主响应 */ }
64
- return json(res, 200, { ok: true, profiles: listClean(deps.store, stats), costSummary, advice, adviceGlobal, evolutionCandidates, adviceDetails });
64
+ // 冷启动状态(apply/防退化可用性 + 原因清单):只读暴露,UI 展示归建议面板。
65
+ const evolutionStatus = typeof deps.evolution?.evolutionStatus === 'function' ? deps.evolution.evolutionStatus() : null;
66
+ return json(res, 200, { ok: true, profiles: listClean(deps.store, stats), costSummary, advice, adviceGlobal, evolutionCandidates, adviceDetails, evolutionStatus });
65
67
  }
66
68
 
67
69
  // 轻量摘要:enabled/advice 开关/模型目录/预设名册/审计分级。
@@ -247,6 +249,12 @@ async function handleAdd(deps, req, res) {
247
249
  const detail = warnings.map((w) => `${w.field}:${w.reason}`).join(';');
248
250
  return json(res, 400, { ok: false, error: `写入被拒绝:${detail}` });
249
251
  }
252
+ // description 落库契约(仅当请求显式携带 description 键:新建/编辑严格,未传
253
+ // 保留存量值不校验——存量宽容)。
254
+ if (Object.hasOwn(profile, 'description')) {
255
+ try { assertProfileDescription(profile.description); }
256
+ catch (error) { return json(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) }); }
257
+ }
250
258
  const hadToolFilter = profile.toolFilter !== undefined;
251
259
  // tokenTier 单独用 raw-body 守卫(sanitize 恒回填 balanced,进 merge 会破坏
252
260
  // 「未传→保留 existing」语义);strict 下非法值已在上面 400 拒绝。
@@ -273,7 +281,7 @@ async function handleAdd(deps, req, res) {
273
281
  }
274
282
  if (hadTokenTier) merged.tokenTier = clean.tokenTier;
275
283
  if (merged.enabled !== undefined) merged.enabled = merged.enabled === false ? false : true;
276
- deps.store.profiles.set(merged.id, { ...merged, ...(isBuiltin ? { builtin: true } : {}), persisted: true });
284
+ deps.store.saveProfile({ ...merged, ...(isBuiltin ? { builtin: true } : {}), persisted: true });
277
285
  deps.store.deletedBuiltins.delete(merged.id);
278
286
  return persistOk(res, { id: merged.id }, deps.store.persistProfiles());
279
287
  }
@@ -368,6 +376,7 @@ async function routeRequest(deps, req, res, url, sub) {
368
376
  if (req.method === 'POST' && sub === '/draft/preview') { const body = await readBody(req); return json(res, 200, { ok: true, ...(await deps.previewDraft(body)) }); }
369
377
  if (req.method === 'GET' && sub === '/evolution/assets') return handleEvolutionAssets(deps, res);
370
378
  if (req.method === 'POST' && sub === '/evolution/apply') return handleEvolutionApply(deps, req, res);
379
+ if (req.method === 'POST' && sub === '/evolution/rollback') return handleEvolutionRollback(deps, req, res);
371
380
  if (req.method === 'POST' && sub === '/evolution/regenerate') return handleEvolutionRegenerate(deps, req, res);
372
381
  if (req.method === 'POST' && sub === '/advice/save-profile') return handleAdviceSaveProfile(deps, req, res);
373
382
  if (req.method === 'POST' && sub === '/settings/candidate') return handleSettingsCandidate(deps, req, res);
@@ -404,7 +413,9 @@ export function createHttpRoutes({ webServer, store, getEnabled, setEnabled, syn
404
413
  return json(res, 403, { ok: false, error: `CSRF 防护拒绝:${violation.reason}` });
405
414
  }
406
415
  }
407
- return routeRequest(deps, req, res, url, sub);
416
+ // 显式 await:async 路由的拒绝必须落入下方 catch(未 await 时 reject 会绕过
417
+ // catch 直接传播给 webServer,fail-loud 变成未捕获拒绝)。
418
+ return await routeRequest(deps, req, res, url, sub);
408
419
  } catch (error) {
409
420
  // 通用 500 不回显内部错误信息(防泄漏),详情只进宿主日志。
410
421
  deps.logger.error('[dsh-subagent-profile] settings route error:', error instanceof Error ? (error.stack ?? error.message) : String(error));
@@ -0,0 +1,77 @@
1
+ // lib/core/model-policy.mjs — 官方子代理模型选择投影(subagentModelSelectionPolicy)
2
+ // 的读取与生效路由断言。与官方工具链同源同 key(key 字符串照抄官方投影定义),
3
+ // 无 @deepseek-ai 依赖,可被 bare-import 单测直接 import。
4
+ //
5
+ // 读取语义(与官方一致):
6
+ // * sessionProjections 服务缺失 / stateOf 不可用 / 投影值 undefined 或 null
7
+ // (key 已注册但本会话未固化策略,官方视同未配置)→ { configured:false },
8
+ // 跳过、不抛错——插件行为与 0.4.0 完全一致。
9
+ // * 投影值为数组 → 形状校验(照官方 assertAllowedModelRoutes:数组、每项
10
+ // provider/model 为非空字符串、无重复路由、至少一条)→ 畸形 fail-loud,
11
+ // 官方格式变更不得静默失效。
12
+ //
13
+ // 生效路由判定:判定输入是 per-call > profile > parent 解析链完成后的最终
14
+ // provider/model(含继承带出的模型),与 allowedModels 精确成对匹配。
15
+
16
+ const PROJECTION_SERVICE = 'sessionProjections';
17
+ const POLICY_KEY = 'subagentModelSelectionPolicy';
18
+
19
+ // 三层中文错误文案(定稿):第一层说清越界事实与白名单,第二层说明与官方通道
20
+ // 同策略,第三层给出行动路径。缺省侧(parent 未暴露路由)以 '(parent)' 占位。
21
+ export function officialModelRejectMessage(provider, model, allowedModels) {
22
+ const shown = (value) => (typeof value === 'string' && value.length > 0 ? value : '(parent)');
23
+ const allowed = allowedModels.map((route) => `${route.provider}/${route.model}`).join('、');
24
+ return [
25
+ `dispatch: 模型 ${shown(provider)}/${shown(model)} 不在本会话允许的子代理模型白名单内(允许:${allowed})。`,
26
+ '官方在其工具链执行同一策略,本插件同步执行,以保证两通道一致。',
27
+ '请改用允许的模型/方案,或在官方设置更新模型选择(本会话策略已固定,新会话生效)。',
28
+ ].join('\n');
29
+ }
30
+
31
+ // 形状校验:照官方 assertAllowedModelRoutes 语义(非空 provider/model 字符串、
32
+ // 无重复路由),另按官方 stateSchema 的 min(1) 拒绝空数组。畸形即 fail-loud。
33
+ function validateRoutes(routes) {
34
+ if (!Array.isArray(routes) || routes.length === 0) {
35
+ throw new Error('dispatch: 官方子代理模型选择投影形状不符(须为非空路由数组)');
36
+ }
37
+ const seen = new Set();
38
+ for (const candidate of routes) {
39
+ if (
40
+ candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)
41
+ || typeof candidate.provider !== 'string' || candidate.provider.length === 0
42
+ || typeof candidate.model !== 'string' || candidate.model.length === 0
43
+ ) {
44
+ throw new Error('dispatch: 官方子代理模型选择投影形状不符(每项须为 provider/model 非空字符串)');
45
+ }
46
+ const key = `${candidate.provider}\0${candidate.model}`;
47
+ if (seen.has(key)) {
48
+ throw new Error(`dispatch: 官方子代理模型选择投影形状不符(路由 "${candidate.provider}/${candidate.model}" 重复)`);
49
+ }
50
+ seen.add(key);
51
+ }
52
+ }
53
+
54
+ // 读取官方模型选择投影。任何读取失败路径(ctx / 服务 / stateOf 缺失)与未固化
55
+ // 状态(undefined / null)统一回退 { configured:false }(跳过语义);投影存在则
56
+ // 校验形状后返回路由表副本(返回值不得被调用方改写共享)。
57
+ export function readModelSelectionPolicy(ctx, session) {
58
+ const projections = typeof ctx?.get === 'function' ? ctx.get(PROJECTION_SERVICE) : undefined;
59
+ const raw = projections !== null && typeof projections?.stateOf === 'function'
60
+ ? projections.stateOf(session, POLICY_KEY)
61
+ : undefined;
62
+ if (raw === undefined || raw === null) return { configured: false, enabled: false, allowedModels: [] };
63
+ validateRoutes(raw);
64
+ return {
65
+ configured: true,
66
+ enabled: true,
67
+ allowedModels: raw.map((route) => ({ provider: route.provider, model: route.model })),
68
+ };
69
+ }
70
+
71
+ // 生效路由断言:policy 未配置恒通过;否则 provider/model 与 allowedModels 精确
72
+ // 成对匹配,不匹配 throw 三层中文错误(留痕与审计由调用方闸层负责)。
73
+ export function assertOfficialModelRoute(policy, provider, model) {
74
+ if (policy === undefined || policy.configured !== true) return;
75
+ const allowed = policy.allowedModels.some((route) => route.provider === provider && route.model === model);
76
+ if (!allowed) throw new Error(officialModelRejectMessage(provider, model, policy.allowedModels));
77
+ }
@@ -2,7 +2,7 @@
2
2
  // 仅 node 内置,无 @deepseek-ai 依赖)。
3
3
  //
4
4
  // 宿主启动时插件把 bundled `presets/` 树同步进 DSH agent-presets 发现根
5
- // (~/.dsh/.agent-presets),使「编排者模式」无需手动复制就出现在新建会话
5
+ // (~/.dsh/.agent-presets),使「编排者模式 V2」无需手动复制就出现在新建会话
6
6
  // 选择器里——与官方 dsh-liangshen bundle 相同的自安装模式。同步按目录、
7
7
  // 经哈希门控:per-target sidecar(.dsh-sync.json)记录 bundled 内容哈希与派生树
8
8
  // 快照哈希,下次运行跳过未变化的树、跳过(绝不覆盖)用户改过的树;两侧都变时
@@ -230,9 +230,26 @@ export function syncOnePreset(sourceDir, targetDir) {
230
230
  return 'synced';
231
231
  }
232
232
 
233
+ // 旧预设收编归档(V2 取代):bundled 已不再提供 `orchestrator`(由
234
+ // `orchestrator-v2` 收编取代),发现根里若残留旧目录(旧版本自装、用户手工创建
235
+ // 或恢复的),同步时改名归档为 orchestrator.removed-<stamp>——与卸载清理同一样式:
236
+ // 只改名不删除(用户数据保留),幂等(归档后旧名即不存在,下次同步无操作)、
237
+ // fail-soft(改名失败不抛不阻断,下次启动重试)。归档名含点号,不满足官方
238
+ // PRESET_ID(^[a-z0-9][a-z0-9-]*$),不会出现在预设选择器。
239
+ function archiveLegacyOrchestrator(targetRoot) {
240
+ const legacy = join(targetRoot, 'orchestrator');
241
+ if (!existsSync(legacy)) return false;
242
+ try {
243
+ renameSync(legacy, join(targetRoot, `orchestrator.removed-${Date.now()}`));
244
+ return true;
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
249
+
233
250
  // 把 `presets/` 下每个 preset 目录同步进目标发现根。
234
251
  export function syncBundledPresets(targetRoot) {
235
- const result = { synced: [], current: [], userModified: [], failed: [] };
252
+ const result = { synced: [], current: [], userModified: [], failed: [], archived: [] };
236
253
  const sourceRoot = bundledPresetsRoot();
237
254
  mkdirSync(targetRoot, { recursive: true });
238
255
  if (existsSync(sourceRoot)) {
@@ -250,5 +267,10 @@ export function syncBundledPresets(targetRoot) {
250
267
  }
251
268
  }
252
269
  }
270
+ // 旧名收编归档:仅当 bundled 侧确无 orchestrator 目录时处理(防御:未来若仓库
271
+ // 回补同名 bundled,以 bundled 为准、不误归档其派生树)。
272
+ if (!existsSync(join(sourceRoot, 'orchestrator')) && archiveLegacyOrchestrator(targetRoot)) {
273
+ result.archived.push('orchestrator');
274
+ }
253
275
  return result;
254
276
  }
@@ -125,6 +125,23 @@ export function profileStatsFromSummaries(store, summaries) {
125
125
  return out;
126
126
  }
127
127
 
128
+ // dispatch:profiles section 文本(模型可见的轻量目录,瘦身):行格式
129
+ // `- id: "描述" (tier: X)`——成本/成功率等统计只进 profiles_query 与人类面板,不进
130
+ // 静态段。description 为空显示 (无描述)(存量宽容渲染);note 一行查表式并指向
131
+ // profiles_query 下钻。gate 由调用方(index.mjs 的 profileSectionGate)传入。
132
+ export function profileSectionText(store, gate, context) {
133
+ if (!gate(context)) return '';
134
+ const rows = profileDirectoryRows(store);
135
+ if (rows.length === 0) return '';
136
+ const lines = rows.map((p) => {
137
+ const desc = typeof p.description === 'string' && p.description.length > 0 ? `"${p.description}"` : '(无描述)';
138
+ const tier = typeof p.tokenTier === 'string' && p.tokenTier !== '' ? ` (tier: ${p.tokenTier})` : '';
139
+ return `- ${p.id}: ${desc}${tier}`;
140
+ });
141
+ const note = '按任务复杂度匹配方案能力(查资料/汇总→cheap 或 flash;写/改代码→swap-standard;深度推理→premium;1-2 步可自查的小事直接做别派;可用方案与明细用 profiles_query 查询)';
142
+ return `Available dispatch profiles (dispatch.profile):\n${lines.join('\n')}\n${note}`;
143
+ }
144
+
128
145
  // 用 stats map 为目录行补 avgCost/successRate(F 数据层 join)。stats 为
129
146
  // profileStatsFromSummaries 的返回值;行上已有 avgCost/successRate 时优先保留
130
147
  // 显式值(store 侧/测试注入),stats 只补缺失。
@@ -31,6 +31,7 @@ import { resolveWhitelist } from './whitelist.mjs';
31
31
  import { assertCostGuard } from './cost-guard.mjs';
32
32
  import { DELEGATION_CONTEXT, buildDispatchMeta } from './delegation.mjs';
33
33
  import { readSessionEvents } from './session-read.mjs';
34
+ import { applyOfficialModelProviderGate } from './dispatch-gates.mjs';
34
35
 
35
36
  // --- start 预检段(从 start 拆出)-------------------------------------------------
36
37
 
@@ -64,6 +65,9 @@ async function runStartPreflight(request, deps) {
64
65
  // 权威 cost guard(运行时推导;硬上限始终生效,llm 能力核验由 allowFailOpen 门控;
65
66
  // llm 目录读取走共享 catalog 快照)。
66
67
  await assertCostGuard(parent, profile, deps.store.getAllowFailOpen(), deps.logger, deps.catalog);
68
+ // 官方模型选择投影读取(服务/投影缺失或未固化 → configured:false 跳过);生效
69
+ // 路由判定在 agentOptions 解析完成后——provider 渠道与 dispatch 预检同一白名单。
70
+ const policy = deps.readModelSelectionPolicy?.(parent.ctx, parent.session);
67
71
  // 委派深度:官方助手——先断言上限值,再解析子深度(父底 + 1)并执行上限。
68
72
  assertSubagentMaxDepth(profile.maxDepth);
69
73
  const childDepth = resolveChildDepth(parent, profile.maxDepth);
@@ -73,6 +77,7 @@ async function runStartPreflight(request, deps) {
73
77
  const swapPreset = typeof profile.preset === 'string' && profile.preset !== 'inherit' && profile.preset !== parentComposed;
74
78
  const meta = buildProviderMeta(parent, profile, swapPreset, childDepth);
75
79
  const agentOptions = buildProviderAgentOptions(parent, profile, childDepth);
80
+ applyOfficialModelProviderGate({ evoLedger: deps.evoLedger, sessionId: parent.session?.header?.id }, policy, agentOptions);
76
81
  return { parent, profile, delegated, childId, swapPreset, meta, agentOptions };
77
82
  }
78
83
 
@@ -234,8 +239,8 @@ function wireChildLifecycle(handle, request, childId, swapPreset, profile, logge
234
239
  };
235
240
  }
236
241
 
237
- export function createProfileProvider({ subagents, store, getEnabled, logger, catalog, getEscapeSet }) {
238
- const deps = { store, getEnabled, logger, catalog, getEscapeSet };
242
+ export function createProfileProvider({ subagents, store, getEnabled, logger, catalog, getEscapeSet, readModelSelectionPolicy, evoLedger }) {
243
+ const deps = { store, getEnabled, logger, catalog, getEscapeSet, readModelSelectionPolicy, evoLedger };
239
244
  return subagents.registerProvider({
240
245
  name: 'profile',
241
246
  capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true },
@@ -0,0 +1,178 @@
1
+ // lib/core/profiles-query.mjs — profiles_query 工具(派发档案按需下钻):入参全可选
2
+ // (id / tier / provider+model 成对 / 无参全量),未知键由宿主 DSL 默认拒绝(键集闭合
3
+ // 断言在 test/profiles-query.test.mjs)。数据源分布:profileDirectoryRows(档案)/
4
+ // catalog-cache 快照(官方模型 + efforts,TTL 60s)/ summaries.json(统计,
5
+ // mtime+指纹,fail-soft)。官方目录没有的 model 只标「未在册」(advisory,不判失效);
6
+ // 「未授权」联动官方模型选择投影(model-policy):本会话投影配置了 allowedModels 且档案绑定路由不在
7
+ // 其中时追加标注。本工具不复制任何策略逻辑——授权性以官方为准(工具描述首行已指向
8
+ // list_subagent_models)。
9
+
10
+ import { defineTool } from './shims.mjs';
11
+ import { profileDirectoryRows, profileStatsFromSummaries } from './profile-directory.mjs';
12
+ import { readModelSelectionPolicy } from './model-policy.mjs';
13
+ import { readSummaries } from './evolution-advice.mjs';
14
+
15
+ // 入参 schema:全可选;provider+model 成对约束在 execute 内校验(schema 层无法表达
16
+ // 成对)。键集闭合 = 白名单 4 键(与 percall-spec 同口径:多出的键没有接受路径)。
17
+ export const PROFILES_QUERY_PARAMETERS = {
18
+ id: { type: 'string', description: '按 id 查单个派发档案明细(其余入参被忽略)' },
19
+ tier: { type: 'string', enum: ['cheap', 'balanced', 'premium'], description: '按成本/深度档位过滤档案' },
20
+ provider: { type: 'string', description: '与 model 成对使用:先查该模型的官方 reasoning efforts,再列出绑定该模型的档案' },
21
+ model: { type: 'string', description: '与 provider 成对使用:官方模型目录中的模型 id' },
22
+ };
23
+
24
+ // 输出 schema:模型可见面只有 text 一行(render 把 text 包成内容块)。
25
+ export const PROFILES_QUERY_OUTPUT_SCHEMA = {
26
+ type: 'object',
27
+ additionalProperties: false,
28
+ properties: {
29
+ text: { type: 'string', required: true, description: '模型可见的查询结果文本' },
30
+ },
31
+ };
32
+
33
+ // 列表行描述截断长度(单档案明细不截断)。
34
+ const LIST_DESC_CHARS = 80;
35
+
36
+ // 档案绑定的 provider/model 路由(两者齐备才绑定);未绑定返回 null。
37
+ export function boundRouteOf(row) {
38
+ const provider = row !== null && typeof row === 'object' && typeof row.provider === 'string' && row.provider !== '' ? row.provider : undefined;
39
+ const model = row !== null && typeof row === 'object' && typeof row.model === 'string' && row.model !== '' ? row.model : undefined;
40
+ return provider !== undefined && model !== undefined ? { provider, model } : null;
41
+ }
42
+
43
+ // 官方状态标注:在册(catalog models 含 provider+model)/ 未在册 / 未绑定。未在册只
44
+ // 标不判失效(advisory)。
45
+ export function catalogStatusOf(row, models) {
46
+ const route = boundRouteOf(row);
47
+ if (route === null) return '未绑定';
48
+ const listed = Array.isArray(models) && models.some((m) => m !== null && typeof m === 'object' && m.provider === route.provider && m.id === route.model);
49
+ return listed ? '在册' : '未在册';
50
+ }
51
+
52
+ // 未授权标注:投影配置且绑定路由不在 allowedModels → '未授权';投影未配置或未绑定
53
+ // → undefined(不加注)。授权性判定只读官方投影,不复制策略逻辑。
54
+ export function unauthorizedMarkOf(row, policy) {
55
+ const route = boundRouteOf(row);
56
+ if (route === null || policy === undefined || policy === null || policy.configured !== true) return undefined;
57
+ const allowed = Array.isArray(policy.allowedModels) && policy.allowedModels.some((r) => r !== null && typeof r === 'object' && r.provider === route.provider && r.model === route.model);
58
+ return allowed ? undefined : '未授权';
59
+ }
60
+
61
+ // efforts 条件式文案:绑定 → 该模型官方 reasoning efforts(目录无数据 → 占位);
62
+ // 未绑定 → 继承父模型、档位以父为准。
63
+ export function effortsTextOf(row, effortsByModel) {
64
+ const route = boundRouteOf(row);
65
+ if (route === null) return '继承父模型,档位以父为准';
66
+ const efforts = effortsByModel !== null && typeof effortsByModel === 'object' ? effortsByModel[route.model] : undefined;
67
+ if (!Array.isArray(efforts) || efforts.length === 0) return '(目录无该模型明细)';
68
+ return efforts.map((effort) => (effort !== null && typeof effort === 'object' && typeof effort.id === 'string' ? effort.id : String(effort))).join('/');
69
+ }
70
+
71
+ // 单档案明细(两行:主行 + efforts 行)。stats 为 profileStatsFromSummaries 的
72
+ // Map;truncate=true 时描述截断 80 字符 + …(列表口径,单档案明细不截断)。
73
+ export function profileDetailLines(row, { stats, models, effortsByModel, policy, truncate = false } = {}) {
74
+ const desc = row !== null && typeof row === 'object' && typeof row.description === 'string' && row.description !== '' ? row.description : '(无描述)';
75
+ const shown = truncate && desc.length > LIST_DESC_CHARS ? `${desc.slice(0, LIST_DESC_CHARS)}…` : desc;
76
+ const route = boundRouteOf(row);
77
+ const binding = route === null ? '继承父' : `${route.provider}/${route.model}`;
78
+ const tier = row !== null && typeof row === 'object' && typeof row.tokenTier === 'string' && row.tokenTier !== '' ? row.tokenTier : '未标注';
79
+ const stat = stats instanceof Map ? stats.get(row?.id) : (stats !== null && typeof stats === 'object' ? stats[row?.id] : undefined);
80
+ const cost = stat !== null && typeof stat === 'object' && typeof stat.avgCost === 'number' ? `平均 ¥${stat.avgCost}` : undefined;
81
+ const rate = stat !== null && typeof stat === 'object' && typeof stat.successRate === 'number'
82
+ ? `成功率 ${stat.successRate}${typeof stat.n === 'number' ? ` (N=${stat.n})` : ''}`
83
+ : undefined;
84
+ const status = catalogStatusOf(row, models);
85
+ const unauthorized = unauthorizedMarkOf(row, policy);
86
+ const meta = [binding, `tier ${tier}`, cost, rate, `官方状态:${status}${unauthorized !== undefined ? `·${unauthorized}` : ''}`].filter((part) => part !== undefined).join(' · ');
87
+ return [`${row?.id}: ${shown} — ${meta}`, `· efforts: ${effortsTextOf(row, effortsByModel)}`];
88
+ }
89
+
90
+ // 单次查询组装(纯函数):id 单档案 > tier 过滤 > provider+model 成对 > 无参全量。
91
+ // provider/model 只传其一 → fail-loud(成对约束)。
92
+ export function buildProfilesQueryText({ args, rows, stats, models, effortsByModel, policy }) {
93
+ const hasProvider = typeof args?.provider === 'string' && args.provider !== '';
94
+ const hasModel = typeof args?.model === 'string' && args.model !== '';
95
+ if (hasProvider !== hasModel) throw new Error('profiles_query: provider 与 model 须成对提供');
96
+ const details = (row, truncate = false) => profileDetailLines(row, { stats, models, effortsByModel, policy, truncate });
97
+ if (typeof args?.id === 'string' && args.id !== '') {
98
+ const row = rows.find((r) => r !== null && typeof r === 'object' && r.id === args.id);
99
+ if (row === undefined) return { text: `未找到 profile "${args.id}"。可用 id:${rows.map((r) => r?.id).join('、') || '(暂无)'}` };
100
+ return { text: details(row).join('\n') };
101
+ }
102
+ if (typeof args?.tier === 'string' && args.tier !== '') {
103
+ const tierRows = rows.filter((r) => r !== null && typeof r === 'object' && r.tokenTier === args.tier);
104
+ if (tierRows.length === 0) return { text: `无 tier=${args.tier} 的方案` };
105
+ const lines = tierRows.flatMap((row) => details(row, true));
106
+ return { text: `tier=${args.tier} 的方案(${tierRows.length}):\n${lines.join('\n')}` };
107
+ }
108
+ if (hasModel) {
109
+ const listed = Array.isArray(models) && models.some((m) => m !== null && typeof m === 'object' && m.provider === args.provider && m.id === args.model);
110
+ const rawEfforts = effortsByModel !== null && typeof effortsByModel === 'object' ? effortsByModel[args.model] : undefined;
111
+ const modelEfforts = Array.isArray(rawEfforts) && rawEfforts.length > 0
112
+ ? rawEfforts.map((effort) => (effort !== null && typeof effort === 'object' && typeof effort.id === 'string' ? effort.id : String(effort))).join('/')
113
+ : undefined;
114
+ const header = `模型 ${args.provider}/${args.model} 官方 reasoning efforts:${modelEfforts ?? '(目录无该模型明细)'}`;
115
+ const bound = rows.filter((r) => {
116
+ const route = boundRouteOf(r);
117
+ return route !== null && route.provider === args.provider && route.model === args.model;
118
+ });
119
+ if (bound.length === 0) return { text: `${header}\n无绑定该模型的方案${listed ? '' : '(该模型不在官方目录,未在册)'}` };
120
+ const lines = bound.flatMap((row) => details(row, true));
121
+ return { text: `${header}\n${lines.join('\n')}` };
122
+ }
123
+ if (rows.length === 0) return { text: '暂无可用派发档案(可在设置页「子 Agent 方案」创建)' };
124
+ const lines = rows.flatMap((row) => details(row, true));
125
+ return { text: `派发档案目录(${rows.length}):\n${lines.join('\n')}` };
126
+ }
127
+
128
+ // 查询主体:catalog 快照(含官方 efforts)→ summaries 统计(fail-soft)→ 官方
129
+ // 模型选择投影(未授权标注)→ 纯函数组装文本。
130
+ async function runProfilesQuery(args, exec, deps) {
131
+ const agent = exec !== null && typeof exec === 'object' ? exec.agent : undefined;
132
+ const snapshot = await deps.catalog.getSnapshot(typeof agent?.ctx?.get === 'function' ? agent.ctx.get('llm') : undefined);
133
+ const models = snapshot !== null && typeof snapshot === 'object' && Array.isArray(snapshot.models) ? snapshot.models : [];
134
+ const effortsByModel = snapshot !== null && typeof snapshot === 'object' && snapshot.efforts !== null && typeof snapshot.efforts === 'object' ? snapshot.efforts : {};
135
+ let summaries = null;
136
+ try { summaries = readSummaries(deps.summariesFile, deps.logger, { onLoss: deps.onLoss }); } catch { /* 统计只增强,查询不因统计失败而失败 */ }
137
+ const stats = profileStatsFromSummaries(deps.store, summaries);
138
+ const policy = readModelSelectionPolicy(agent?.ctx, agent?.session);
139
+ return buildProfilesQueryText({ args, rows: profileDirectoryRows(deps.store), stats, models, effortsByModel, policy });
140
+ }
141
+
142
+ // 工厂返回 { syncTool, dispose }:与 dispatch 同款 syncTool 机制(随 enabled 开关
143
+ // 注册/注销,工具列表自动可见,不依赖静态段)。
144
+ export function createProfilesQueryTool({ register, store, catalog, summariesFile, onLoss, logger, getEnabled }) {
145
+ const deps = { store, catalog, summariesFile, onLoss, logger, getEnabled };
146
+ const tool = defineTool({
147
+ name: 'profiles_query',
148
+ description: '按方案查询派发档案与明细(值不值:描述/档位/统计);授权路由(能不能用)请用官方 list_subagent_models。入参全可选:id 查单个档案;tier 按档位过滤;provider+model 成对查询(先查官方 efforts 再列绑定方案);无参返回全量目录。',
149
+ parameters: PROFILES_QUERY_PARAMETERS,
150
+ output: {
151
+ schema: PROFILES_QUERY_OUTPUT_SCHEMA,
152
+ render: (_args, value) => [{ type: 'text', text: value.text }],
153
+ },
154
+ isConcurrencySafe: () => true,
155
+ execute: (args, exec) => runProfilesQuery(args, exec, deps),
156
+ });
157
+ let disposeTool;
158
+ function syncTool() {
159
+ if (getEnabled() && disposeTool === undefined) {
160
+ disposeTool = register(tool);
161
+ } else if (!getEnabled() && disposeTool !== undefined) {
162
+ const dispose = disposeTool;
163
+ disposeTool = undefined;
164
+ if (typeof dispose === 'function') dispose();
165
+ }
166
+ }
167
+ syncTool();
168
+ return {
169
+ syncTool,
170
+ dispose() {
171
+ if (disposeTool !== undefined) {
172
+ const dispose = disposeTool;
173
+ disposeTool = undefined;
174
+ if (typeof dispose === 'function') dispose();
175
+ }
176
+ }
177
+ };
178
+ }
@@ -34,15 +34,30 @@ export function dshHome() {
34
34
  // 1. Profile 注册表(per-instance 状态;bundle 行是进程级,故本 Map 是单例
35
35
  // store,与动态插件完全一致)。内置种子带 `builtin: true`,reset/remove 可
36
36
  // 识别它们,改过的内置方案与纯用户 profile 可区分。
37
- // description 是语义化的:一行定位 + 何时用,帮助模型选择。reasoningEffort
37
+ // description 是语义化的:一行定位 + 何时用,帮助模型选择;description
38
+ // 须含适用场景词(落库契约,见 assertProfileDescription)。reasoningEffort
38
39
  // 档位经 llm-deepseek adapter 核验(off/high/max;见 resolveModel 对
39
40
  // connection.defaults.thinking 的门控——本部署未设 thinking,
40
41
  // 故对 deepseek-v4-flash 全档位宣传)。
41
42
  export const BUILTIN_SEEDS = [
42
- { id: 'swap-standard', name: '标准编码', description: '切换到 standard 预设的完整编码工具集。当父会话不是 standard、但子任务需要完整编码能力时用。', preset: 'standard', tokenTier: 'balanced', builtin: true },
43
- { id: 'researcher', name: '调研检索', description: '关闭深度推理省 token,继承父工具。适合查资料、汇总、背景调研,不适合改代码。', reasoningEffort: 'off', persona: 'You are a research subagent: search, read, and summarize only. Do not modify code or files.', tokenTier: 'cheap', builtin: true }
43
+ { id: 'swap-standard', name: '标准编码', description: '写代码与重构场景:切换到 standard 预设的完整编码工具集,适合多文件改动、修复与测试;当父会话不是 standard、但子任务需要完整编码能力时选用。', preset: 'standard', tokenTier: 'balanced', builtin: true },
44
+ { id: 'researcher', name: '调研检索', description: '调研与汇总场景:关闭深度推理省 token,继承父工具;适合查资料、检索、背景调研与只读分析,不适合改代码。', reasoningEffort: 'off', persona: 'You are a research subagent: search, read, and summarize only. Do not modify code or files.', tokenTier: 'cheap', builtin: true }
44
45
  ];
45
46
 
47
+ // description 落库契约词表:description 必须包含至少一个适用场景词(常量,可微调)。
48
+ export const DESCRIPTION_SCENARIO_WORDS = ['查', '调研', '检索', '汇总', '代码', '重构', '修复', '审查', '只读', '审计', '生成', '测试', '翻译', '编写', '改', '脚本', '分析'];
49
+
50
+ // description 落库校验(纯函数,zero-dep):非空(trim)+ 长度 ≥10 字符 + 含适用
51
+ // 场景词,任一不满足 fail-loud(文案定稿)。返回 trim 后的文本供调用方落库。
52
+ export function assertProfileDescription(description) {
53
+ const text = typeof description === 'string' ? description.trim() : '';
54
+ const valid = text.length >= 10 && DESCRIPTION_SCENARIO_WORDS.some((word) => text.includes(word));
55
+ if (!valid) {
56
+ throw new Error('description 需写明适用场景(如:查资料/周报汇总、多文件重构、只读审计),当前描述不含可用场景词');
57
+ }
58
+ return text;
59
+ }
60
+
46
61
  // --- 模块级 store 函数(从工厂拆出;per-apply 状态经 `state` 注入)---------------
47
62
 
48
63
  function resolveProfile(profiles, id) {
@@ -223,6 +238,20 @@ function persistState(state, patch, onGovernanceFailure) {
223
238
  }
224
239
  }
225
240
 
241
+ // 落库保存路径:description 值发生变化(新建或编辑改写)必须过契约校验;
242
+ // 与原值相同(存量未动)或未携带 description 时跳过——存量宽容不迁移。
243
+ // 校验失败 fail-loud,不落 Map。
244
+ function saveProfile(state, profile) {
245
+ if (profile !== null && typeof profile === 'object' && profile.description !== undefined) {
246
+ const existing = state.profiles.get(profile.id);
247
+ if (existing === undefined || existing.description !== profile.description) {
248
+ assertProfileDescription(profile.description);
249
+ }
250
+ }
251
+ state.profiles.set(profile.id, profile);
252
+ return profile;
253
+ }
254
+
226
255
  // 构造一个 store 实例。`dshHome` 是已解析的主目录字符串(来自导出的
227
256
  // dshHome());`logger` 是 apply 时的 ctx.logger。`onGovernanceFailure`(可选)
228
257
  // 是任何 profile/state 持久化写失败时触发的审计钩子——一次治理审计丢失事件。
@@ -244,6 +273,7 @@ export function createProfileStore({ dshHome, logger, onGovernanceFailure = () =
244
273
  profiles,
245
274
  deletedBuiltins,
246
275
  resolveProfile: (id) => resolveProfile(profiles, id),
276
+ saveProfile: (profile) => saveProfile(state, profile),
247
277
  loadProfiles: () => loadProfiles(state, logger),
248
278
  persistProfiles: () => {
249
279
  const result = persistProfiles(state, logger);
package/lib/core/pure.mjs CHANGED
@@ -369,7 +369,7 @@ export function sanitizeProfile(profile, options = {}) {
369
369
  case 'name':
370
370
  case 'description':
371
371
  // id/preset 与 name/description 同走文本采样:压平换行,阻断经 \n 注入
372
- // orchestrator systemPrompt 的提示注入面(H1)。id/preset 此前走 default
372
+ // orchestrator-v2 systemPrompt 的提示注入面(H1)。id/preset 此前走 default
373
373
  // 分支原样拷贝,可注入换行破坏 dispatch:profiles 行。
374
374
  sanitizeTextField(key, value, clean, warnings);
375
375
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-subagent-profile",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "DeepSeek Harness 子 Agent 派发插件:按任务为子代理选模型、推理强度与工具范围,常用组合存成命名方案随时复用;内置安全检查、成本估算与节省分析、完整决策台账。",
5
5
  "repository": {
6
6
  "type": "git",
@@ -79,4 +79,4 @@
79
79
  "profiles"
80
80
  ],
81
81
  "license": "MIT"
82
- }
82
+ }
@@ -0,0 +1,7 @@
1
+ agent.cordis.yml 改编自 DeepSeek Harness 内置 Minimal 与 Standard preset(MIT)。
2
+ tool-bootstrap.mjs 来自 https://github.com/xiaobright/dsh-anchored-standard(MIT),
3
+ 并包含 dsh-liangshen 的两阶段隔离扩展。
4
+ custom-bash.mjs 移植自同一上游(xiaobright/dsh-anchored-standard,MIT)的
5
+ combo-anchored/custom-bash.mjs,用于 win32 上以跨平台子进程通道提供同名
6
+ `bash` 工具(Git Bash)。
7
+ 原始 DeepSeek 版权和 MIT 许可声明见开源仓库。