@waterplus-ai/waterbuddy 0.1.37 → 0.1.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/assets/experts/digital/it-assessment.jpg +0 -0
  2. package/assets/experts/digital/it-assessment.md +42 -0
  3. package/assets/experts/digital/smart-water-assistant.jpg +0 -0
  4. package/assets/experts/digital/smart-water-assistant.md +42 -0
  5. package/assets/experts/digital/smart-water-case-expert.jpg +0 -0
  6. package/assets/experts/digital/smart-water-case-expert.md +45 -0
  7. package/assets/experts/digital/water-industry-brain.jpg +0 -0
  8. package/assets/experts/digital/water-industry-brain.md +46 -0
  9. package/assets/experts/drainage/drainage-sewage-ops.jpg +0 -0
  10. package/assets/experts/drainage/drainage-sewage-ops.md +32 -0
  11. package/assets/experts/drainage/flood-control-pumping.jpg +0 -0
  12. package/assets/experts/drainage/flood-control-pumping.md +42 -0
  13. package/assets/experts/general/article-writing.jpg +0 -0
  14. package/assets/experts/general/article-writing.md +32 -0
  15. package/assets/experts/general/award-application.jpg +0 -0
  16. package/assets/experts/general/award-application.md +32 -0
  17. package/assets/experts/general/benchmarking.jpg +0 -0
  18. package/assets/experts/general/benchmarking.md +42 -0
  19. package/assets/experts/general/data-analysis.jpg +0 -0
  20. package/assets/experts/general/data-analysis.md +32 -0
  21. package/assets/experts/general/evaluation-assistant.jpg +0 -0
  22. package/assets/experts/general/evaluation-assistant.md +32 -0
  23. package/assets/experts/general/event-planning.jpg +0 -0
  24. package/assets/experts/general/event-planning.md +32 -0
  25. package/assets/experts/general/excel-formula.jpg +0 -0
  26. package/assets/experts/general/excel-formula.md +32 -0
  27. package/assets/experts/general/it-coding.jpg +0 -0
  28. package/assets/experts/general/it-coding.md +32 -0
  29. package/assets/experts/general/meeting-minutes.jpg +0 -0
  30. package/assets/experts/general/meeting-minutes.md +32 -0
  31. package/assets/experts/general/summarization.jpg +0 -0
  32. package/assets/experts/general/summarization.md +32 -0
  33. package/assets/experts/general/video-script.jpg +0 -0
  34. package/assets/experts/general/video-script.md +32 -0
  35. package/assets/experts/general/work-report.jpg +0 -0
  36. package/assets/experts/general/work-report.md +32 -0
  37. package/assets/experts/management/admin-party-affairs.jpg +0 -0
  38. package/assets/experts/management/admin-party-affairs.md +42 -0
  39. package/assets/experts/management/group-management-assistant.jpg +0 -0
  40. package/assets/experts/management/group-management-assistant.md +42 -0
  41. package/assets/experts/management/hr-performance.jpg +0 -0
  42. package/assets/experts/management/hr-performance.md +42 -0
  43. package/assets/experts/management/procurement-contract-risk.jpg +0 -0
  44. package/assets/experts/management/procurement-contract-risk.md +32 -0
  45. package/assets/experts/operations/customer-service.jpg +0 -0
  46. package/assets/experts/operations/customer-service.md +32 -0
  47. package/assets/experts/operations/metering-revenue.jpg +0 -0
  48. package/assets/experts/operations/metering-revenue.md +42 -0
  49. package/assets/experts/policy/smart-water-standards.jpg +0 -0
  50. package/assets/experts/policy/smart-water-standards.md +46 -0
  51. package/assets/experts/policy/water-pricing.jpg +0 -0
  52. package/assets/experts/policy/water-pricing.md +46 -0
  53. package/assets/experts/quality/plant-water-quality.jpg +0 -0
  54. package/assets/experts/quality/plant-water-quality.md +32 -0
  55. package/assets/experts/supply/construction-safety.jpg +0 -0
  56. package/assets/experts/supply/construction-safety.md +42 -0
  57. package/assets/experts/supply/energy-equipment-ops.jpg +0 -0
  58. package/assets/experts/supply/energy-equipment-ops.md +32 -0
  59. package/assets/experts/supply/inspection-repair.jpg +0 -0
  60. package/assets/experts/supply/inspection-repair.md +32 -0
  61. package/assets/experts/supply/network-asset-leakage.jpg +0 -0
  62. package/assets/experts/supply/network-asset-leakage.md +42 -0
  63. package/assets/experts/supply/water-source-dispatch.jpg +0 -0
  64. package/assets/experts/supply/water-source-dispatch.md +42 -0
  65. package/lib/client/index.js +1021 -16
  66. package/lib/host/expert-store.js +245 -0
  67. package/lib/host/expert-tools.js +416 -0
  68. package/lib/host/experts.js +318 -0
  69. package/lib/host/index.js +281 -5
  70. package/lib/host/llm-gateway.js +32 -0
  71. package/package.json +11 -5
  72. package/patches/cordis.patch.yml +4 -0
@@ -0,0 +1,245 @@
1
+ /**
2
+ * 专家配置的持久化:启用白名单 + 自定义专家。
3
+ *
4
+ * **存在 `$DSH_HOME/waterbuddy-experts.json`,而不是 settings 命名空间**。宿主 settings 服务的
5
+ * `update()` 只对**已注册**的命名空间生效,未注册会直接抛
6
+ * `settings namespace "..." is not registered`;注册要传一个 schemastery schema,而本插件是
7
+ * 零依赖纯 JS 包,构造不了它。文件存储与本插件既有做法一致(见 `waterbuddy-device.json`),
8
+ * 也免得为了两个字段去引入宿主内部依赖。
9
+ *
10
+ * 两个设计点:
11
+ *
12
+ * - **`enabled` 是白名单,且只在用户动过开关后才落盘**。未配置时视为「内置专家全部启用」,
13
+ * 这样以后新增的内置专家默认可用,而不是被一份陈旧的清单永久挡在外面。
14
+ * - **写入带 `revision` 乐观并发**。两个窗口同时编辑时,落后的一方会被拒绝,而不是后写者
15
+ * 静默覆盖先写者。
16
+ */
17
+ import { randomUUID } from 'node:crypto'
18
+ import fs from 'node:fs'
19
+ import os from 'node:os'
20
+ import path from 'node:path'
21
+ import { EXPERT_DIVISIONS } from './experts.js'
22
+
23
+ /** 配置文件名,位于 `$DSH_HOME` 下。 */
24
+ export const EXPERTS_STATE_FILENAME = 'waterbuddy-experts.json'
25
+
26
+ /** 配置文件路径;`DSH_HOME` 与插件其它持久化文件同源。 */
27
+ export function expertStateFile() {
28
+ return path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), EXPERTS_STATE_FILENAME)
29
+ }
30
+
31
+ /** 自定义专家数量上限。 */
32
+ export const CUSTOM_EXPERT_LIMIT = 200
33
+
34
+ /** 自定义专家的 slug 前缀:用来把「只能读」的内置专家和「可改」的自定义专家分开。 */
35
+ export const CUSTOM_SLUG_PREFIX = 'custom-'
36
+
37
+ /** 各字段的长度上限(正文上限较大,persona 本来就是长提示词)。 */
38
+ export const CUSTOM_FIELD_LIMITS = { name: 60, description: 300, emoji: 8, prompt: 20000 }
39
+
40
+ /** 是否是我们生成的自定义专家 slug。 */
41
+ export function isCustomSlug(slug) {
42
+ return typeof slug === 'string' && slug.startsWith(CUSTOM_SLUG_PREFIX)
43
+ }
44
+
45
+ /** 把任意存储值收敛成合法状态:坏字段逐项丢弃,不让一条脏数据把整个页面打挂。 */
46
+ export function normalizeExpertState(raw) {
47
+ const value = raw && typeof raw === 'object' ? raw : {}
48
+ return {
49
+ // `undefined` = 从未配置 = 内置专家全开;空数组是明确的「全关」,两者不能混。
50
+ enabled: Array.isArray(value.enabled)
51
+ ? value.enabled.filter(slug => typeof slug === 'string' && slug.length > 0)
52
+ : undefined,
53
+ customExperts: Array.isArray(value.customExperts)
54
+ ? value.customExperts.filter(isStoredCustomExpert)
55
+ : [],
56
+ revision: Number.isSafeInteger(value.revision) && value.revision >= 0 ? value.revision : 0,
57
+ }
58
+ }
59
+
60
+ /** 存储里的自定义专家是否结构完整(长度与格式在写入侧校验,这里只保证读得出来)。 */
61
+ function isStoredCustomExpert(value) {
62
+ return Boolean(
63
+ value
64
+ && typeof value === 'object'
65
+ && isCustomSlug(value.slug)
66
+ && typeof value.name === 'string'
67
+ && typeof value.description === 'string'
68
+ && typeof value.division === 'string'
69
+ && typeof value.prompt === 'string',
70
+ )
71
+ }
72
+
73
+ /** 某位专家当前是否启用。 */
74
+ export function isExpertEnabled(state, slug) {
75
+ return state.enabled === undefined || state.enabled.includes(slug)
76
+ }
77
+
78
+ /**
79
+ * 校验并规范化一份来自客户端的自定义专家输入。
80
+ *
81
+ * @param input - 请求体里的 `expert`。
82
+ * @param options.slug - 已有 slug(编辑时传入;不传则新建)。
83
+ * @returns 规范化后的自定义专家。
84
+ * @throws 字段缺失或越界时抛出带错误码的 Error。
85
+ */
86
+ export function validateCustomExpert(input, options = {}) {
87
+ const source = input && typeof input === 'object' ? input : {}
88
+ const name = String(source.name || '').trim()
89
+ const description = String(source.description || '').trim()
90
+ const division = String(source.division || '').trim()
91
+ const prompt = String(source.prompt || '').trim()
92
+ const emoji = String(source.emoji || '').trim() || '🧠'
93
+
94
+ if (!name) throw new Error('EXPERT_NAME_REQUIRED: 名称不能为空')
95
+ if (!description) throw new Error('EXPERT_DESCRIPTION_REQUIRED: 描述不能为空')
96
+ if (!prompt) throw new Error('EXPERT_PROMPT_REQUIRED: 提示词不能为空')
97
+ if (!Object.prototype.hasOwnProperty.call(EXPERT_DIVISIONS, division)) {
98
+ throw new Error(`EXPERT_DIVISION_UNKNOWN: 未知分类 "${division}"`)
99
+ }
100
+ for (const [field, limit] of Object.entries(CUSTOM_FIELD_LIMITS)) {
101
+ const value = field === 'name' ? name : field === 'description' ? description : field === 'prompt' ? prompt : emoji
102
+ if (Array.from(value).length > limit) {
103
+ throw new Error(`EXPERT_FIELD_TOO_LONG: ${field} 超过 ${limit} 字上限`)
104
+ }
105
+ }
106
+ const avatar = String(source.avatar || '').trim()
107
+ if (avatar && !/^https?:\/\//i.test(avatar)) {
108
+ throw new Error('EXPERT_AVATAR_INVALID: 自定义专家的头像请填 http(s) 图片地址')
109
+ }
110
+ if (avatar.length > 500) throw new Error('EXPERT_FIELD_TOO_LONG: avatar 超过 500 字上限')
111
+ return {
112
+ slug: options.slug || `${CUSTOM_SLUG_PREFIX}${randomUUID()}`,
113
+ name,
114
+ description,
115
+ division,
116
+ emoji,
117
+ ...(avatar ? { avatar } : {}),
118
+ prompt,
119
+ }
120
+ }
121
+
122
+ /**
123
+ * 建立配置存储。
124
+ *
125
+ * @param options.file - 配置文件路径,默认 {@link expertStateFile}(测试注入临时文件)。
126
+ * @returns 读写接口。
127
+ */
128
+ export function createExpertStore(options = {}) {
129
+ const file = options.file || expertStateFile()
130
+ // 名册当前有哪些 slug(由调用方在目录加载完成后提供)。用来识别「白名单来自上一代名册」:
131
+ // 那种情况下它一个都对不上,若照字面当成「全关」,用户换一次名册就会看到全部专家被停用。
132
+ const knownSlugs = options.knownSlugs
133
+
134
+ const read = () => {
135
+ let state
136
+ try {
137
+ state = normalizeExpertState(JSON.parse(fs.readFileSync(file, 'utf8')))
138
+ } catch (error) {
139
+ if (error.code !== 'ENOENT') {
140
+ // 坏文件不能让设置页打不开:退回默认值并记一条 warning,下次写入会覆盖它。
141
+ console.warn('[WaterBuddy] expert config is unreadable:', error.message)
142
+ }
143
+ return normalizeExpertState(undefined)
144
+ }
145
+ if (state.enabled !== undefined && state.enabled.length > 0) {
146
+ const known = typeof knownSlugs === 'function' ? knownSlugs() : undefined
147
+ if (known && known.size > 0 && state.enabled.every(slug => !known.has(slug))) {
148
+ return { ...state, enabled: undefined }
149
+ }
150
+ }
151
+ return state
152
+ }
153
+
154
+ /** 先写临时文件再 rename:进程被杀也不会留下半截 JSON。 */
155
+ const write = next => {
156
+ const temporary = `${file}.tmp`
157
+ fs.mkdirSync(path.dirname(file), { recursive: true })
158
+ fs.writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 })
159
+ fs.renameSync(temporary, file)
160
+ try { fs.chmodSync(file, 0o600) } catch { /* 文件系统不支持就算了 */ }
161
+ return read()
162
+ }
163
+
164
+ /** 修订号校验:落后的一方直接拒绝,避免静默覆盖。 */
165
+ const checkRevision = expected => {
166
+ const current = read().revision
167
+ if (!Number.isSafeInteger(expected) || expected !== current) {
168
+ throw new Error(`EXPERT_REVISION_CONFLICT: 配置已被其他窗口修改(当前修订号 ${current})`)
169
+ }
170
+ return current
171
+ }
172
+
173
+ /** 写后回读确认:解析结果与预期不一致就当作没写成功。 */
174
+ const commit = next => {
175
+ const persisted = write(next)
176
+ if (persisted.revision !== next.revision) {
177
+ throw new Error('EXPERT_STATE_NOT_PERSISTED: 专家配置未落盘')
178
+ }
179
+ return persisted
180
+ }
181
+
182
+ return {
183
+ file,
184
+ read,
185
+ /**
186
+ * 整体替换启用白名单。
187
+ * @param enabled - 启用的 slug 列表。
188
+ * @param expectedRevision - 客户端读到的修订号。
189
+ */
190
+ async replaceEnabled(enabled, expectedRevision) {
191
+ const current = checkRevision(expectedRevision)
192
+ if (!Array.isArray(enabled) || enabled.some(slug => typeof slug !== 'string')) {
193
+ throw new Error('EXPERT_ENABLED_INVALID: 启用列表格式不正确')
194
+ }
195
+ return commit({
196
+ enabled: [...new Set(enabled)],
197
+ customExperts: read().customExperts,
198
+ revision: current + 1,
199
+ })
200
+ },
201
+ /**
202
+ * 新建或更新一位自定义专家。新建时自动启用。
203
+ * @param input - 客户端提交的专家字段。
204
+ * @param expectedRevision - 客户端读到的修订号。
205
+ */
206
+ async saveCustom(input, expectedRevision) {
207
+ const current = checkRevision(expectedRevision)
208
+ const previous = read()
209
+ const existing = input?.slug ? previous.customExperts.find(item => item.slug === input.slug) : undefined
210
+ if (input?.slug && !existing) {
211
+ throw new Error(`EXPERT_NOT_CUSTOM: ${input.slug} 不是可编辑的自定义专家`)
212
+ }
213
+ const expert = validateCustomExpert(input, existing ? { slug: existing.slug } : {})
214
+ const customExperts = existing
215
+ ? previous.customExperts.map(item => (item.slug === expert.slug ? expert : item))
216
+ : [...previous.customExperts, expert]
217
+ if (customExperts.length > CUSTOM_EXPERT_LIMIT) {
218
+ throw new Error(`EXPERT_CUSTOM_LIMIT: 自定义专家最多 ${CUSTOM_EXPERT_LIMIT} 位`)
219
+ }
220
+ // 新建的专家默认启用:白名单未配置时本来就「全开」,已配置成白名单才需要显式加入。
221
+ // 编辑已有专家不动启用状态——用户特意关掉的专家不该因为改个描述又被打开。
222
+ const enabled = existing || previous.enabled === undefined
223
+ ? previous.enabled
224
+ : [...new Set([...previous.enabled, expert.slug])]
225
+ return commit({ enabled, customExperts, revision: current + 1 })
226
+ },
227
+ /**
228
+ * 删除一位自定义专家,并从启用白名单里摘掉。
229
+ * @param slug - 自定义专家 slug。
230
+ * @param expectedRevision - 客户端读到的修订号。
231
+ */
232
+ async deleteCustom(slug, expectedRevision) {
233
+ const current = checkRevision(expectedRevision)
234
+ const previous = read()
235
+ if (!previous.customExperts.some(item => item.slug === slug)) {
236
+ throw new Error(`EXPERT_NOT_CUSTOM: ${slug} 不是可编辑的自定义专家`)
237
+ }
238
+ return commit({
239
+ enabled: previous.enabled === undefined ? undefined : previous.enabled.filter(item => item !== slug),
240
+ customExperts: previous.customExperts.filter(item => item.slug !== slug),
241
+ revision: current + 1,
242
+ })
243
+ },
244
+ }
245
+ }
@@ -0,0 +1,416 @@
1
+ /**
2
+ * 专家工具:把名册变成模型可召唤的专家分身。
3
+ *
4
+ * 三个工具:
5
+ * - `list_experts` 浏览名册(无参只回分区与计数,省 token)
6
+ * - `summon_expert` 召唤单个专家
7
+ * - `summon_experts` 并行召唤一队专家(有上限与并发约束)
8
+ *
9
+ * 召唤走 `ctx.subagents`:把 persona 注入子代理(provider 的 `persona` 能力),
10
+ * 并用 `toolFilter` 禁掉这三个工具本身——否则专家会继续召唤专家,无限套娃。
11
+ */
12
+ import { EXPERT_DIVISIONS, readExpertPersona } from './experts.js'
13
+
14
+ /** 三个工具的名字。`summon*` 会互相禁用,列表见 {@link EXPERT_SUMMON_DENY}。 */
15
+ export const EXPERT_TOOL_NAMES = ['list_experts', 'summon_expert', 'summon_experts']
16
+
17
+ /** 子代理里要禁掉的工具:阻止专家递归召唤。 */
18
+ export const EXPERT_SUMMON_DENY = [...EXPERT_TOOL_NAMES]
19
+
20
+ /** 一次批量召唤的专家数上限。 */
21
+ export const SUMMON_EXPERTS_MAX = 8
22
+
23
+ /** 批量召唤的并发上限。 */
24
+ export const SUMMON_EXPERTS_CONCURRENCY = 4
25
+
26
+ /** 单个任务的码点上限(按码点算,避免 emoji 被误判超长)。 */
27
+ export const SUMMON_TASK_MAX_CHARS = 8000
28
+
29
+ /** 名册里回给模型的描述截断长度。 */
30
+ export const EXPERT_DESCRIPTION_MAX_CHARS = 120
31
+
32
+ /** 默认子代理 provider:内置 `providerName: spawn` 支持 persona/toolFilter/depthLimit。 */
33
+ export const DEFAULT_EXPERT_PROVIDER = 'spawn'
34
+
35
+ /** provider 必须声明这两项能力,否则召唤会静默丢掉专家身份或防套娃。 */
36
+ const REQUIRED_PROVIDER_CAPABILITIES = ['persona', 'toolFilter']
37
+
38
+ /**
39
+ * 受限并发映射,且**保持结果顺序与输入一致**。
40
+ *
41
+ * 没有用 `Promise.all(items.map(...))`:那样会一次性起 N 个子代理。
42
+ * @param items - 待处理项。
43
+ * @param limit - 并发上限(小于 1 时按 1 处理)。
44
+ * @param worker - 处理单项,接收 `(item, index)`。
45
+ * @returns 与 `items` 等长、顺序一致的结果数组。
46
+ */
47
+ export async function mapPool(items, limit, worker) {
48
+ const results = new Array(items.length)
49
+ const width = Math.max(1, Math.min(Number(limit) || 1, items.length || 1))
50
+ let next = 0
51
+ const run = async () => {
52
+ while (true) {
53
+ const index = next
54
+ next += 1
55
+ if (index >= items.length) return
56
+ results[index] = await worker(items[index], index)
57
+ }
58
+ }
59
+ await Promise.all(Array.from({ length: width }, run))
60
+ return results
61
+ }
62
+
63
+ /** 按码点计算长度:`.length` 会把 emoji 算成 2,导致误判超长。 */
64
+ function codePointLength(text) {
65
+ return Array.from(String(text)).length
66
+ }
67
+
68
+ /** 把子代理输出的 ContentBlock 数组拼成纯文本。 */
69
+ function blocksToText(blocks) {
70
+ if (!Array.isArray(blocks)) return ''
71
+ return blocks
72
+ .filter(block => block && block.type === 'text' && typeof block.text === 'string')
73
+ .map(block => block.text)
74
+ .join('\n')
75
+ .trim()
76
+ }
77
+
78
+ /**
79
+ * 非 `completed` 的终止原因都算失败——与内置 subagent 工具同一口径,
80
+ * 避免把半截输出当成成功结果交给模型。
81
+ * @param stopReason - 子代理的终止原因。
82
+ * @returns 失败说明;`completed` 返回 `undefined`。
83
+ */
84
+ function stopReasonError(stopReason) {
85
+ switch (stopReason) {
86
+ case 'completed':
87
+ return undefined
88
+ case 'aborted':
89
+ return '专家分身被取消'
90
+ case 'error':
91
+ return '专家分身运行失败'
92
+ case 'max-tokens':
93
+ return '专家分身达到 token 上限而未完成'
94
+ case 'refusal':
95
+ return '专家分身拒绝执行该任务'
96
+ default:
97
+ // 可扩展联合:不认识的终止原因按失败处理,而不是把半截结果当成功。
98
+ return `专家分身异常结束(${String(stopReason)})`
99
+ }
100
+ }
101
+
102
+ /**
103
+ * 解析并校验子代理 provider。
104
+ *
105
+ * 缺 provider 或缺能力都必须显式失败:静默降级会表现为"专家召唤成功但回答的是通用助手",
106
+ * 比报错更难排查。
107
+ * @param subagents - `ctx.subagents`。
108
+ * @param config - 插件配置。
109
+ * @returns `{ providerName, provider }`。
110
+ */
111
+ export function resolveExpertProvider(subagents, config) {
112
+ const providerName = String(config?.expertProvider || DEFAULT_EXPERT_PROVIDER).trim()
113
+ if (!subagents?.getProvider) {
114
+ throw new Error('EXPERT_SUBAGENTS_UNAVAILABLE: 宿主没有 subagents 服务,专家召唤不可用')
115
+ }
116
+ const provider = subagents.getProvider(providerName)
117
+ if (!provider) {
118
+ throw new Error(`EXPERT_PROVIDER_UNAVAILABLE: 子代理 provider "${providerName}" 未注册`)
119
+ }
120
+ const missing = REQUIRED_PROVIDER_CAPABILITIES.filter(name => provider.capabilities?.[name] !== true)
121
+ if (missing.length > 0) {
122
+ throw new Error(
123
+ `EXPERT_PROVIDER_INCAPABLE: provider "${providerName}" 缺少 ${missing.join('、')} 能力`,
124
+ )
125
+ }
126
+ return { providerName, provider }
127
+ }
128
+
129
+ /** 按 slug 找专家;找不到时同时接受精确名字,并把候选写进报错里让模型自我纠正。 */
130
+ function findExpert(catalog, reference) {
131
+ const wanted = String(reference || '').trim()
132
+ if (!wanted) throw new Error('EXPERT_REQUIRED: 必须给出专家 slug 或名称')
133
+ const bySlug = catalog.bySlug.get(wanted.toLowerCase())
134
+ if (bySlug) return bySlug
135
+ const all = [...catalog.bySlug.values()]
136
+ const byName = all.find(expert => expert.name === wanted)
137
+ if (byName) return byName
138
+ const partial = all.filter(expert => expert.name.includes(wanted) || expert.slug.includes(wanted.toLowerCase()))
139
+ const hint = partial.length > 0
140
+ ? `。相近的专家:${partial.slice(0, 5).map(expert => expert.slug).join('、')}`
141
+ : `。用 list_experts 查看名册`
142
+ throw new Error(`EXPERT_NOT_FOUND: 找不到专家 "${wanted}"${hint}`)
143
+ }
144
+
145
+ /** 校验任务文本。 */
146
+ function requireTask(task) {
147
+ const text = String(task || '').trim()
148
+ if (!text) throw new Error('EXPERT_TASK_REQUIRED: 必须给出要专家完成的任务')
149
+ if (codePointLength(text) > SUMMON_TASK_MAX_CHARS) {
150
+ throw new Error(`EXPERT_TASK_TOO_LONG: 任务超过 ${SUMMON_TASK_MAX_CHARS} 字上限`)
151
+ }
152
+ return text
153
+ }
154
+
155
+ /**
156
+ * 构造三个工具定义。
157
+ *
158
+ * 名册用 `getCatalog()` 惰性获取而不是直接传入:注册发生在同步的 `apply` 里,
159
+ * 而名册读取是异步的;工具执行本身就是 async,等第一次调用时再取即可。
160
+ *
161
+ * @param options.getCatalog - 返回名册(Promise),结果会被缓存。
162
+ * @param options.getEnabled - 返回启用白名单(`undefined` = 全部启用),同步读取。
163
+ * @param options.config - 插件配置(读 `expertProvider`、`expertMaxDepth`)。
164
+ * @param options.subagents - `ctx.subagents`,测试里用替身注入。
165
+ * @returns 可直接交给 `ctx.tools.register` 的工具定义数组。
166
+ */
167
+ export function createExpertToolDefinitions({ getCatalog, getEnabled, config, subagents }) {
168
+ /**
169
+ * 该专家当前是否启用。`undefined` 表示「未配置 = 全部启用」(见 expert-store)。
170
+ * 每次执行都重新读,所以用户在设置页一开关就立刻生效,不需要重启。
171
+ */
172
+ const isEnabled = slug => {
173
+ const enabled = typeof getEnabled === 'function' ? getEnabled() : undefined
174
+ return enabled === undefined || enabled.includes(slug)
175
+ }
176
+
177
+ /** 只保留启用中的专家;一个分类被全部关掉时整条不显示,免得名册里冒出空分类。 */
178
+ const visibleDivisions = catalog => catalog.divisions
179
+ .map(division => ({ ...division, experts: division.experts.filter(expert => isEnabled(expert.slug)) }))
180
+ .filter(division => division.experts.length > 0)
181
+
182
+ /** 名册总览:分区永远给(便宜),指定分区时才附专家列表。 */
183
+ const divisionOverview = catalog => visibleDivisions(catalog).map(division => ({
184
+ slug: division.slug,
185
+ name: division.name,
186
+ count: division.experts.length,
187
+ }))
188
+
189
+ const expertBrief = expert => ({
190
+ slug: expert.slug,
191
+ name: expert.name,
192
+ emoji: expert.emoji,
193
+ description: codePointLength(expert.description) > EXPERT_DESCRIPTION_MAX_CHARS
194
+ ? `${Array.from(expert.description).slice(0, EXPERT_DESCRIPTION_MAX_CHARS).join('')}…`
195
+ : expert.description,
196
+ })
197
+
198
+ /** 召唤一个专家并等它跑完;失败按仓库口径抛出(注册表转成 isError)。 */
199
+ async function summonOnce(expert, task, exec) {
200
+ if (!exec?.agent) {
201
+ throw new Error('EXPERT_REQUIRES_AGENT: 专家召唤需要所属的 agent 会话')
202
+ }
203
+ // 停用的专家必须显式拒绝:静默"召唤成功"会让人以为开关没生效。
204
+ if (!isEnabled(expert.slug)) {
205
+ throw new Error(`EXPERT_DISABLED: 专家 ${expert.slug} 已在设置里停用`)
206
+ }
207
+ const { providerName, provider } = resolveExpertProvider(subagents, config)
208
+ const persona = await readExpertPersona(expert)
209
+ if (!persona) {
210
+ throw new Error(`EXPERT_PERSONA_EMPTY: ${expert.slug} 的正文为空`)
211
+ }
212
+ const maxDepth = Number(config?.expertMaxDepth)
213
+ const run = await subagents.start(providerName, {
214
+ label: `expert:${expert.slug}`,
215
+ prompt: [{ type: 'text', text: task }],
216
+ parent: exec.agent,
217
+ persona,
218
+ // 禁掉召唤工具:专家不该再召唤专家。
219
+ toolFilter: { deny: EXPERT_SUMMON_DENY },
220
+ ...(Number.isSafeInteger(maxDepth) && maxDepth >= 0 ? { maxDepth } : {}),
221
+ signal: exec.signal,
222
+ })
223
+ try {
224
+ const result = await run.result
225
+ const output = blocksToText(result.output)
226
+ const failure = stopReasonError(result.stopReason)
227
+ if (failure !== undefined) {
228
+ throw new Error(`${failure}:${output || '(无输出)'}`)
229
+ }
230
+ return { expert: expert.slug, name: expert.name, provider: providerName, output }
231
+ } finally {
232
+ // 无论成功失败都要释放子代理资源。
233
+ await run.dispose()
234
+ }
235
+ }
236
+
237
+ return [
238
+ {
239
+ name: 'list_experts',
240
+ description:
241
+ '浏览专家名册。不传 division 时只返回分区与各分区专家数量;传了 division 才返回该分区的专家名单。',
242
+ // 注意:本插件是纯 JS、绕过了 `defineTool`,所以 parameters / output.schema 都必须写成
243
+ // **原始 JSON Schema**(`required` 是对象级的字符串数组)。宿主里的内置工具用的是
244
+ // 属性级 `required: true` 的「spec 方言」,那由 `defineTool` 转换——我们不经过它。
245
+ parameters: {
246
+ type: 'object',
247
+ additionalProperties: false,
248
+ properties: {
249
+ division: {
250
+ type: 'string',
251
+ description: `分区 slug,可选值:${Object.keys(EXPERT_DIVISIONS).join('、')}`,
252
+ },
253
+ },
254
+ },
255
+ output: {
256
+ schema: {
257
+ type: 'object',
258
+ additionalProperties: false,
259
+ required: ['divisions', 'experts'],
260
+ properties: {
261
+ divisions: { type: 'array', items: { type: 'object' } },
262
+ experts: { type: 'array', items: { type: 'object' } },
263
+ },
264
+ },
265
+ render: (_args, value) => [{
266
+ type: 'text',
267
+ text: value.experts.length > 0
268
+ ? `专家名单(${value.experts.length} 位):\n${value.experts
269
+ .map(expert => `- ${expert.slug}(${expert.name}):${expert.description}`)
270
+ .join('\n')}`
271
+ : `专家分区共 ${value.divisions.length} 个:\n${value.divisions
272
+ .map(division => `- ${division.slug}(${division.name}):${division.count} 位`)
273
+ .join('\n')}\n用 division 参数查看某个分区的专家。`,
274
+ }],
275
+ },
276
+ async execute(args) {
277
+ const catalog = await getCatalog()
278
+ const divisions = visibleDivisions(catalog)
279
+ const division = String(args?.division || '').trim()
280
+ if (!division) {
281
+ return { divisions: divisionOverview(catalog), experts: [] }
282
+ }
283
+ const matched = divisions.find(item => item.slug === division || item.name === division)
284
+ if (!matched) {
285
+ throw new Error(
286
+ `EXPERT_DIVISION_UNKNOWN: 没有分区 "${division}",可选:${divisions.map(item => item.slug).join('、')}`,
287
+ )
288
+ }
289
+ return {
290
+ divisions: divisionOverview(catalog),
291
+ experts: matched.experts.map(expertBrief),
292
+ }
293
+ },
294
+ },
295
+
296
+ {
297
+ name: 'summon_expert',
298
+ description:
299
+ '召唤一位专家分身执行任务,并等它返回结果。专家是独立的子代理,拥有该领域的系统提示词与完整的常备工具集。',
300
+ parameters: {
301
+ type: 'object',
302
+ additionalProperties: false,
303
+ required: ['expert', 'task'],
304
+ properties: {
305
+ expert: {
306
+ type: 'string',
307
+ description: '专家 slug(用 list_experts 查询),也接受精确的专家名称',
308
+ },
309
+ task: {
310
+ type: 'string',
311
+ description: `交给该专家的任务,尽量写清背景、已知条件与要交付的东西(上限 ${SUMMON_TASK_MAX_CHARS} 字)`,
312
+ },
313
+ },
314
+ },
315
+ output: {
316
+ schema: {
317
+ type: 'object',
318
+ additionalProperties: false,
319
+ required: ['expert', 'name', 'provider', 'output'],
320
+ properties: {
321
+ expert: { type: 'string' },
322
+ name: { type: 'string' },
323
+ provider: { type: 'string' },
324
+ output: { type: 'string' },
325
+ },
326
+ },
327
+ render: (_args, value) => [{
328
+ type: 'text',
329
+ text: `【${value.name}】\n${value.output}`,
330
+ }],
331
+ },
332
+ async execute(args, exec) {
333
+ const catalog = await getCatalog()
334
+ const expert = findExpert(catalog, args?.expert)
335
+ const task = requireTask(args?.task)
336
+ return summonOnce(expert, task, exec)
337
+ },
338
+ },
339
+
340
+ {
341
+ name: 'summon_experts',
342
+ description:
343
+ `并行召唤多位专家,每个专家拿到自己的任务。最多 ${SUMMON_EXPERTS_MAX} 位,同时最多跑 ${SUMMON_EXPERTS_CONCURRENCY} 位。`
344
+ + '单个专家失败不影响其他专家,结果里会逐条标明。',
345
+ parameters: {
346
+ type: 'object',
347
+ additionalProperties: false,
348
+ required: ['items'],
349
+ properties: {
350
+ items: {
351
+ type: 'array',
352
+ description: '要召唤的专家与各自的任务',
353
+ items: {
354
+ type: 'object',
355
+ additionalProperties: false,
356
+ required: ['expert', 'task'],
357
+ properties: {
358
+ expert: { type: 'string', description: '专家 slug' },
359
+ task: { type: 'string', description: '交给该专家的任务' },
360
+ },
361
+ },
362
+ },
363
+ },
364
+ },
365
+ output: {
366
+ schema: {
367
+ type: 'object',
368
+ additionalProperties: false,
369
+ required: ['results'],
370
+ properties: {
371
+ results: { type: 'array', items: { type: 'object' } },
372
+ },
373
+ },
374
+ render: (_args, value) => {
375
+ const ok = value.results.filter(item => item.ok).length
376
+ const lines = value.results.map((item, index) => {
377
+ const head = `${index + 1}. ${item.expert}`
378
+ return item.ok ? `${head}\n${item.output}` : `${head} —— 失败:${item.error}`
379
+ })
380
+ return [{
381
+ type: 'text',
382
+ text: `召唤 ${value.results.length} 位专家,成功 ${ok} 位,失败 ${value.results.length - ok} 位。\n\n${lines.join('\n\n')}`,
383
+ }]
384
+ },
385
+ },
386
+ async execute(args, exec) {
387
+ const catalog = await getCatalog()
388
+ const items = args?.items
389
+ if (!Array.isArray(items) || items.length === 0) {
390
+ throw new Error('EXPERT_ITEMS_REQUIRED: 必须给出要召唤的专家列表')
391
+ }
392
+ if (items.length > SUMMON_EXPERTS_MAX) {
393
+ throw new Error(`EXPERT_ITEMS_TOO_MANY: 一次最多召唤 ${SUMMON_EXPERTS_MAX} 位专家`)
394
+ }
395
+ // 先整体校验:坏参数应当在起任何子代理之前失败,否则会留下半跑的专家。
396
+ const plan = items.map((item, index) => {
397
+ try {
398
+ return { expert: findExpert(catalog, item?.expert), task: requireTask(item?.task) }
399
+ } catch (error) {
400
+ throw new Error(`EXPERT_ITEM_INVALID: 第 ${index + 1} 项:${error.message}`)
401
+ }
402
+ })
403
+ const results = await mapPool(plan, SUMMON_EXPERTS_CONCURRENCY, async ({ expert, task }) => {
404
+ try {
405
+ const settled = await summonOnce(expert, task, exec)
406
+ return { ok: true, expert: expert.slug, name: expert.name, output: settled.output }
407
+ } catch (error) {
408
+ // 单个专家失败不拖垮整批:把失败收敛成结果项。
409
+ return { ok: false, expert: expert.slug, name: expert.name, error: error.message }
410
+ }
411
+ })
412
+ return { results }
413
+ },
414
+ },
415
+ ]
416
+ }