@lvy-jsd/dsh-tool-manager 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +302 -2
- package/cordis.patch.yml +29 -0
- package/lib/catalog.d.ts +35 -0
- package/lib/catalog.d.ts.map +1 -0
- package/lib/catalog.js +311 -0
- package/lib/catalog.js.map +1 -0
- package/lib/exempt.d.ts +45 -0
- package/lib/exempt.d.ts.map +1 -0
- package/lib/exempt.js +122 -0
- package/lib/exempt.js.map +1 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +797 -0
- package/lib/index.js.map +1 -0
- package/lib/manifest.d.ts +21 -0
- package/lib/manifest.d.ts.map +1 -0
- package/lib/manifest.js +112 -0
- package/lib/manifest.js.map +1 -0
- package/lib/metadata.d.ts +38 -0
- package/lib/metadata.d.ts.map +1 -0
- package/lib/metadata.js +102 -0
- package/lib/metadata.js.map +1 -0
- package/lib/router/bm25.d.ts +21 -0
- package/lib/router/bm25.d.ts.map +1 -0
- package/lib/router/bm25.js +63 -0
- package/lib/router/bm25.js.map +1 -0
- package/lib/router/index.d.ts +9 -0
- package/lib/router/index.d.ts.map +1 -0
- package/lib/router/index.js +422 -0
- package/lib/router/index.js.map +1 -0
- package/lib/router/sidecar.d.ts +34 -0
- package/lib/router/sidecar.d.ts.map +1 -0
- package/lib/router/sidecar.js +156 -0
- package/lib/router/sidecar.js.map +1 -0
- package/lib/router/types.d.ts +164 -0
- package/lib/router/types.d.ts.map +1 -0
- package/lib/router/types.js +7 -0
- package/lib/router/types.js.map +1 -0
- package/lib/service.d.ts +51 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +199 -0
- package/lib/service.js.map +1 -0
- package/lib/settings.d.ts +32 -0
- package/lib/settings.d.ts.map +1 -0
- package/lib/settings.js +93 -0
- package/lib/settings.js.map +1 -0
- package/lib/types.d.ts +453 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +7 -0
- package/lib/types.js.map +1 -0
- package/lib/visibility.d.ts +33 -0
- package/lib/visibility.d.ts.map +1 -0
- package/lib/visibility.js +305 -0
- package/lib/visibility.js.map +1 -0
- package/package.json +54 -4
- package/sidecar/bin/tool-router-sidecar +0 -0
- package/src/catalog.ts +305 -0
- package/src/exempt.ts +148 -0
- package/src/index.ts +805 -0
- package/src/manifest.ts +116 -0
- package/src/metadata.ts +123 -0
- package/src/router/bm25.ts +68 -0
- package/src/router/index.ts +440 -0
- package/src/router/sidecar.ts +159 -0
- package/src/router/types.ts +149 -0
- package/src/service.ts +207 -0
- package/src/settings.ts +108 -0
- package/src/types.ts +450 -0
- package/src/visibility.ts +317 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
// dsh-tool-manager: browse every DSH tool as a directory tree
|
|
2
|
+
// (category -> group -> tool) and load tools on demand per agent.
|
|
3
|
+
//
|
|
4
|
+
// Loaded tools are native: the model receives their full parameter schema.
|
|
5
|
+
// Visibility is enforced with the host's per-agent `tools.restrict({ allow })`.
|
|
6
|
+
//
|
|
7
|
+
// The core ships no placement rules: without a classifier plugin every group
|
|
8
|
+
// is `uncategorized`. Placement, metadata, hooks and extra visibility narrowing
|
|
9
|
+
// are provided by other plugins through the `toolCatalog` service.
|
|
10
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
11
|
+
import { buildCatalog, resolveTarget, toolsUnder, UNCATEGORIZED } from './catalog.js';
|
|
12
|
+
import { createMetadataStore, defaultMetadataFile } from './metadata.js';
|
|
13
|
+
import { createExemptStore, defaultExemptFile } from './exempt.js';
|
|
14
|
+
import { defaultManifestFile, syncManifest } from './manifest.js';
|
|
15
|
+
import { createSettingsStore, defaultSettingsFile } from './settings.js';
|
|
16
|
+
import { createVisibilityController } from './visibility.js';
|
|
17
|
+
import { createRegistries, readManifestAnnotations, listPluginPackages } from './service.js';
|
|
18
|
+
import { registerRouter } from './router/index.js';
|
|
19
|
+
export { buildCatalog, resolveTarget, toolsUnder, estimateTokens, summary, shortName, structuralGroup, DEFAULT_CATEGORIES, UNCATEGORIZED, MISC_GROUP, OVERRIDE_PRIORITY } from './catalog.js';
|
|
20
|
+
export const name = 'tool-manager';
|
|
21
|
+
export const inject = ['tools'];
|
|
22
|
+
export const ENTRY_TOOLS = ['tools_ls', 'tools_load', 'tools_unload'];
|
|
23
|
+
// Host transports that are not capabilities: never listed, loaded or restricted.
|
|
24
|
+
// `run_code` carries every call in DSH's PTC mode; the host refuses to restrict
|
|
25
|
+
// it and adds it to the view after all restrictions, so treating it as a hidden
|
|
26
|
+
// tool would block PTC mode outright.
|
|
27
|
+
export const HOST_RESERVED = ['run_code'];
|
|
28
|
+
// Always visible and never listed in the directory. Names the host does not
|
|
29
|
+
// have are ignored, so this list survives DSH renames. Config `pinned` adds to
|
|
30
|
+
// it; config `unpin` removes from it.
|
|
31
|
+
export const DEFAULT_PINNED = [
|
|
32
|
+
// DSH core
|
|
33
|
+
'bash', 'read', 'write', 'edit', 'str_replace_editor', 'glob', 'grep', 'read_image',
|
|
34
|
+
'ask_user_question', 'ask_user', 'exit_plan_mode', 'workflow', 'todo_write', 'present', 'skill', 'plan',
|
|
35
|
+
'web_fetch', 'web_search', 'job_list', 'job_output', 'job_kill', 'create_goal', 'get_goal', 'update_goal',
|
|
36
|
+
// delegation
|
|
37
|
+
'subagent', 'subagent_fork', 'list_subagent_models', 'wait_agent', 'interrupt_agent', 'list_agents',
|
|
38
|
+
// memory lookup is small and frequent
|
|
39
|
+
'recall',
|
|
40
|
+
// team chat: a Bot's group prompt tells it to answer through these, so they
|
|
41
|
+
// must never sit behind tools_load
|
|
42
|
+
'group_relay', 'post_to_group', 'read_group_chat', 'read_own_chat', 'message_bot', 'list_bots',
|
|
43
|
+
];
|
|
44
|
+
const TEXT_OUTPUT = {
|
|
45
|
+
schema: { type: 'string' },
|
|
46
|
+
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
|
47
|
+
};
|
|
48
|
+
function fmtTokens(n) {
|
|
49
|
+
return n >= 1000 ? `~${(n / 1000).toFixed(1)}k tok` : `~${n} tok`;
|
|
50
|
+
}
|
|
51
|
+
function pad(text, width) {
|
|
52
|
+
return text.length >= width ? `${text} ` : text + ' '.repeat(width - text.length);
|
|
53
|
+
}
|
|
54
|
+
export function apply(ctx, config = {}) {
|
|
55
|
+
const logger = (typeof ctx.logger === 'function' ? ctx.logger('tool-manager') : ctx.logger) ?? console;
|
|
56
|
+
// Values a UI may edit live in their own file, which wins per key over the
|
|
57
|
+
// plugin config: an operator editing cordis.patch.yml must not have a stale
|
|
58
|
+
// panel overwrite it on the next click, and a panel edit must not be undone
|
|
59
|
+
// by the next restart.
|
|
60
|
+
const settings = createSettingsStore({
|
|
61
|
+
file: config.settingsFile ?? defaultSettingsFile(),
|
|
62
|
+
base: { pinned: config.pinned, unpin: config.unpin, highRisk: config.highRisk },
|
|
63
|
+
logger,
|
|
64
|
+
});
|
|
65
|
+
// Panel-edited tool metadata (group, category, risk, summary). Applied after
|
|
66
|
+
// every other annotation, so an edit corrects a package declaration instead of
|
|
67
|
+
// being overruled by it.
|
|
68
|
+
const metadata = createMetadataStore({ file: config.metadataFile ?? defaultMetadataFile(), logger });
|
|
69
|
+
// Model routes whose tool list is handed to an external client once per
|
|
70
|
+
// session and never refreshed (Claude Code receives DSH tools through an MCP
|
|
71
|
+
// bridge at startup). Narrowing such a session hides tools it can then never
|
|
72
|
+
// load, and the client already lazy-loads schemas itself (ToolSearch), so
|
|
73
|
+
// these agents are left unrestricted.
|
|
74
|
+
//
|
|
75
|
+
// The list in force comes from the exemption file when it exists, else from
|
|
76
|
+
// config `exemptProviders` (default ["claude-code"]; [] restricts every
|
|
77
|
+
// route). A file entry can also name a single session id, so one session can
|
|
78
|
+
// be exempted without touching the route it uses.
|
|
79
|
+
const exemptions = createExemptStore({
|
|
80
|
+
file: config.exemptFile ?? defaultExemptFile(),
|
|
81
|
+
configProviders: config.exemptProviders ?? ['claude-code'],
|
|
82
|
+
logger,
|
|
83
|
+
});
|
|
84
|
+
/** Session id used to match a per-session exemption, with the agent id as fallback. */
|
|
85
|
+
function sessionIdOf(agent) {
|
|
86
|
+
const id = agent?.session?.id ?? agent?.id;
|
|
87
|
+
return id === undefined || id === null || id === '' ? undefined : String(id);
|
|
88
|
+
}
|
|
89
|
+
/** Why this agent is not narrowed, if it is not. */
|
|
90
|
+
function exemptionOf(agent) {
|
|
91
|
+
return exemptions.reasonFor(providerOf(agent), sessionIdOf(agent));
|
|
92
|
+
}
|
|
93
|
+
/** The route an agent's next request will use: latest model/selection, else its options. */
|
|
94
|
+
const selectionScan = new WeakMap(); // session -> incremental scan
|
|
95
|
+
function providerOf(agent) {
|
|
96
|
+
const session = agent?.session;
|
|
97
|
+
try {
|
|
98
|
+
if (session && typeof session.eventAt === 'function' && Number.isInteger(session.seq)) {
|
|
99
|
+
const memo = selectionScan.get(session) ?? { seq: 0, provider: undefined };
|
|
100
|
+
for (let seq = memo.seq; seq < session.seq; seq += 1) {
|
|
101
|
+
const event = session.eventAt(seq);
|
|
102
|
+
if (event?.type === 'model/selection' && event.data?.provider)
|
|
103
|
+
memo.provider = event.data.provider;
|
|
104
|
+
}
|
|
105
|
+
memo.seq = session.seq;
|
|
106
|
+
selectionScan.set(session, memo);
|
|
107
|
+
if (memo.provider)
|
|
108
|
+
return memo.provider;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// fall back to the agent's options
|
|
113
|
+
}
|
|
114
|
+
return agent?.options?.provider ?? session?.requestHeader?.()?.provider;
|
|
115
|
+
}
|
|
116
|
+
// High-risk tools can be loaded individually only while at least one risk
|
|
117
|
+
// gate (e.g. dsh-tool-manager-risk-gate) is registered; the gate confirms the
|
|
118
|
+
// load before it executes. With no gate, high-risk loads are refused.
|
|
119
|
+
const riskGates = new Set();
|
|
120
|
+
const pinnedSet = () => {
|
|
121
|
+
const current = settings.effective();
|
|
122
|
+
const set = new Set([...DEFAULT_PINNED, ...current.pinned]);
|
|
123
|
+
for (const n of current.unpin)
|
|
124
|
+
set.delete(n);
|
|
125
|
+
for (const n of ENTRY_TOOLS)
|
|
126
|
+
set.add(n);
|
|
127
|
+
return set;
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* Configuration as one object for UIs. `extraPinned`/`unpinned` show the
|
|
131
|
+
* settings file, so the panel edits exactly what it displays; `pinned` is the
|
|
132
|
+
* resulting always-on set, defaults and entry tools included.
|
|
133
|
+
*/
|
|
134
|
+
function configSnapshot() {
|
|
135
|
+
const current = settings.effective();
|
|
136
|
+
return {
|
|
137
|
+
pinned: [...pinnedSet()],
|
|
138
|
+
entryTools: [...ENTRY_TOOLS],
|
|
139
|
+
extraPinned: [...current.pinned],
|
|
140
|
+
unpinned: [...current.unpin],
|
|
141
|
+
highRisk: [...current.highRisk],
|
|
142
|
+
exemptProviders: [...exemptions.effective().providers],
|
|
143
|
+
exemptSessions: [...exemptions.effective().sessions],
|
|
144
|
+
exemptFile: exemptions.file,
|
|
145
|
+
hasExemptFile: exemptions.present(),
|
|
146
|
+
defaults: [...DEFAULT_PINNED],
|
|
147
|
+
settingsFile: settings.file,
|
|
148
|
+
// An empty file is not an override, so count keys rather than existence.
|
|
149
|
+
hasSettings: Object.keys(settings.stored()).length > 0,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* One tool's metadata plus every layer behind it, so the panel can show what
|
|
154
|
+
* is declared, what was edited, and what the model actually gets. A tool that
|
|
155
|
+
* is pinned away or not registered is reported as missing rather than absent,
|
|
156
|
+
* so the UI can explain why there is nothing to edit.
|
|
157
|
+
*/
|
|
158
|
+
function toolMetaSnapshot(name) {
|
|
159
|
+
const entry = getCatalog().tools.get(name);
|
|
160
|
+
const declared = { ...annotationForLayers(name) };
|
|
161
|
+
return {
|
|
162
|
+
name,
|
|
163
|
+
found: entry !== undefined,
|
|
164
|
+
effective: entry?.annotation ?? declared,
|
|
165
|
+
edited: metadata.get(name) ?? {},
|
|
166
|
+
declared,
|
|
167
|
+
group: entry?.group ?? declared.group ?? '',
|
|
168
|
+
category: entry?.category ?? declared.category ?? '',
|
|
169
|
+
summary: entry?.summary ?? declared.summary ?? '',
|
|
170
|
+
highRisk: entry?.highRisk === true,
|
|
171
|
+
path: entry ? `/${entry.category}/${entry.group}/${entry.short}` : '',
|
|
172
|
+
missing: entry === undefined,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The declared metadata only: package.json first, runtime annotate() after,
|
|
177
|
+
* with panel edits left out so the UI can tell the two apart.
|
|
178
|
+
*/
|
|
179
|
+
function annotationForLayers(name) {
|
|
180
|
+
manifestAnnotations ??= readManifestAnnotations(ctx, logger);
|
|
181
|
+
const merged = {};
|
|
182
|
+
for (const annotation of [...manifestAnnotations, ...registries.annotations]) {
|
|
183
|
+
const hit = typeof annotation.match === 'string' ? annotation.match === name : annotation.match.test(name);
|
|
184
|
+
if (hit)
|
|
185
|
+
Object.assign(merged, annotation.meta);
|
|
186
|
+
}
|
|
187
|
+
return merged;
|
|
188
|
+
}
|
|
189
|
+
// --- catalog -----------------------------------------------------------------
|
|
190
|
+
// The directory is built per agent: global tools plus tools registered in the
|
|
191
|
+
// agent's ancestor scopes (its preset). In the DSH web profile most tools
|
|
192
|
+
// live in a preset layer, so a global-only catalog would hide them without
|
|
193
|
+
// listing them. Agents with the same tool set share one cached catalog.
|
|
194
|
+
const catalogs = new Map(); // key (sorted names) -> catalog
|
|
195
|
+
let manifestAnnotations = null;
|
|
196
|
+
const invalidate = () => { catalogs.clear(); };
|
|
197
|
+
const registries = createRegistries(() => {
|
|
198
|
+
invalidate();
|
|
199
|
+
visibility?.refresh();
|
|
200
|
+
});
|
|
201
|
+
function schemaOf(definition, name) {
|
|
202
|
+
try {
|
|
203
|
+
if (typeof ctx.tools.schemaOf === 'function')
|
|
204
|
+
return ctx.tools.schemaOf(definition, false);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// fall through to the public fields
|
|
208
|
+
}
|
|
209
|
+
return { name, description: definition.description ?? '', parameters: definition.parameters ?? {} };
|
|
210
|
+
}
|
|
211
|
+
/** Schemas of tools an agent inherits from its ancestor (preset) layers. */
|
|
212
|
+
function inheritedSchemas(agent) {
|
|
213
|
+
const layers = ctx.tools.layers;
|
|
214
|
+
if (!agent || typeof layers?.chainLayers !== 'function')
|
|
215
|
+
return [];
|
|
216
|
+
const own = layers.peek?.(agent);
|
|
217
|
+
const out = [];
|
|
218
|
+
for (const layer of layers.chainLayers(agent)) {
|
|
219
|
+
if (layer === own)
|
|
220
|
+
continue;
|
|
221
|
+
for (const [name, definition] of layer.tools.entries())
|
|
222
|
+
out.push(schemaOf(definition, name));
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
/** Catalog for one agent, or for the union of all live agents when omitted. */
|
|
227
|
+
const getCatalog = (agent) => {
|
|
228
|
+
const pinned = pinnedSet();
|
|
229
|
+
const byName = new Map(ctx.tools.schemas().map((s) => [s.name, s]));
|
|
230
|
+
for (const a of agent ? [agent] : visibility.agents()) {
|
|
231
|
+
for (const s of inheritedSchemas(a))
|
|
232
|
+
if (!byName.has(s.name))
|
|
233
|
+
byName.set(s.name, s);
|
|
234
|
+
}
|
|
235
|
+
const schemas = [...byName.values()].filter((s) => !pinned.has(s.name) && !HOST_RESERVED.includes(s.name));
|
|
236
|
+
const key = schemas.map((s) => s.name).sort().join('\n');
|
|
237
|
+
const cached = catalogs.get(key);
|
|
238
|
+
if (cached)
|
|
239
|
+
return cached;
|
|
240
|
+
ctx.emit('tool-catalog/before-rebuild', {});
|
|
241
|
+
manifestAnnotations ??= readManifestAnnotations(ctx, logger);
|
|
242
|
+
const catalog = buildCatalog(schemas, {
|
|
243
|
+
classifiers: [...registries.classifiers],
|
|
244
|
+
// Package declarations first, runtime annotate() after, so runtime wins —
|
|
245
|
+
// and panel edits last of all, so they win over both.
|
|
246
|
+
annotations: [...manifestAnnotations, ...registries.annotations, ...metadata.annotations()],
|
|
247
|
+
categories: Object.fromEntries(registries.categories),
|
|
248
|
+
highRisk: settings.effective().highRisk,
|
|
249
|
+
});
|
|
250
|
+
catalogs.set(key, catalog);
|
|
251
|
+
// A client that receives its tool list once per session cannot discover the
|
|
252
|
+
// directory by asking, so it gets the index as a file instead. Written here,
|
|
253
|
+
// after every rebuild, so it cannot drift from what tools_load would answer.
|
|
254
|
+
// An empty path turns it off.
|
|
255
|
+
const manifestFile = config.manifestFile ?? defaultManifestFile();
|
|
256
|
+
if (manifestFile)
|
|
257
|
+
syncManifest(catalog, manifestFile, logger);
|
|
258
|
+
ctx.emit('tool-catalog/after-rebuild', { catalog });
|
|
259
|
+
return catalog;
|
|
260
|
+
};
|
|
261
|
+
const visibility = createVisibilityController(ctx, {
|
|
262
|
+
pinned: () => [...pinnedSet()],
|
|
263
|
+
entryTools: ENTRY_TOOLS,
|
|
264
|
+
getFilters: () => [...registries.filters],
|
|
265
|
+
getCatalog,
|
|
266
|
+
// The catalog is rebuilt lazily after any registry change.
|
|
267
|
+
invalidate,
|
|
268
|
+
onStateChange: (agent, state) => ctx.emit('tool-catalog/state-changed', { agent, state }),
|
|
269
|
+
isExempt: (agent) => exemptionOf(agent).exempt,
|
|
270
|
+
logger,
|
|
271
|
+
});
|
|
272
|
+
if (typeof ctx.inject === 'function') {
|
|
273
|
+
ctx.inject(['agents'], (agentCtx) => {
|
|
274
|
+
agentCtx.effect(() => visibility.mount(agentCtx), 'tool-manager.visibility()');
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
logger.warn?.('tool-manager: host has no ctx.inject; per-agent visibility is disabled');
|
|
279
|
+
}
|
|
280
|
+
ctx.effect(() => () => visibility.dispose(), 'tool-manager.dispose()');
|
|
281
|
+
// Rescue a direct call of a hidden tool (see rescueHiddenCall). Runs before
|
|
282
|
+
// dispatch resolves the name, so loading here makes the call succeed.
|
|
283
|
+
ctx.effect(() => ctx.on('tools/pre-execute', async (exec, next) => {
|
|
284
|
+
const agent = exec?.agent;
|
|
285
|
+
if (!agent || pinnedSet().has(exec.name) || ENTRY_TOOLS.includes(exec.name))
|
|
286
|
+
return next();
|
|
287
|
+
const refusal = await rescueHiddenCall(agent, exec.name);
|
|
288
|
+
if (refusal !== undefined)
|
|
289
|
+
return { kind: 'deny', reason: refusal };
|
|
290
|
+
return next();
|
|
291
|
+
}), 'tool-manager.rescue-hidden-call()');
|
|
292
|
+
// --- the toolCatalog service ---------------------------------------------------
|
|
293
|
+
const service = {
|
|
294
|
+
registerClassifier: registries.registerClassifier,
|
|
295
|
+
annotate: registries.annotate,
|
|
296
|
+
defineCategory: registries.defineCategory,
|
|
297
|
+
registerVisibilityFilter: registries.registerVisibilityFilter,
|
|
298
|
+
/**
|
|
299
|
+
* Own a target prefix (`"cap:"`) in tools_load / tools_ls / tools_unload.
|
|
300
|
+
* `resolve(target)` returns one or more ordinary targets to resolve
|
|
301
|
+
* instead, or undefined to pass. Returns a disposer.
|
|
302
|
+
*/
|
|
303
|
+
registerTargetResolver: registries.registerTargetResolver,
|
|
304
|
+
/**
|
|
305
|
+
* Let loaded state outlive the process (DSH restart). `load(agent)` returns
|
|
306
|
+
* a serialized state or null, `save(agent, state)` receives plain JSON.
|
|
307
|
+
* Returns a disposer. See dsh-tool-manager-persist.
|
|
308
|
+
*/
|
|
309
|
+
registerStateProvider: visibility.registerStateProvider,
|
|
310
|
+
/**
|
|
311
|
+
* Declare that a plugin confirms high-risk loads (normally by returning
|
|
312
|
+
* {kind:'ask'} from tools/pre-execute for tools_load). Returns a disposer.
|
|
313
|
+
*/
|
|
314
|
+
registerRiskGate(gate = {}) {
|
|
315
|
+
const entry = { name: gate.name ?? 'anonymous' };
|
|
316
|
+
riskGates.add(entry);
|
|
317
|
+
return () => { riskGates.delete(entry); };
|
|
318
|
+
},
|
|
319
|
+
riskGates: () => [...riskGates].map((g) => g.name),
|
|
320
|
+
/** Re-read package declarations and rebuild. */
|
|
321
|
+
refresh() {
|
|
322
|
+
manifestAnnotations = null;
|
|
323
|
+
invalidate();
|
|
324
|
+
visibility.refresh();
|
|
325
|
+
},
|
|
326
|
+
/** Catalog as one agent sees it, or the union over live agents. */
|
|
327
|
+
get: (agent) => getCatalog(agent),
|
|
328
|
+
resolve: (target, agent) => resolveTarget(getCatalog(agent), target, [...registries.resolvers]),
|
|
329
|
+
/** Loaded state of one agent: { loaded, groups, excluded, handoffGroups, handoffTools } (Sets). */
|
|
330
|
+
stateOf: (agent) => visibility.stateOf(agent),
|
|
331
|
+
/** Plain JSON of an agent's loaded state, for persistence plugins. */
|
|
332
|
+
exportState: (agent) => visibility.exportState(agent),
|
|
333
|
+
/** Replace an agent's loaded state (e.g. restored after a restart). */
|
|
334
|
+
importState: (agent, state) => visibility.importState(agent, state),
|
|
335
|
+
allowFor: (agent) => visibility.allowFor(agent),
|
|
336
|
+
deniedFor: (agent) => [...deniedFor(agent)],
|
|
337
|
+
/** True when the agent's model route is in `exemptProviders` (never narrowed). */
|
|
338
|
+
isExempt: (agent) => visibility.isExempt(agent),
|
|
339
|
+
/** Why an agent is exempt, so the panel can label a route-wide exemption. */
|
|
340
|
+
exemptionOf: (agent) => exemptionOf(agent),
|
|
341
|
+
/**
|
|
342
|
+
* Load/unload on behalf of an agent, through the same path as the entry
|
|
343
|
+
* tools (hooks, vetoes, risk gates and persistence all apply). Returns the
|
|
344
|
+
* same text the model would see. Intended for UI plugins such as
|
|
345
|
+
* dsh-tool-manager-panel.
|
|
346
|
+
*/
|
|
347
|
+
load: (agent, targets, options) => loadTargets(agent, targets, options),
|
|
348
|
+
unload: (agent, targets) => unloadTargets(agent, targets),
|
|
349
|
+
/** Live agents, each with its parent when it is a subagent. */
|
|
350
|
+
agents: () => {
|
|
351
|
+
// The parent lookup goes through the injected agents API: the plugin ctx
|
|
352
|
+
// itself does not inject `agents`, and reading it there throws.
|
|
353
|
+
return visibility.agents().map((agent) => {
|
|
354
|
+
const parent = visibility.parentOf(agent);
|
|
355
|
+
return { agent, id: String(agent.id), parentId: parent ? String(parent.id) : null };
|
|
356
|
+
});
|
|
357
|
+
},
|
|
358
|
+
/** Effective configuration, for UI display and editing. */
|
|
359
|
+
config: () => configSnapshot(),
|
|
360
|
+
/**
|
|
361
|
+
* Persist UI-edited settings and apply them at once. The catalog is rebuilt
|
|
362
|
+
* because a changed always-on or high-risk list changes every directory.
|
|
363
|
+
*/
|
|
364
|
+
updateConfig(patch) {
|
|
365
|
+
settings.update(patch);
|
|
366
|
+
invalidate();
|
|
367
|
+
visibility.refresh();
|
|
368
|
+
ctx.emit('tool-catalog/settings-changed', { config: configSnapshot() });
|
|
369
|
+
return configSnapshot();
|
|
370
|
+
},
|
|
371
|
+
/** Re-read the settings file, for after an external edit. */
|
|
372
|
+
reloadConfig() {
|
|
373
|
+
settings.reload();
|
|
374
|
+
invalidate();
|
|
375
|
+
visibility.refresh();
|
|
376
|
+
ctx.emit('tool-catalog/settings-changed', { config: configSnapshot() });
|
|
377
|
+
return configSnapshot();
|
|
378
|
+
},
|
|
379
|
+
/**
|
|
380
|
+
* Exempt one session from narrowing, or stop exempting it. Written to the
|
|
381
|
+
* exemption file, so the choice survives a restart and is reversible by
|
|
382
|
+
* editing or deleting that one file.
|
|
383
|
+
*/
|
|
384
|
+
setExemptSession(sessionId, exempt) {
|
|
385
|
+
exemptions.setSession(String(sessionId), exempt);
|
|
386
|
+
// An exemption installs or lifts a restriction, so re-apply it now.
|
|
387
|
+
visibility.refresh();
|
|
388
|
+
ctx.emit('tool-catalog/settings-changed', { config: configSnapshot() });
|
|
389
|
+
return configSnapshot();
|
|
390
|
+
},
|
|
391
|
+
/** Re-read the exemption file, for after an external edit. */
|
|
392
|
+
reloadExempt() {
|
|
393
|
+
exemptions.reload();
|
|
394
|
+
visibility.refresh();
|
|
395
|
+
ctx.emit('tool-catalog/settings-changed', { config: configSnapshot() });
|
|
396
|
+
return configSnapshot();
|
|
397
|
+
},
|
|
398
|
+
/** Metadata of one tool, with the layers that decide each field. */
|
|
399
|
+
toolMeta: (name) => toolMetaSnapshot(String(name)),
|
|
400
|
+
/**
|
|
401
|
+
* Edit metadata from the panel. The store wins over the package
|
|
402
|
+
* declaration, and the catalog is rebuilt so the Directory tab moves the
|
|
403
|
+
* tool at once.
|
|
404
|
+
*/
|
|
405
|
+
updateToolMeta(name, meta) {
|
|
406
|
+
metadata.set(String(name), meta);
|
|
407
|
+
invalidate();
|
|
408
|
+
visibility.refresh();
|
|
409
|
+
ctx.emit('tool-catalog/metadata-changed', { name: String(name) });
|
|
410
|
+
return toolMetaSnapshot(String(name));
|
|
411
|
+
},
|
|
412
|
+
/** Drop every edited field of one tool, falling back to its declaration. */
|
|
413
|
+
clearToolMeta(name) {
|
|
414
|
+
metadata.clear(String(name));
|
|
415
|
+
invalidate();
|
|
416
|
+
visibility.refresh();
|
|
417
|
+
ctx.emit('tool-catalog/metadata-changed', { name: String(name) });
|
|
418
|
+
return toolMetaSnapshot(String(name));
|
|
419
|
+
},
|
|
420
|
+
metadataFile: () => metadata.file,
|
|
421
|
+
/** Enabled plugin packages on this host: `{ name, dir, manifest }[]`. */
|
|
422
|
+
packages: () => listPluginPackages(ctx),
|
|
423
|
+
/** Registered extension points, so a UI can show what is active. */
|
|
424
|
+
extensions: () => ({
|
|
425
|
+
classifiers: [...registries.classifiers].map((c) => ({ name: c.name, priority: c.priority ?? 0 })),
|
|
426
|
+
filters: [...registries.filters].map((f) => f.name),
|
|
427
|
+
annotations: registries.annotations.map((a) => ({ source: a.source, match: String(a.match) })),
|
|
428
|
+
categories: [...registries.categories.keys()],
|
|
429
|
+
riskGates: [...riskGates].map((g) => g.name),
|
|
430
|
+
stateProviders: visibility.stateProviders(),
|
|
431
|
+
}),
|
|
432
|
+
/**
|
|
433
|
+
* Structured cross-directory search for UIs: the same matching rules as
|
|
434
|
+
* tools_ls(query), returned as data instead of text.
|
|
435
|
+
*/
|
|
436
|
+
find: (query, path = '/') => {
|
|
437
|
+
const cat = getCatalog();
|
|
438
|
+
const node = resolveTarget(cat, path, [...registries.resolvers]);
|
|
439
|
+
if (node.kind === 'missing')
|
|
440
|
+
return [];
|
|
441
|
+
return matchingTools(cat, node, query).slice(0, 200).map((tool) => ({
|
|
442
|
+
name: tool.name,
|
|
443
|
+
short: tool.short,
|
|
444
|
+
path: `/${tool.category}/${tool.group}/${tool.short}`,
|
|
445
|
+
category: tool.category,
|
|
446
|
+
group: tool.group,
|
|
447
|
+
summary: tool.summary,
|
|
448
|
+
tokens: tool.tokens,
|
|
449
|
+
highRisk: tool.highRisk === true,
|
|
450
|
+
}));
|
|
451
|
+
},
|
|
452
|
+
/** Schema tokens actually sent for an agent: pinned tools plus its loaded set. */
|
|
453
|
+
wireTokens: (agent) => {
|
|
454
|
+
const visible = new Set(visibility.allowFor(agent) ?? []);
|
|
455
|
+
const cat = getCatalog(agent);
|
|
456
|
+
let tokens = 0;
|
|
457
|
+
for (const schema of ctx.tools.schemas(agent)) {
|
|
458
|
+
if (!visible.has(schema.name))
|
|
459
|
+
continue;
|
|
460
|
+
tokens += cat.tools.get(schema.name)?.tokens ?? 0;
|
|
461
|
+
}
|
|
462
|
+
return tokens;
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
ctx.provide('toolCatalog', service);
|
|
466
|
+
// --- the retrieval + routing layer ---------------------------------------------
|
|
467
|
+
//
|
|
468
|
+
// The router reuses the toolCatalog service just provided: it indexes the same
|
|
469
|
+
// catalog, loads through the same path, and reports the same token budget. It
|
|
470
|
+
// adds tools_find / tools_route / skills_find / skills_route on top of the
|
|
471
|
+
// directory tools above.
|
|
472
|
+
registerRouter(ctx, config, service);
|
|
473
|
+
// --- direct calls of a hidden tool -----------------------------------------------
|
|
474
|
+
//
|
|
475
|
+
// A tool this plugin hid is still a registered tool: the model may call it by
|
|
476
|
+
// name, and the host would answer "unknown tool" — which reads like a broken
|
|
477
|
+
// plugin and, for a protocol tool a plugin's own prompt tells the model to
|
|
478
|
+
// use, breaks the feature outright (a group Bot replying through group_relay,
|
|
479
|
+
// for instance).
|
|
480
|
+
//
|
|
481
|
+
// So a direct call of a hidden tool is handled here, before dispatch:
|
|
482
|
+
// - an ordinary tool is loaded on the spot and the call proceeds;
|
|
483
|
+
// - a high-risk tool is refused with the exact load instruction, so it still
|
|
484
|
+
// has to go through the risk gate and its approval.
|
|
485
|
+
// The cost is a few tokens when the model skips the directory; the benefit is
|
|
486
|
+
// that forgetting to pin a protocol tool degrades instead of breaking.
|
|
487
|
+
async function rescueHiddenCall(agent, name) {
|
|
488
|
+
// allowFor() is null for an agent this plugin does not track (and for one
|
|
489
|
+
// whose context cannot restrict at all): nothing to rescue there.
|
|
490
|
+
if (visibility.allowFor(agent) === null)
|
|
491
|
+
return undefined;
|
|
492
|
+
const state = visibility.stateOf(agent);
|
|
493
|
+
if (state.loaded.has(name))
|
|
494
|
+
return undefined;
|
|
495
|
+
// Only tools this plugin manages: pinned tools and entry tools are always
|
|
496
|
+
// visible, and anything else is hidden by the host or another plugin, which
|
|
497
|
+
// is not ours to override.
|
|
498
|
+
const catalog = getCatalog(agent);
|
|
499
|
+
const tool = catalog.tools.get(name);
|
|
500
|
+
if (!tool)
|
|
501
|
+
return undefined;
|
|
502
|
+
if (tool.highRisk) {
|
|
503
|
+
return `"${name}" is high risk and is not loaded. Load it first with tools_load(["${name}"]), which asks for approval.`;
|
|
504
|
+
}
|
|
505
|
+
// An auto-load is still a load: a before-load veto must stop it too.
|
|
506
|
+
try {
|
|
507
|
+
await ctx.serial('tool-catalog/before-load', { agent, targets: [name], names: [name], rescue: true });
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
return `Load of "${name}" refused: ${errorText(error)}`;
|
|
511
|
+
}
|
|
512
|
+
visibility.update(agent, (next) => {
|
|
513
|
+
next.loaded.add(name);
|
|
514
|
+
next.excluded.delete(name);
|
|
515
|
+
});
|
|
516
|
+
// A visibility filter (or a connector deny) may still keep it out; say so
|
|
517
|
+
// rather than letting the host report an unknown tool.
|
|
518
|
+
if (!(visibility.allowFor(agent) ?? []).includes(name)) {
|
|
519
|
+
// Undo the load, or a tool the session cannot see would sit in its state
|
|
520
|
+
// (and be persisted) as if it had been loaded.
|
|
521
|
+
visibility.update(agent, (next) => { next.loaded.delete(name); });
|
|
522
|
+
return `"${name}" is not available in this session. Use tools_ls to see what is.`;
|
|
523
|
+
}
|
|
524
|
+
ctx.emit('tool-catalog/auto-loaded', { agent, name });
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
527
|
+
// --- tools another layer hides ---------------------------------------------------
|
|
528
|
+
// Our allowlist intersects with every other restriction on the agent (MCP
|
|
529
|
+
// connector governance, its per-session injection, other plugins), so a tool
|
|
530
|
+
// can be pinned or loaded here and still never reach the model. The host's
|
|
531
|
+
// final view tells them apart; without it nothing is reported.
|
|
532
|
+
function deniedFor(agent) {
|
|
533
|
+
const allow = agent ? visibility.allowFor(agent) : null;
|
|
534
|
+
if (!agent || !allow || typeof ctx.tools.view !== 'function')
|
|
535
|
+
return new Set();
|
|
536
|
+
let visible;
|
|
537
|
+
try {
|
|
538
|
+
visible = ctx.tools.view(agent).visible;
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
return new Set();
|
|
542
|
+
}
|
|
543
|
+
if (!visible)
|
|
544
|
+
return new Set();
|
|
545
|
+
const cat = getCatalog(agent);
|
|
546
|
+
return new Set(allow.filter((name) => cat.tools.has(name) && !visible.has(name)));
|
|
547
|
+
}
|
|
548
|
+
const warnedDenied = new Set();
|
|
549
|
+
function warnDenied(names) {
|
|
550
|
+
const fresh = names.filter((name) => !warnedDenied.has(name));
|
|
551
|
+
if (!fresh.length)
|
|
552
|
+
return;
|
|
553
|
+
for (const name of fresh)
|
|
554
|
+
warnedDenied.add(name);
|
|
555
|
+
logger?.warn?.(`tool-manager: loaded but hidden by another restriction: ${fresh.join(', ')}. If these are MCP tools, check the connector's governance rules and set the connection to "always inject" (per-session injection narrows on top of this plugin).`);
|
|
556
|
+
}
|
|
557
|
+
// --- listing -------------------------------------------------------------------
|
|
558
|
+
function visibleSetFor(agent) {
|
|
559
|
+
const allow = agent ? visibility.allowFor(agent) : null;
|
|
560
|
+
return allow ? new Set(allow) : null;
|
|
561
|
+
}
|
|
562
|
+
function toolLine(tool, visible, denied, withPath = false) {
|
|
563
|
+
const marks = `${visible?.has(tool.name) ? '*' : ' '}${tool.highRisk ? '!' : ' '}`;
|
|
564
|
+
const label = withPath ? `/${tool.category}/${tool.group}/${tool.short}` : tool.short;
|
|
565
|
+
return ` ${marks} ${pad(label, withPath ? 48 : 34)}${pad(fmtTokens(tool.tokens), 11)}${denied.has(tool.name) ? '[denied] ' : ''}${tool.summary}`;
|
|
566
|
+
}
|
|
567
|
+
function listing(cat, node, visible, denied = new Set()) {
|
|
568
|
+
const count = (tools) => ({
|
|
569
|
+
n: tools.length,
|
|
570
|
+
loaded: tools.filter((t) => visible?.has(t.name)).length,
|
|
571
|
+
tokens: tools.reduce((s, t) => s + t.tokens, 0),
|
|
572
|
+
});
|
|
573
|
+
const lines = [];
|
|
574
|
+
if (node.kind === 'root') {
|
|
575
|
+
lines.push('/ (categories; * = loaded, ! = high risk, [denied] = loaded but blocked by another plugin; tools_ls with query searches everything)');
|
|
576
|
+
for (const category of cat.categories.values()) {
|
|
577
|
+
const c = count(category.groups.flatMap((g) => g.tools));
|
|
578
|
+
if (c.n === 0 && category.name !== UNCATEGORIZED)
|
|
579
|
+
continue;
|
|
580
|
+
lines.push(` ${pad(`${category.name}/`, 16)}${pad(`${c.n} tools`, 10)}${pad(`${c.loaded} loaded`, 11)}${pad(fmtTokens(c.tokens), 11)}${category.description}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
else if (node.kind === 'category') {
|
|
584
|
+
lines.push(`/${node.category.name}/ ${node.category.description}`);
|
|
585
|
+
for (const group of node.category.groups) {
|
|
586
|
+
const c = count(group.tools);
|
|
587
|
+
lines.push(` ${pad(`${group.name}/`, 22)}${pad(`${c.n} tools`, 10)}${pad(`${c.loaded} loaded`, 11)}${fmtTokens(c.tokens)}`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
else if (node.kind === 'group') {
|
|
591
|
+
lines.push(`/${node.group.category}/${node.group.name}/`);
|
|
592
|
+
for (const tool of node.group.tools)
|
|
593
|
+
lines.push(toolLine(tool, visible, denied));
|
|
594
|
+
}
|
|
595
|
+
else if (node.kind === 'tool') {
|
|
596
|
+
const t = node.tool;
|
|
597
|
+
lines.push(`/${t.category}/${t.group}/${t.short}`, ` name: ${t.name}`, ` ${t.summary}`, ` schema ${fmtTokens(t.tokens)}${t.highRisk ? ' · high risk: load it on its own' : ''}${visible?.has(t.name) ? ' · loaded' : ''}${denied.has(t.name) ? ' · denied by another plugin (e.g. an MCP connector rule): not callable' : ''}`);
|
|
598
|
+
}
|
|
599
|
+
return lines.join('\n');
|
|
600
|
+
}
|
|
601
|
+
/** Tools under `node` matching every word of `query` (name, group, category, description). */
|
|
602
|
+
function matchingTools(cat, node, query) {
|
|
603
|
+
const words = String(query ?? '').toLowerCase().split(/\s+/).filter(Boolean);
|
|
604
|
+
if (!words.length)
|
|
605
|
+
return [];
|
|
606
|
+
return toolsUnder(cat, node).filter((t) => {
|
|
607
|
+
const hay = `${t.name} ${t.group} ${t.category} ${t.description}`.toLowerCase();
|
|
608
|
+
return words.every((w) => hay.includes(w));
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
function search(cat, node, query, visible, denied = new Set()) {
|
|
612
|
+
const hits = matchingTools(cat, node, query);
|
|
613
|
+
if (!hits.length)
|
|
614
|
+
return `No tools match "${query}".`;
|
|
615
|
+
const shown = hits.slice(0, 40);
|
|
616
|
+
return [`${hits.length} match(es) for "${query}" (load by path or full name):`, ...shown.map((t) => toolLine(t, visible, denied, true)), hits.length > shown.length ? ` … ${hits.length - shown.length} more; narrow the query or the path` : ''].filter(Boolean).join('\n');
|
|
617
|
+
}
|
|
618
|
+
function agentOf(exec) {
|
|
619
|
+
if (!exec?.agent)
|
|
620
|
+
throw new Error('tool-manager: this tool must be called by an agent');
|
|
621
|
+
return exec.agent;
|
|
622
|
+
}
|
|
623
|
+
// --- load / unload (shared by the entry tools and the toolCatalog service) -------
|
|
624
|
+
/**
|
|
625
|
+
* Subscribe an agent to targets. Runs the before-load hook (which may veto) and
|
|
626
|
+
* returns the model-facing text; the panel calls this same path, so hooks
|
|
627
|
+
* apply identically; risk-gate approval does not, since a panel click is
|
|
628
|
+
* itself the user's consent.
|
|
629
|
+
*/
|
|
630
|
+
async function loadTargets(agent, targets, { forSubagents = false } = {}) {
|
|
631
|
+
const cat = getCatalog(agent);
|
|
632
|
+
const nodes = targets.map((raw) => ({ raw, node: resolveTarget(cat, raw, [...registries.resolvers]) }));
|
|
633
|
+
const names = nodes.flatMap(({ node }) => (node.kind === 'root' || node.kind === 'missing' ? [] : toolsUnder(cat, node).map((t) => t.name)));
|
|
634
|
+
try {
|
|
635
|
+
await ctx.serial('tool-catalog/before-load', { agent, targets, names });
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
return `Load refused: ${errorText(error)}`;
|
|
639
|
+
}
|
|
640
|
+
const before = new Set(visibility.allowFor(agent) ?? []);
|
|
641
|
+
const notes = [];
|
|
642
|
+
visibility.update(agent, (state) => {
|
|
643
|
+
for (const { raw, node } of nodes) {
|
|
644
|
+
if (node.kind === 'missing' || node.kind === 'root') {
|
|
645
|
+
notes.push(`skipped ${raw}: ${node.kind === 'root' ? 'cannot load the whole tree' : 'not found (try tools_ls with query)'}`);
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
// An alias (e.g. a capability pack) names exact tools: add those, never
|
|
649
|
+
// the whole group they happen to live in.
|
|
650
|
+
if (node.kind === 'category' && node.category.aliasTools) {
|
|
651
|
+
for (const tool of node.category.aliasTools) {
|
|
652
|
+
if (tool.highRisk && riskGates.size === 0) {
|
|
653
|
+
notes.push(`${tool.name} is high risk and no risk gate is installed to confirm it; not loaded`);
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
state.loaded.add(tool.name);
|
|
657
|
+
state.excluded.delete(tool.name);
|
|
658
|
+
if (forSubagents)
|
|
659
|
+
state.handoffTools.add(tool.name);
|
|
660
|
+
}
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
const groups = node.kind === 'category' ? node.category.groups : node.kind === 'group' ? [node.group] : [];
|
|
664
|
+
for (const group of groups) {
|
|
665
|
+
state.groups.add(group.name);
|
|
666
|
+
if (forSubagents)
|
|
667
|
+
state.handoffGroups.add(group.name);
|
|
668
|
+
for (const tool of group.tools)
|
|
669
|
+
state.excluded.delete(tool.name);
|
|
670
|
+
const risky = group.tools.filter((t) => t.highRisk).map((t) => t.short);
|
|
671
|
+
if (risky.length)
|
|
672
|
+
notes.push(`${group.name}: high-risk tools not included (${risky.join(', ')}); load them one by one`);
|
|
673
|
+
}
|
|
674
|
+
if (node.kind === 'tool') {
|
|
675
|
+
if (node.tool.highRisk && riskGates.size === 0) {
|
|
676
|
+
notes.push(`${node.tool.name} is high risk and no risk gate is installed to confirm it; not loaded`);
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
state.loaded.add(node.tool.name);
|
|
680
|
+
state.excluded.delete(node.tool.name);
|
|
681
|
+
if (forSubagents)
|
|
682
|
+
state.handoffTools.add(node.tool.name);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
const after = visibility.allowFor(agent) ?? [];
|
|
687
|
+
const added = after.filter((n) => !before.has(n));
|
|
688
|
+
ctx.emit('tool-catalog/after-load', { agent, targets, added });
|
|
689
|
+
const denied = deniedFor(agent);
|
|
690
|
+
const blocked = added.filter((n) => denied.has(n));
|
|
691
|
+
const callable = added.filter((n) => !denied.has(n));
|
|
692
|
+
if (blocked.length)
|
|
693
|
+
warnDenied(blocked);
|
|
694
|
+
const tokens = callable.reduce((sum, n) => sum + (cat.tools.get(n)?.tokens ?? 0), 0);
|
|
695
|
+
return [
|
|
696
|
+
callable.length ? `Loaded ${callable.length} tool(s), ${fmtTokens(tokens)}; callable from your next step:` : 'Nothing new loaded.',
|
|
697
|
+
...callable.map((n) => ` ${n}`),
|
|
698
|
+
...(blocked.length ? [`Denied by another plugin (e.g. an MCP connector rule), not callable even when loaded: ${blocked.join(', ')}`] : []),
|
|
699
|
+
...notes,
|
|
700
|
+
].join('\n');
|
|
701
|
+
}
|
|
702
|
+
/** Hide targets again. Returns the model-facing text. */
|
|
703
|
+
async function unloadTargets(agent, targets) {
|
|
704
|
+
const cat = getCatalog(agent);
|
|
705
|
+
try {
|
|
706
|
+
await ctx.serial('tool-catalog/before-unload', { agent, targets });
|
|
707
|
+
}
|
|
708
|
+
catch (error) {
|
|
709
|
+
return `Unload refused: ${errorText(error)}`;
|
|
710
|
+
}
|
|
711
|
+
const before = new Set(visibility.allowFor(agent) ?? []);
|
|
712
|
+
visibility.update(agent, (state) => {
|
|
713
|
+
for (const raw of targets) {
|
|
714
|
+
if (String(raw).trim().toLowerCase() === 'all') {
|
|
715
|
+
for (const set of Object.values(state))
|
|
716
|
+
set.clear();
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
const node = resolveTarget(cat, raw, [...registries.resolvers]);
|
|
720
|
+
if (node.kind === 'category' && node.category.aliasTools) {
|
|
721
|
+
for (const tool of node.category.aliasTools) {
|
|
722
|
+
state.loaded.delete(tool.name);
|
|
723
|
+
state.handoffTools.delete(tool.name);
|
|
724
|
+
}
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
const groups = node.kind === 'category' ? node.category.groups : node.kind === 'group' ? [node.group] : [];
|
|
728
|
+
for (const group of groups) {
|
|
729
|
+
state.groups.delete(group.name);
|
|
730
|
+
state.handoffGroups.delete(group.name);
|
|
731
|
+
for (const tool of group.tools)
|
|
732
|
+
state.loaded.delete(tool.name);
|
|
733
|
+
}
|
|
734
|
+
if (node.kind === 'tool') {
|
|
735
|
+
state.loaded.delete(node.tool.name);
|
|
736
|
+
state.handoffTools.delete(node.tool.name);
|
|
737
|
+
if (state.groups.has(node.tool.group))
|
|
738
|
+
state.excluded.add(node.tool.name);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
const after = new Set(visibility.allowFor(agent) ?? []);
|
|
743
|
+
const removed = [...before].filter((n) => !after.has(n));
|
|
744
|
+
ctx.emit('tool-catalog/after-unload', { agent, targets, removed });
|
|
745
|
+
return removed.length ? `Unloaded ${removed.length} tool(s):\n${removed.map((n) => ` ${n}`).join('\n')}` : 'Nothing was unloaded.';
|
|
746
|
+
}
|
|
747
|
+
// --- tools -----------------------------------------------------------------------
|
|
748
|
+
ctx.tools.register(defineTool({
|
|
749
|
+
name: 'tools_ls',
|
|
750
|
+
description: 'Browse every available tool as a directory: / lists categories, /<category> lists groups (a connector or plugin), /<category>/<group> lists tools with a one-line summary and schema size. Tools marked * are loaded (callable now). Most tools are hidden until loaded with tools_load. Pass query to search names and descriptions across the whole tree (or under path) in one step.',
|
|
751
|
+
parameters: {
|
|
752
|
+
path: { type: 'string', description: 'Directory path such as "/", "/code", "/code/github", or a group or tool name. Default "/".' },
|
|
753
|
+
query: { type: 'string', description: 'Search words matched against tool name, group, category and description; searches everything under path.' },
|
|
754
|
+
},
|
|
755
|
+
output: TEXT_OUTPUT,
|
|
756
|
+
isConcurrencySafe: () => true,
|
|
757
|
+
async execute(args, exec) {
|
|
758
|
+
const cat = getCatalog(exec?.agent);
|
|
759
|
+
const node = resolveTarget(cat, args.path ?? '/', [...registries.resolvers]);
|
|
760
|
+
if (node.kind === 'missing')
|
|
761
|
+
return `No such path: ${node.target}. Start from tools_ls("/") or search with tools_ls({query}).`;
|
|
762
|
+
const agent = exec?.agent;
|
|
763
|
+
const visible = visibleSetFor(agent);
|
|
764
|
+
const denied = deniedFor(agent);
|
|
765
|
+
return args.query ? search(cat, node, args.query, visible, denied) : listing(cat, node, visible, denied);
|
|
766
|
+
},
|
|
767
|
+
}));
|
|
768
|
+
ctx.tools.register(defineTool({
|
|
769
|
+
name: 'tools_load',
|
|
770
|
+
description: 'Make tools callable for the rest of this session. Each target is a group path ("/code/github"), a category ("/maps"), or one tool by path or exact name ("mcp__github__get_file_contents"). Loading a group also includes tools the group gains later. Prefer one group load over many single tools. Loaded tools are available from your next step. Set forSubagents to also hand these targets to subagents you start afterwards.',
|
|
771
|
+
parameters: {
|
|
772
|
+
targets: { type: 'array', items: { type: 'string' }, required: true, description: 'Paths, group names or exact tool names.' },
|
|
773
|
+
forSubagents: { type: 'boolean', description: 'Also give these targets to subagents started from now on.' },
|
|
774
|
+
},
|
|
775
|
+
output: TEXT_OUTPUT,
|
|
776
|
+
async execute(args, exec) {
|
|
777
|
+
return loadTargets(agentOf(exec), args.targets ?? [], { forSubagents: args.forSubagents === true });
|
|
778
|
+
},
|
|
779
|
+
}));
|
|
780
|
+
ctx.tools.register(defineTool({
|
|
781
|
+
name: 'tools_unload',
|
|
782
|
+
description: 'Hide loaded tools again to keep context small. Targets use the same forms as tools_load; "all" returns to the always-on set. Always-on tools cannot be unloaded.',
|
|
783
|
+
parameters: {
|
|
784
|
+
targets: { type: 'array', items: { type: 'string' }, required: true, description: 'Paths, group names, exact tool names, or "all".' },
|
|
785
|
+
},
|
|
786
|
+
output: TEXT_OUTPUT,
|
|
787
|
+
async execute(args, exec) {
|
|
788
|
+
return unloadTargets(agentOf(exec), args.targets ?? []);
|
|
789
|
+
},
|
|
790
|
+
}));
|
|
791
|
+
}
|
|
792
|
+
/** The message of a thrown value, whatever was thrown. */
|
|
793
|
+
function errorText(error) {
|
|
794
|
+
return String(error?.message ?? error);
|
|
795
|
+
}
|
|
796
|
+
export default { name, inject, apply };
|
|
797
|
+
//# sourceMappingURL=index.js.map
|