dsh-agentic-tool-catalog 0.0.0-stage → 0.3.2
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/CHANGELOG.md +63 -0
- package/LICENSE +21 -0
- package/README.md +173 -2
- package/README.zh.md +135 -0
- package/cordis.patch.yml +29 -0
- package/docs/assets/plugin-page.png +0 -0
- package/docs/assets/ui-dock.png +0 -0
- package/docs/assets/ui-panel.png +0 -0
- package/docs/benchmark.md +54 -0
- package/docs/data-card.md +96 -0
- package/docs/detail-benchmark.svg +30 -0
- package/docs/detail-hero.svg +57 -0
- package/docs/detail.md +33 -0
- package/docs/detail.zh.md +30 -0
- package/icon.svg +15 -0
- package/lib/catalog.js +208 -0
- package/lib/client.js +368 -0
- package/lib/generation.js +342 -0
- package/lib/index.js +795 -0
- package/lib/store.js +108 -0
- package/lib/subagent-compat.js +87 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +90 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-agentic-tool-catalog —— 由 agent 维护的按需工具目录。
|
|
3
|
+
*
|
|
4
|
+
* 核心:目录变化时由 DSH 原生子代理生成一次分组(agent-curated),插件严格校验后
|
|
5
|
+
* 按目录指纹缓存到 **官方 Storage**(ctx.storageDomain),失败自动重试/沿用最近一次成功分组,
|
|
6
|
+
* 生成完成前用扁平目录过渡;模型通过 `search_tools` 按需加载,`catalog_status` 只读观测。
|
|
7
|
+
*
|
|
8
|
+
* 机制要点(2026-11-10/11 对照 0.2.2-rc.1 源码核对):
|
|
9
|
+
* - 目录来自 `ctx.tools.definitions(scope)`(公开 API);
|
|
10
|
+
* - 用 `agent.ctx.tools.restrict({ deny })` 把未加载工具连 schema 从请求里移除;
|
|
11
|
+
* - restriction 变更会同步触发 `tools/change`,必须带重入守卫;
|
|
12
|
+
* - restriction 释放由插件实例 effect 持有(全局登记表跨实例清理)。
|
|
13
|
+
*
|
|
14
|
+
* @module @local/dsh-agentic-tool-catalog
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
|
|
18
|
+
import z from '@deepseek-ai/schemastery'
|
|
19
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
20
|
+
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
|
21
|
+
|
|
22
|
+
import { buildBootstrapCatalog, catalogSummary, noteText, rankTools, residentMatcher } from './catalog.js'
|
|
23
|
+
import { adaptAgentGroups, catalogFromAgentGroups, createAgentGrouper, validateAgentGroups } from './generation.js'
|
|
24
|
+
import { createCatalogStore } from './store.js'
|
|
25
|
+
|
|
26
|
+
/** Cordis 插件名(Loader 行 id 用它)。 */
|
|
27
|
+
export const name = 'dsh-agentic-tool-catalog'
|
|
28
|
+
|
|
29
|
+
/** 需要工具注册表。 */
|
|
30
|
+
export const inject = ['tools']
|
|
31
|
+
|
|
32
|
+
/** 运行期配置。 */
|
|
33
|
+
export const Config = z.object({
|
|
34
|
+
/** 一次普通 search 最多加载多少个工具。 */
|
|
35
|
+
maxResults: z.number().default(6),
|
|
36
|
+
/** 整组加载上限(query 精确等于组名时)。 */
|
|
37
|
+
groupMaxResults: z.number().default(30),
|
|
38
|
+
/** 是否在系统提示里加动态发现提示。 */
|
|
39
|
+
promptNote: z.boolean().default(true),
|
|
40
|
+
/** 覆盖内置常驻清单(空数组 = 用内置默认)。 */
|
|
41
|
+
resident: z.array(z.string()).default([]),
|
|
42
|
+
/** 往常驻清单追加 glob。 */
|
|
43
|
+
extraKeepResident: z.array(z.string()).default([]),
|
|
44
|
+
/** 强制按需(glob),优先于常驻。 */
|
|
45
|
+
extraDeferred: z.array(z.string()).default([]),
|
|
46
|
+
/** 生成子代理 provider(DSH subagent 服务里的注册名)。 */
|
|
47
|
+
catalogProvider: z.string().default('spawn'),
|
|
48
|
+
/** 单次生成超时(毫秒)。 */
|
|
49
|
+
catalogTimeoutMs: z.number().default(120000),
|
|
50
|
+
/** 缓存命名空间:改这个值会让所有目录指纹失效、强制重新生成(运维/调试用)。 */
|
|
51
|
+
catalogResetNonce: z.string().default(''),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/** 截断长描述,避免搜索结果膨胀。 */
|
|
55
|
+
function truncate(text, limit) {
|
|
56
|
+
const value = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
57
|
+
return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 跨插件实例的 restriction 登记(清理 HMR/重载遗留;兼容 v1 WeakMap)。 */
|
|
61
|
+
const REGISTRY = Symbol.for('@local/dsh-agentic-tool-catalog/restrictions')
|
|
62
|
+
/** 旧插件身份(重命名迁移用):清理旧实例/旧 bundle 留下的 restriction,避免工具表残缺。 */
|
|
63
|
+
const LEGACY_REGISTRY = Symbol.for('@local/dsh-tool-discovery/restrictions')
|
|
64
|
+
|
|
65
|
+
function restrictionRegistry() {
|
|
66
|
+
const existing = globalThis[REGISTRY]
|
|
67
|
+
if (existing instanceof Map) return existing
|
|
68
|
+
const registry = new Map()
|
|
69
|
+
globalThis[REGISTRY] = registry
|
|
70
|
+
return registry
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function disposeEntry(entry) {
|
|
74
|
+
try {
|
|
75
|
+
if (typeof entry === 'function') entry()
|
|
76
|
+
else if (entry !== null && typeof entry === 'object' && typeof entry.dispose === 'function') entry.dispose()
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const SHARED = Symbol.for('@local/dsh-agentic-tool-catalog/shared-v1')
|
|
81
|
+
|
|
82
|
+
/** 进程级共享状态:HMR 换插件实例时缓存/统计不丢(globalThis 跨模块实例保留)。 */
|
|
83
|
+
function sharedState() {
|
|
84
|
+
let shared = globalThis[SHARED]
|
|
85
|
+
if (shared === undefined) {
|
|
86
|
+
shared = {
|
|
87
|
+
instanceSeq: 0,
|
|
88
|
+
agentStats: {
|
|
89
|
+
runs: 0,
|
|
90
|
+
cacheHits: 0,
|
|
91
|
+
failures: 0,
|
|
92
|
+
retries: 0,
|
|
93
|
+
deduped: 0,
|
|
94
|
+
staleUses: 0,
|
|
95
|
+
incrementalRuns: 0,
|
|
96
|
+
lastError: null,
|
|
97
|
+
lastOkAt: null,
|
|
98
|
+
},
|
|
99
|
+
catalogCache: new Map(), // fingerprint -> 已构建目录(热路径)
|
|
100
|
+
groups: new Map(), // fingerprint -> 原始分组(来自官方 Storage / 本进程生成)
|
|
101
|
+
lastGood: null, // { fingerprint, groups, at }
|
|
102
|
+
failures: new Map(), // fingerprint -> 失败时间(退避)
|
|
103
|
+
inflight: new Map(), // fingerprint -> Promise<目录|null>(单飞)
|
|
104
|
+
lastReload: null,
|
|
105
|
+
}
|
|
106
|
+
globalThis[SHARED] = shared
|
|
107
|
+
}
|
|
108
|
+
return shared
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 注册 search_tools / catalog_status,并按 agent 维护"常驻 + 按需"两类工具。
|
|
113
|
+
* @param ctx - 带 `tools` 服务的 profile 上下文。
|
|
114
|
+
* @param config - patch 行提供的配置。
|
|
115
|
+
*/
|
|
116
|
+
export function apply(ctx, config) {
|
|
117
|
+
const instance = Symbol('instance')
|
|
118
|
+
const shared = sharedState()
|
|
119
|
+
const instanceId = ++shared.instanceSeq
|
|
120
|
+
// 新实例 = 新代码:不继承上一版留下的退避/暂停,否则热修后还要等 10 分钟才重试。
|
|
121
|
+
shared.failures.clear()
|
|
122
|
+
delete shared.agentStats.paused
|
|
123
|
+
const registry = restrictionRegistry()
|
|
124
|
+
const logger = ctx.logger
|
|
125
|
+
const bootAt = Date.now() // 区分“进程启动后新建会话”(预热)与“恢复会话”(按需)
|
|
126
|
+
|
|
127
|
+
for (const [agent, entry] of [...registry.entries()]) {
|
|
128
|
+
if (entry !== null && typeof entry === 'object' && entry.instance === instance) continue
|
|
129
|
+
disposeEntry(entry)
|
|
130
|
+
registry.delete(agent)
|
|
131
|
+
}
|
|
132
|
+
// 重命名迁移:旧 bundle 被 remove/替换时,其 restriction 可能没被旧实例 effect 清掉(实测孤儿限制),
|
|
133
|
+
// 这里主动释放旧全局登记表,避免新会话工具表只剩少数工具。
|
|
134
|
+
const legacy = globalThis[LEGACY_REGISTRY]
|
|
135
|
+
if (legacy instanceof Map) {
|
|
136
|
+
for (const [, entry] of [...legacy.entries()]) disposeEntry(entry)
|
|
137
|
+
legacy.clear()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const isResident = residentMatcher(config)
|
|
141
|
+
const maxResults = Math.max(1, Math.min(20, config.maxResults ?? 6))
|
|
142
|
+
const groupMaxResults = Math.max(1, config.groupMaxResults ?? 30)
|
|
143
|
+
const agentGrouper = createAgentGrouper(ctx, config, logger, shared.agentStats)
|
|
144
|
+
const store = createCatalogStore(ctx, shared, logger)
|
|
145
|
+
const resetNonce = typeof config.catalogResetNonce === 'string' ? config.catalogResetNonce : ''
|
|
146
|
+
|
|
147
|
+
/** SSE 订阅:sessionId → Set<ServerResponse>;只承载目录/会话状态快照,不推送正文。 */
|
|
148
|
+
const subscribers = new Map()
|
|
149
|
+
/** 每个 SSE 连接最后发送的 payload:相同快照不重复推送。 */
|
|
150
|
+
const lastEventPayload = new WeakMap()
|
|
151
|
+
/** 由 snapshotFor 注入;applyRestriction/rebuild 在运行时调用。 */
|
|
152
|
+
let publishSnapshot = () => {}
|
|
153
|
+
const sendEvent = (res, payload) => {
|
|
154
|
+
if (lastEventPayload.get(res) === payload) return
|
|
155
|
+
lastEventPayload.set(res, payload)
|
|
156
|
+
try { res.write(payload) } catch {}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const states = new WeakMap()
|
|
160
|
+
const stateOf = (agent) => {
|
|
161
|
+
let state = states.get(agent)
|
|
162
|
+
if (state === undefined) {
|
|
163
|
+
state = {
|
|
164
|
+
catalog: null,
|
|
165
|
+
loaded: new Set(),
|
|
166
|
+
denier: undefined,
|
|
167
|
+
denyKey: '',
|
|
168
|
+
warnings: [],
|
|
169
|
+
fingerprint: '',
|
|
170
|
+
source: 'bootstrap',
|
|
171
|
+
cacheOrigin: 'none',
|
|
172
|
+
activated: false,
|
|
173
|
+
needsEnrichment: false,
|
|
174
|
+
deferred: [],
|
|
175
|
+
resident: [],
|
|
176
|
+
enrichment: { pending: undefined, active: false, dirty: false },
|
|
177
|
+
}
|
|
178
|
+
states.set(agent, state)
|
|
179
|
+
}
|
|
180
|
+
return state
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const disposeRestriction = (agent, state = states.get(agent)) => {
|
|
184
|
+
if (state?.denier !== undefined) {
|
|
185
|
+
try { state.denier() } catch {}
|
|
186
|
+
state.denier = undefined
|
|
187
|
+
state.denyKey = ''
|
|
188
|
+
}
|
|
189
|
+
const entry = registry.get(agent)
|
|
190
|
+
if (entry !== undefined) {
|
|
191
|
+
disposeEntry(entry)
|
|
192
|
+
registry.delete(agent)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 该 agent 可被 restriction 过滤的工具名(继承层;own-layer 不可过滤)。 */
|
|
197
|
+
const restrictableNamesOf = (agent) => {
|
|
198
|
+
const scope = scopeOf(agent.ctx)
|
|
199
|
+
if (scope !== undefined) {
|
|
200
|
+
try {
|
|
201
|
+
const view = ctx.tools.view(scope)
|
|
202
|
+
if (view?.restrictableNames instanceof Set) return view.restrictableNames
|
|
203
|
+
} catch {}
|
|
204
|
+
}
|
|
205
|
+
try { return new Set(ctx.tools.schemas().map((s) => s.name)) } catch { return new Set() }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 该 agent 自己的可见工具定义(含 preset 组合)。 */
|
|
209
|
+
const definitionsOf = (agent) => {
|
|
210
|
+
const scope = scopeOf(agent.ctx)
|
|
211
|
+
if (scope === undefined) return []
|
|
212
|
+
try {
|
|
213
|
+
if (typeof ctx.tools.definitions === 'function') return ctx.tools.definitions(scope)
|
|
214
|
+
return ctx.tools.schemas(scope)
|
|
215
|
+
} catch (error) {
|
|
216
|
+
logger?.warn?.(`dsh-agentic-tool-catalog: 读取 agent 工具表失败:${String(error?.message ?? error)}`)
|
|
217
|
+
return []
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 目录指纹 = sha256(排序后的工具名 + 缓存命名空间)。只依赖名字,不依赖描述。 */
|
|
222
|
+
const fingerprintOf = (tools) => {
|
|
223
|
+
const names = tools.map((tool) => tool.name).sort().join('\n')
|
|
224
|
+
return createHash('sha256').update(`${resetNonce}\u0000${names}`).digest('hex')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 收敛可见工具集:deny = 目录 − 已加载。 */
|
|
228
|
+
const applyRestriction = (agent, state) => {
|
|
229
|
+
if (state.catalog === null) return
|
|
230
|
+
const catalog = state.catalog
|
|
231
|
+
const hidden = catalog.entries.map((e) => e.name).filter((toolName) => !state.loaded.has(toolName))
|
|
232
|
+
const key = hidden.slice().sort().join('\n')
|
|
233
|
+
if (key === state.denyKey) {
|
|
234
|
+
publishSnapshot(agent)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
if (hidden.length === 0) {
|
|
238
|
+
disposeRestriction(agent, state)
|
|
239
|
+
publishSnapshot(agent)
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
mutating = true
|
|
244
|
+
const dispose = agent.ctx.tools.restrict({ deny: hidden })
|
|
245
|
+
disposeRestriction(agent, state)
|
|
246
|
+
state.denier = dispose
|
|
247
|
+
state.denyKey = key
|
|
248
|
+
registry.set(agent, { instance, dispose })
|
|
249
|
+
mutating = false
|
|
250
|
+
} catch (error) {
|
|
251
|
+
mutating = false
|
|
252
|
+
state.denyKey = ''
|
|
253
|
+
logger?.warn?.(`dsh-agentic-tool-catalog: 收敛可见工具集失败(稍后重试):${String(error?.message ?? error)}`)
|
|
254
|
+
}
|
|
255
|
+
publishSnapshot(agent)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 由原始分组构建目录(并缓存到热路径)。 */
|
|
259
|
+
const catalogFor = (fingerprint, deferred, rawGroups, origin) => {
|
|
260
|
+
const cached = shared.catalogCache.get(fingerprint)
|
|
261
|
+
if (cached !== undefined) return cached
|
|
262
|
+
const checked = validateAgentGroups(deferred, { groups: rawGroups })
|
|
263
|
+
if (checked.error !== undefined) return undefined
|
|
264
|
+
const catalog = catalogFromAgentGroups(deferred, checked.groups)
|
|
265
|
+
catalog.source = 'agent'
|
|
266
|
+
shared.catalogCache.set(fingerprint, catalog)
|
|
267
|
+
return catalog
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 目录增强:真子代理生成。目录变化时跑一次、按指纹缓存;
|
|
272
|
+
* 失败 → 自动重试(由 grouper 负责)→ 上次成功分组 → 扁平引导目录。
|
|
273
|
+
*/
|
|
274
|
+
const startEnrichment = (agent, state, deferred, fingerprint, options = {}) => {
|
|
275
|
+
const allowGenerate = options.allowGenerate !== false
|
|
276
|
+
if (state.enrichment.active) return
|
|
277
|
+
if (deferred.length === 0) {
|
|
278
|
+
// 没有可分组的工具:不启动子代理(防御性,正常由 rebuild 的空目录分支拦下)。
|
|
279
|
+
state.needsEnrichment = false
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
const cacheKey = fingerprint
|
|
283
|
+
const isSubAgent = agent.session?.header?.origin === 'subagent'
|
|
284
|
+
if (isSubAgent && !shared.groups.has(cacheKey)) return
|
|
285
|
+
|
|
286
|
+
const applyCatalog = (enriched, origin = 'generated') => {
|
|
287
|
+
if (enriched === undefined) {
|
|
288
|
+
shared.agentStats.staleUses += 1
|
|
289
|
+
const lastGood = shared.lastGood
|
|
290
|
+
if (lastGood !== null && Array.isArray(lastGood.groups)) {
|
|
291
|
+
const adapted = adaptAgentGroups(deferred, lastGood.groups)
|
|
292
|
+
if (state.fingerprint !== fingerprint) return
|
|
293
|
+
state.catalog = adapted
|
|
294
|
+
state.source = 'agent-stale'
|
|
295
|
+
state.warnings = [...(adapted.warnings ?? []), { kind: 'catalog-stale-groups', message: `本次生成未成功,暂用最近一次成功分组(fingerprint ${String(lastGood.fingerprint).slice(0, 12)});后台会退避重试,详见 generation.lastError` }]
|
|
296
|
+
applyRestriction(agent, state)
|
|
297
|
+
logger?.warn?.(`dsh-agentic-tool-catalog: 生成未成功,暂用最近一次成功分组(${shared.agentStats.lastError?.code ?? 'UNKNOWN'})`)
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
state.source = 'bootstrap'
|
|
301
|
+
state.warnings = [...(state.catalog?.warnings ?? []), { kind: 'catalog-bootstrap', message: '生成未成功且无历史分组,暂用扁平目录;详见 generation.lastError' }]
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
if (state.fingerprint !== fingerprint) return
|
|
305
|
+
state.catalog = enriched
|
|
306
|
+
state.source = enriched.source ?? 'agent'
|
|
307
|
+
state.cacheOrigin = origin
|
|
308
|
+
state.warnings = [...(enriched.warnings ?? [])]
|
|
309
|
+
state.needsEnrichment = false
|
|
310
|
+
if (enriched.groups.length > 0) {
|
|
311
|
+
const lastGood = {
|
|
312
|
+
fingerprint,
|
|
313
|
+
groups: enriched.groups.map((group) => ({ id: group.id, description: group.description, tools: group.members })),
|
|
314
|
+
at: Date.now(),
|
|
315
|
+
}
|
|
316
|
+
store.saveLastGood(lastGood)
|
|
317
|
+
}
|
|
318
|
+
applyRestriction(agent, state)
|
|
319
|
+
publishSnapshot(agent)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 1) 热路径缓存
|
|
323
|
+
const hot = shared.catalogCache.get(cacheKey)
|
|
324
|
+
if (hot !== undefined) {
|
|
325
|
+
shared.agentStats.cacheHits += 1
|
|
326
|
+
applyCatalog(hot, 'memory')
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
// 2) 官方 Storage hydrate 的原始分组
|
|
330
|
+
const raw = shared.groups.get(cacheKey)
|
|
331
|
+
if (raw !== undefined) {
|
|
332
|
+
const catalog = catalogFor(cacheKey, deferred, raw, 'storage')
|
|
333
|
+
if (catalog !== undefined) {
|
|
334
|
+
shared.agentStats.cacheHits += 1
|
|
335
|
+
applyCatalog(catalog, 'storage')
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
shared.groups.delete(cacheKey)
|
|
339
|
+
}
|
|
340
|
+
// Storage 还在 hydrate:先不生成,等 ready 后统一 syncAll
|
|
341
|
+
if (store.info.state === 'opening') {
|
|
342
|
+
state.needsEnrichment = true
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
// 软闸门:宿主连续拒绝 activation spec 后暂停自动生成,继续用 last-known-good;
|
|
346
|
+
// 到期由 generate() 自动解除,插件重装也会清掉(见 apply 顶部)。
|
|
347
|
+
const paused = shared.agentStats.paused
|
|
348
|
+
if (paused !== undefined && Date.now() < paused.retryAt) {
|
|
349
|
+
state.needsEnrichment = true
|
|
350
|
+
if (!state.warnings.some((entry) => entry.kind === 'catalog-generation-paused')) {
|
|
351
|
+
state.warnings = [...state.warnings, { kind: 'catalog-generation-paused', message: paused.message }]
|
|
352
|
+
}
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
const failedAt = shared.failures.get(cacheKey)
|
|
356
|
+
if (failedAt !== undefined && Date.now() - failedAt < 600000) return // 10 分钟退避
|
|
357
|
+
|
|
358
|
+
if (!allowGenerate) {
|
|
359
|
+
state.needsEnrichment = true
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
state.enrichment.active = true
|
|
364
|
+
publishSnapshot(agent)
|
|
365
|
+
const run = async () => {
|
|
366
|
+
const previousGroups = shared.lastGood?.groups
|
|
367
|
+
const generated = await agentGrouper.generate({ agent, tools: deferred, previousGroups })
|
|
368
|
+
if (generated.groups === undefined) {
|
|
369
|
+
shared.failures.set(cacheKey, Date.now())
|
|
370
|
+
return null
|
|
371
|
+
}
|
|
372
|
+
const catalog = catalogFromAgentGroups(deferred, generated.groups)
|
|
373
|
+
catalog.source = 'agent'
|
|
374
|
+
shared.catalogCache.set(cacheKey, catalog)
|
|
375
|
+
shared.failures.delete(cacheKey)
|
|
376
|
+
store.saveGroups(cacheKey, generated.groups.map((group) => ({ id: group.id, description: group.description, tools: group.members })))
|
|
377
|
+
return catalog
|
|
378
|
+
}
|
|
379
|
+
let generation = shared.inflight.get(cacheKey)
|
|
380
|
+
if (generation === undefined) {
|
|
381
|
+
generation = run()
|
|
382
|
+
shared.inflight.set(cacheKey, generation)
|
|
383
|
+
generation.finally(() => shared.inflight.delete(cacheKey)).catch(() => {})
|
|
384
|
+
} else {
|
|
385
|
+
shared.agentStats.deduped += 1
|
|
386
|
+
}
|
|
387
|
+
const settle = (enriched) => {
|
|
388
|
+
state.enrichment.active = false
|
|
389
|
+
state.enrichment.pending = undefined
|
|
390
|
+
publishSnapshot(agent)
|
|
391
|
+
applyCatalog(enriched ?? undefined)
|
|
392
|
+
const dirty = state.enrichment.dirty === true
|
|
393
|
+
state.enrichment.dirty = false
|
|
394
|
+
if (dirty && state.fingerprint !== '' && state.fingerprint !== fingerprint) {
|
|
395
|
+
startEnrichment(agent, state, state.deferred, state.fingerprint, { allowGenerate: true })
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
state.enrichment.pending = generation.then((enriched) => { settle(enriched); return enriched }).catch(() => { settle(undefined); return undefined })
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** 重建该 agent 的目录:释放旧 restriction → 读全量定义 → 引导目录/上次成功分组 → 缓存/生成。 */
|
|
402
|
+
const rebuild = (agent, force = false) => {
|
|
403
|
+
const state = stateOf(agent)
|
|
404
|
+
mutating = true
|
|
405
|
+
disposeRestriction(agent, state)
|
|
406
|
+
mutating = false
|
|
407
|
+
const restrictable = restrictableNamesOf(agent)
|
|
408
|
+
const definitions = definitionsOf(agent)
|
|
409
|
+
const deferred = definitions
|
|
410
|
+
.filter((definition) => restrictable.has(definition.name))
|
|
411
|
+
.filter((definition) => !isResident(definition.name))
|
|
412
|
+
.map((definition) => ({ name: definition.name, description: String(definition.description ?? '') }))
|
|
413
|
+
const deferredNames = new Set(deferred.map((definition) => definition.name))
|
|
414
|
+
state.resident = definitions
|
|
415
|
+
.filter((definition) => !deferredNames.has(definition.name))
|
|
416
|
+
.map((definition) => definition.name)
|
|
417
|
+
const fingerprint = fingerprintOf(deferred)
|
|
418
|
+
if (!force && state.catalog !== null && state.fingerprint === fingerprint) {
|
|
419
|
+
applyRestriction(agent, state)
|
|
420
|
+
publishSnapshot(agent)
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
if (deferred.length === 0) {
|
|
424
|
+
// 空目录(工具全部常驻/已加载):没有可分组的对象,直接给空目录并跳过生成。
|
|
425
|
+
// 否则每次都会启动子代理去分 0 个工具,必然失败在 TOO_FEW_GROUPS / EMPTY_GROUP(2026-11-11 实测)。
|
|
426
|
+
if (state.enrichment.active && state.fingerprint !== fingerprint) state.enrichment.dirty = true
|
|
427
|
+
state.catalog = { strategy: 'agentic-tool-catalog', source: 'agent', entries: [], groups: [], others: [], warnings: [] }
|
|
428
|
+
state.source = 'agent'
|
|
429
|
+
state.warnings = []
|
|
430
|
+
state.fingerprint = fingerprint
|
|
431
|
+
state.deferred = []
|
|
432
|
+
state.needsEnrichment = false
|
|
433
|
+
applyRestriction(agent, state)
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
const lastGood = shared.lastGood
|
|
437
|
+
let catalog
|
|
438
|
+
if (lastGood !== null && Array.isArray(lastGood.groups) && lastGood.groups.length > 0) {
|
|
439
|
+
catalog = adaptAgentGroups(deferred, lastGood.groups)
|
|
440
|
+
catalog.strategy = 'agentic-tool-catalog'
|
|
441
|
+
catalog.warnings = [...(catalog.warnings ?? []), { kind: 'catalog-refreshing', message: '目录变化:暂用最近一次成功分组并后台重新生成' }]
|
|
442
|
+
} else {
|
|
443
|
+
catalog = buildBootstrapCatalog(deferred)
|
|
444
|
+
}
|
|
445
|
+
state.catalog = catalog
|
|
446
|
+
state.source = catalog.source ?? 'bootstrap'
|
|
447
|
+
state.warnings = [...(catalog.warnings ?? [])]
|
|
448
|
+
if (state.enrichment.active && state.fingerprint !== fingerprint) state.enrichment.dirty = true
|
|
449
|
+
state.fingerprint = fingerprint
|
|
450
|
+
state.deferred = deferred
|
|
451
|
+
applyRestriction(agent, state)
|
|
452
|
+
const isNewSession = (agent.session?.header?.createdAt ?? 0) >= bootAt
|
|
453
|
+
const eager = isNewSession || state.activated
|
|
454
|
+
state.needsEnrichment = true
|
|
455
|
+
startEnrichment(agent, state, deferred, fingerprint, { allowGenerate: eager })
|
|
456
|
+
publishSnapshot(agent)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** 真正需要时才触发生成(恢复会话的首个请求/搜索;新会话在创建时已预热)。 */
|
|
460
|
+
const ensureEnrichment = (agent) => {
|
|
461
|
+
const state = states.get(agent)
|
|
462
|
+
if (state === undefined) return
|
|
463
|
+
state.activated = true
|
|
464
|
+
if (state.catalog === null) return
|
|
465
|
+
if (state.enrichment.active) return
|
|
466
|
+
if (state.needsEnrichment) startEnrichment(agent, state, state.deferred, state.fingerprint, { allowGenerate: true })
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let applying = false
|
|
470
|
+
let mutating = false
|
|
471
|
+
const isMutating = () => applying || mutating
|
|
472
|
+
const sync = (agent, force = false) => {
|
|
473
|
+
if (isMutating()) return
|
|
474
|
+
applying = true
|
|
475
|
+
try { rebuild(agent, force) } finally { applying = false }
|
|
476
|
+
}
|
|
477
|
+
const syncAll = (force = false) => {
|
|
478
|
+
const agents = ctx.get('agents')
|
|
479
|
+
if (agents === undefined) return
|
|
480
|
+
for (const agent of agents.list()) sync(agent, force)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Storage hydrate 完成后,把缓存目录应用到所有 agent(首次启动的磁盘命中在这里生效)。
|
|
484
|
+
store.whenReady().then((ok) => {
|
|
485
|
+
if (!ok) return
|
|
486
|
+
shared.catalogCache.clear()
|
|
487
|
+
syncAll(true)
|
|
488
|
+
}).catch(() => {})
|
|
489
|
+
|
|
490
|
+
// ── 只读状态快照(catalog_status 工具 + Client dock 共用) ─────────────────
|
|
491
|
+
const snapshotFor = (agent, state, options = {}) => {
|
|
492
|
+
const catalog = state?.catalog
|
|
493
|
+
const loaded = state === undefined ? new Set() : state.loaded
|
|
494
|
+
const groups = (catalog?.groups ?? [])
|
|
495
|
+
.map((group) => {
|
|
496
|
+
const entry = { id: group.id, count: group.members.filter((name) => !loaded.has(name)).length }
|
|
497
|
+
if (options.withDescriptions === true) entry.description = truncate(group.description, 160)
|
|
498
|
+
return entry
|
|
499
|
+
})
|
|
500
|
+
.filter((group) => group.count > 0)
|
|
501
|
+
const pendingOthers = (catalog?.others ?? []).filter((name) => !loaded.has(name)).length
|
|
502
|
+
const active = state?.enrichment?.active === true
|
|
503
|
+
const freshness = state === undefined
|
|
504
|
+
? 'none'
|
|
505
|
+
: state.source === 'bootstrap'
|
|
506
|
+
? (active ? 'refreshing' : 'bootstrap')
|
|
507
|
+
: state.source === 'agent-stale'
|
|
508
|
+
? (active ? 'refreshing' : 'stale')
|
|
509
|
+
: (active ? 'refreshing' : 'fresh')
|
|
510
|
+
return {
|
|
511
|
+
plugin: 'agentic-tool-catalog',
|
|
512
|
+
catalog: {
|
|
513
|
+
source: state?.source ?? 'none',
|
|
514
|
+
freshness,
|
|
515
|
+
origin: state?.cacheOrigin ?? 'none',
|
|
516
|
+
total: catalog?.entries.length ?? 0,
|
|
517
|
+
resident: state?.resident?.length ?? 0,
|
|
518
|
+
groups,
|
|
519
|
+
others: pendingOthers,
|
|
520
|
+
},
|
|
521
|
+
session: {
|
|
522
|
+
loaded: [...loaded].sort(),
|
|
523
|
+
pending: catalog === null || state === undefined ? null : catalog.entries.length - loaded.size,
|
|
524
|
+
restriction: state === undefined
|
|
525
|
+
? 'no-agent'
|
|
526
|
+
: state.denier === undefined ? 'none' : `deny ${(state.denyKey ?? '').split('\n').filter(Boolean).length} tools`,
|
|
527
|
+
resident: `${(config.resident?.length ?? 0) > 0 ? `${config.resident.length} (override)` : 'builtin'} + ${config.extraKeepResident?.length ?? 0} extra`,
|
|
528
|
+
},
|
|
529
|
+
generation: {
|
|
530
|
+
provider: agentGrouper.provider,
|
|
531
|
+
enabled: agentGrouper.enabled(),
|
|
532
|
+
activationShapes: agentGrouper.activationShapes,
|
|
533
|
+
state: active ? 'running' : 'idle',
|
|
534
|
+
...shared.agentStats,
|
|
535
|
+
instanceId,
|
|
536
|
+
lastReload: shared.lastReload,
|
|
537
|
+
},
|
|
538
|
+
storage: store.status(),
|
|
539
|
+
warnings: state?.warnings ?? [],
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ── search_tools:唯一的常驻入口 ─────────────────────────────────────────
|
|
544
|
+
const description = [
|
|
545
|
+
'Search the agent-curated on-demand tool catalog and load matching tools for this session.',
|
|
546
|
+
'Most integration and management tools are NOT loaded by default (MCP servers, DSH self-management, browser automation, reminders, goals, workflows, and other extensions).',
|
|
547
|
+
'Before using one of those capabilities, search here with an intent or keywords; matching tools become callable directly for the rest of this session.',
|
|
548
|
+
'If the query exactly matches a catalog group name, the whole group loads at once (up to the configured group limit).',
|
|
549
|
+
].join(' ')
|
|
550
|
+
|
|
551
|
+
ctx.tools.register(defineTool({
|
|
552
|
+
name: 'search_tools',
|
|
553
|
+
description,
|
|
554
|
+
parameters: {
|
|
555
|
+
query: {
|
|
556
|
+
type: 'string',
|
|
557
|
+
description: 'What capability you need, or keywords from a tool name/description (e.g. "cloudflare dns", "gmail send", "archive session", "browser screenshot", "reminder"), or an exact catalog group name.',
|
|
558
|
+
},
|
|
559
|
+
limit: {
|
|
560
|
+
type: 'number',
|
|
561
|
+
description: `Maximum number of tools to load; defaults to ${maxResults}, capped at 20.`,
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
output: {
|
|
565
|
+
schema: { type: 'string' },
|
|
566
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
567
|
+
},
|
|
568
|
+
async execute(args, exec) {
|
|
569
|
+
const agent = exec.agent
|
|
570
|
+
if (agent === undefined) return 'search_tools can only run inside an agent session.'
|
|
571
|
+
sync(agent)
|
|
572
|
+
ensureEnrichment(agent)
|
|
573
|
+
const state = stateOf(agent)
|
|
574
|
+
const catalog = state.catalog
|
|
575
|
+
if (catalog === null || catalog.entries.length === 0) {
|
|
576
|
+
return 'No on-demand capability tools are registered for this session.'
|
|
577
|
+
}
|
|
578
|
+
const pending = catalog.entries.length - state.loaded.size
|
|
579
|
+
if (pending <= 0) {
|
|
580
|
+
return 'All on-demand capability tools are already loaded for this session. Call them directly, or use catalog_status to inspect the catalog.'
|
|
581
|
+
}
|
|
582
|
+
const query = String(args.query ?? '').trim()
|
|
583
|
+
const limit = Math.max(1, Math.min(20, Math.trunc(args.limit ?? maxResults)))
|
|
584
|
+
const result = rankTools(catalog, query, { limit, groupMaxResults, loaded: state.loaded })
|
|
585
|
+
if (result.loaded.length === 0) {
|
|
586
|
+
return `No deferred tool matches "${query}". ${state.warnings.map((w) => w.message).join('; ')}\nSearch again with keywords from the on-demand groups, or use catalog_status for details.`
|
|
587
|
+
}
|
|
588
|
+
for (const toolName of result.loaded) state.loaded.add(toolName)
|
|
589
|
+
applyRestriction(agent, state)
|
|
590
|
+
const byName = new Map(catalog.entries.map((e) => [e.name, e]))
|
|
591
|
+
const lines = result.loaded.map((toolName) => `- ${toolName}: ${truncate(byName.get(toolName)?.description, 240)}`)
|
|
592
|
+
const head = result.groupLoad
|
|
593
|
+
? `Loaded ${result.loaded.length} tool(s) from group "${result.groupId}" for this session — call them directly now:`
|
|
594
|
+
: `Loaded ${result.loaded.length} tool(s) for this session — call them directly now:`
|
|
595
|
+
const remaining = result.remainingInGroup > 0
|
|
596
|
+
? `\n${result.remainingInGroup} more tool(s) in this group did not fit (groupMaxResults=${groupMaxResults}); search again with narrower keywords to load them.`
|
|
597
|
+
: ''
|
|
598
|
+
const more = result.more > 0
|
|
599
|
+
? `\n${result.more} more tool(s) matched; search again with a narrower query (or a higher limit) to load them.`
|
|
600
|
+
: ''
|
|
601
|
+
return `${head}\n${lines.join('\n')}${remaining}${more}`
|
|
602
|
+
},
|
|
603
|
+
}))
|
|
604
|
+
|
|
605
|
+
// ── catalog_status:只读观测入口 ─────────────────────────────────────────
|
|
606
|
+
ctx.tools.register(defineTool({
|
|
607
|
+
name: 'catalog_status',
|
|
608
|
+
description: 'Read-only status of the agent-curated on-demand tool catalog: catalog freshness/origin, groups, loaded tools, generation/cache health, storage mode, warnings and restriction health. Use when debugging why a capability cannot be found or loaded.',
|
|
609
|
+
parameters: {},
|
|
610
|
+
output: {
|
|
611
|
+
schema: { type: 'string' },
|
|
612
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
613
|
+
},
|
|
614
|
+
async execute(_args, exec) {
|
|
615
|
+
const agent = exec.agent
|
|
616
|
+
const state = agent === undefined ? undefined : states.get(agent)
|
|
617
|
+
return JSON.stringify(snapshotFor(agent, state), null, 2)
|
|
618
|
+
},
|
|
619
|
+
}))
|
|
620
|
+
|
|
621
|
+
// ── SSE:目录/会话状态变化事件驱动推送(面板不再轮询) ────────────────────
|
|
622
|
+
const EVENTS_ROUTE = '/plugins/agentic-tool-catalog/events'
|
|
623
|
+
publishSnapshot = (agent) => {
|
|
624
|
+
const sessionId = agent?.session?.header?.id ?? agent?.session?.id
|
|
625
|
+
if (typeof sessionId !== 'string' || sessionId === '') return
|
|
626
|
+
const set = subscribers.get(sessionId)
|
|
627
|
+
if (set === undefined || set.size === 0) return
|
|
628
|
+
let payload
|
|
629
|
+
try {
|
|
630
|
+
payload = `event: snapshot\ndata: ${JSON.stringify({ v: 1, snapshot: snapshotFor(agent, states.get(agent), { withDescriptions: true }) })}\n\n`
|
|
631
|
+
} catch { return }
|
|
632
|
+
for (const res of [...set]) {
|
|
633
|
+
sendEvent(res, payload)
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ── Client dock 只读状态路由(可选 webServer;CLI/profile 无页面时静默跳过) ──
|
|
638
|
+
const STATUS_ROUTE = '/plugins/agentic-tool-catalog/snapshot'
|
|
639
|
+
const isTrustedRequest = (req) => {
|
|
640
|
+
const site = req.headers['sec-fetch-site']
|
|
641
|
+
if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') return false
|
|
642
|
+
const origin = req.headers.origin
|
|
643
|
+
if (typeof origin !== 'string' || origin === '' || origin === 'null') return true
|
|
644
|
+
try {
|
|
645
|
+
const url = new URL(origin)
|
|
646
|
+
if (url.protocol === 'dsh-app:') return true
|
|
647
|
+
const host = req.headers.host
|
|
648
|
+
return host !== undefined && url.host === host
|
|
649
|
+
} catch { return false }
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
ctx.inject(['webServer'], (scope) => {
|
|
653
|
+
const disposeRoute = scope.webServer.register({
|
|
654
|
+
kind: 'exact',
|
|
655
|
+
path: STATUS_ROUTE,
|
|
656
|
+
handler: (req, res) => {
|
|
657
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
658
|
+
res.writeHead(405, { 'content-type': 'application/json' })
|
|
659
|
+
res.end(JSON.stringify({ v: 1, error: 'method-not-allowed' }))
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
if (!isTrustedRequest(req)) {
|
|
663
|
+
res.writeHead(403, { 'content-type': 'application/json' })
|
|
664
|
+
res.end(JSON.stringify({ v: 1, error: 'forbidden' }))
|
|
665
|
+
return
|
|
666
|
+
}
|
|
667
|
+
let sessionId = ''
|
|
668
|
+
try { sessionId = new URL(req.url ?? '/', 'http://localhost').searchParams.get('sessionId') ?? '' } catch {}
|
|
669
|
+
if (sessionId === '') {
|
|
670
|
+
res.writeHead(400, { 'content-type': 'application/json' })
|
|
671
|
+
res.end(JSON.stringify({ v: 1, error: 'missing-session-id' }))
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
const agents = ctx.get('agents')
|
|
675
|
+
const agent = agents?.list?.().find((candidate) => (candidate.session?.header?.id ?? candidate.session?.id) === sessionId)
|
|
676
|
+
if (agent === undefined) {
|
|
677
|
+
res.writeHead(404, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
678
|
+
res.end(JSON.stringify({ v: 1, error: 'session-not-found' }))
|
|
679
|
+
return
|
|
680
|
+
}
|
|
681
|
+
const snapshot = snapshotFor(agent, states.get(agent), { withDescriptions: true })
|
|
682
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
683
|
+
res.end(JSON.stringify({ v: 1, snapshot }))
|
|
684
|
+
},
|
|
685
|
+
})
|
|
686
|
+
scope.effect(() => () => {
|
|
687
|
+
try { disposeRoute() } catch {}
|
|
688
|
+
}, 'agentic-tool-catalog: snapshot route')
|
|
689
|
+
|
|
690
|
+
const disposeEvents = scope.webServer.register({
|
|
691
|
+
kind: 'exact',
|
|
692
|
+
path: EVENTS_ROUTE,
|
|
693
|
+
handler: (req, res) => {
|
|
694
|
+
if (req.method !== 'GET') {
|
|
695
|
+
res.writeHead(405, { 'content-type': 'application/json' })
|
|
696
|
+
res.end(JSON.stringify({ v: 1, error: 'method-not-allowed' }))
|
|
697
|
+
return
|
|
698
|
+
}
|
|
699
|
+
if (!isTrustedRequest(req)) {
|
|
700
|
+
res.writeHead(403, { 'content-type': 'application/json' })
|
|
701
|
+
res.end(JSON.stringify({ v: 1, error: 'forbidden' }))
|
|
702
|
+
return
|
|
703
|
+
}
|
|
704
|
+
let sessionId = ''
|
|
705
|
+
try { sessionId = new URL(req.url ?? '/', 'http://localhost').searchParams.get('sessionId') ?? '' } catch {}
|
|
706
|
+
const agents = ctx.get('agents')
|
|
707
|
+
const agent = agents?.list?.().find((candidate) => (candidate.session?.header?.id ?? candidate.session?.id) === sessionId)
|
|
708
|
+
if (sessionId === '' || agent === undefined) {
|
|
709
|
+
res.writeHead(sessionId === '' ? 400 : 404, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
710
|
+
res.end(JSON.stringify({ v: 1, error: sessionId === '' ? 'missing-session-id' : 'session-not-found' }))
|
|
711
|
+
return
|
|
712
|
+
}
|
|
713
|
+
res.writeHead(200, {
|
|
714
|
+
'content-type': 'text/event-stream',
|
|
715
|
+
'cache-control': 'no-cache, no-transform',
|
|
716
|
+
connection: 'keep-alive',
|
|
717
|
+
'x-accel-buffering': 'no',
|
|
718
|
+
})
|
|
719
|
+
res.write(': connected\n\n')
|
|
720
|
+
try {
|
|
721
|
+
sendEvent(res, `event: snapshot\ndata: ${JSON.stringify({ v: 1, snapshot: snapshotFor(agent, states.get(agent), { withDescriptions: true }) })}\n\n`)
|
|
722
|
+
} catch {}
|
|
723
|
+
let set = subscribers.get(sessionId)
|
|
724
|
+
if (set === undefined) {
|
|
725
|
+
set = new Set()
|
|
726
|
+
subscribers.set(sessionId, set)
|
|
727
|
+
}
|
|
728
|
+
set.add(res)
|
|
729
|
+
const heartbeat = setInterval(() => {
|
|
730
|
+
try { res.write(': ping\n\n') } catch {}
|
|
731
|
+
}, 25000)
|
|
732
|
+
res.on('close', () => {
|
|
733
|
+
clearInterval(heartbeat)
|
|
734
|
+
set.delete(res)
|
|
735
|
+
if (set.size === 0) subscribers.delete(sessionId)
|
|
736
|
+
})
|
|
737
|
+
},
|
|
738
|
+
})
|
|
739
|
+
scope.effect(() => () => {
|
|
740
|
+
try { disposeEvents() } catch {}
|
|
741
|
+
for (const [sessionId, set] of [...subscribers.entries()]) {
|
|
742
|
+
for (const res of [...set]) {
|
|
743
|
+
try { res.end() } catch {}
|
|
744
|
+
}
|
|
745
|
+
subscribers.delete(sessionId)
|
|
746
|
+
}
|
|
747
|
+
}, 'agentic-tool-catalog: events route')
|
|
748
|
+
})
|
|
749
|
+
} catch (error) {
|
|
750
|
+
logger?.warn?.(`dsh-agentic-tool-catalog: status route unavailable (${String(error?.message ?? error)})`)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ── 重载观测 ────────────────────────────────────────────────────────────
|
|
754
|
+
for (const event of ['hmr/reload', 'plugin-manager/changed', 'app-boot/config-reload']) {
|
|
755
|
+
try { ctx.on(event, () => { shared.lastReload = { event, at: Date.now() } }) } catch {}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ── 按 agent 收敛可见工具集 ──────────────────────────────────────────────
|
|
759
|
+
ctx.on('agent/created', ({ agent }) => sync(agent, true))
|
|
760
|
+
ctx.on('agent/inbox/claimed', ({ agent }) => ensureEnrichment(agent))
|
|
761
|
+
ctx.on('agent/disposed', ({ agent }) => {
|
|
762
|
+
disposeRestriction(agent)
|
|
763
|
+
states.delete(agent)
|
|
764
|
+
})
|
|
765
|
+
ctx.on('agent-preset/selected', () => syncAll(true))
|
|
766
|
+
ctx.on('tools/change', () => {
|
|
767
|
+
if (isMutating()) return
|
|
768
|
+
syncAll(false)
|
|
769
|
+
})
|
|
770
|
+
syncAll(true)
|
|
771
|
+
ctx.effect(() => () => {
|
|
772
|
+
for (const [agent, entry] of [...registry.entries()]) {
|
|
773
|
+
if (entry !== null && typeof entry === 'object' && entry.instance !== instance) continue
|
|
774
|
+
disposeEntry(entry)
|
|
775
|
+
registry.delete(agent)
|
|
776
|
+
}
|
|
777
|
+
const agents = ctx.get('agents')
|
|
778
|
+
if (agents !== undefined) for (const agent of agents.list()) disposeRestriction(agent)
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
// ── 系统提示里的一条简短目录提示 ─────────────────────────────────────────
|
|
782
|
+
const systemPrompt = ctx.get('systemPrompt')
|
|
783
|
+
if (config.promptNote !== false && systemPrompt !== undefined) {
|
|
784
|
+
systemPrompt.context({
|
|
785
|
+
name: 'catalog:note',
|
|
786
|
+
order: 300,
|
|
787
|
+
text: (assemblyContext) => {
|
|
788
|
+
const agent = assemblyContext?.agent
|
|
789
|
+
const state = agent === undefined ? undefined : states.get(agent)
|
|
790
|
+
if (state?.catalog === undefined || state?.catalog === null) return ''
|
|
791
|
+
return noteText(state.catalog, state.loaded)
|
|
792
|
+
},
|
|
793
|
+
})
|
|
794
|
+
}
|
|
795
|
+
}
|