@lvy-jsd/dsh-tool-manager 0.2.0 → 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/README.md +99 -2
- package/cordis.patch.yml +13 -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 +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +243 -34
- package/lib/index.js.map +1 -1
- 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/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 +137 -1
- package/lib/types.d.ts.map +1 -1
- package/package.json +8 -4
- package/sidecar/bin/tool-router-sidecar +0 -0
- package/src/exempt.ts +148 -0
- package/src/index.ts +245 -35
- 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/settings.ts +108 -0
- package/src/types.ts +134 -3
package/src/exempt.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// File-based exemption list for dsh-tool-manager.
|
|
2
|
+
//
|
|
3
|
+
// An exempt session is never narrowed: a route whose tool list is handed to an
|
|
4
|
+
// external client once at session start (Claude Code, say) cannot load a tool
|
|
5
|
+
// that was hidden from the first request, so hiding anything there only loses
|
|
6
|
+
// tools. The provider list originally came from `exemptProviders` in
|
|
7
|
+
// cordis.patch.yml, which a user cannot edit from the browser and which is
|
|
8
|
+
// awkward to change per session.
|
|
9
|
+
//
|
|
10
|
+
// This file replaces it: when <DSH_HOME>/tool-manager/exempt.json exists it wins
|
|
11
|
+
// over the plugin config, the same way the settings file does. Two entry forms
|
|
12
|
+
// are supported, because they answer different questions:
|
|
13
|
+
//
|
|
14
|
+
// { "providers": ["claude-code"], "sessions": ["s_abc123"] }
|
|
15
|
+
//
|
|
16
|
+
// providers model routes never narrowed, for every session (durable)
|
|
17
|
+
// sessions one session exempted for its remaining lifetime, e.g. while the
|
|
18
|
+
// user checks whether a client handles lazy loading (temporary)
|
|
19
|
+
//
|
|
20
|
+
// Sessions are matched against the agent's session id and, as a fallback, the
|
|
21
|
+
// agent id, so an entry can be removed by hand without knowing which of the two
|
|
22
|
+
// the host populated.
|
|
23
|
+
|
|
24
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
25
|
+
import { homedir } from 'node:os'
|
|
26
|
+
import { dirname, join } from 'node:path'
|
|
27
|
+
import type { Logger } from './types.js'
|
|
28
|
+
|
|
29
|
+
/** An exemption list: routes and sessions. Both may be empty. */
|
|
30
|
+
export interface ExemptConfig {
|
|
31
|
+
providers: string[]
|
|
32
|
+
sessions: string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Where the exemption file lives unless a deployment points elsewhere. */
|
|
36
|
+
export function defaultExemptFile(): string {
|
|
37
|
+
const dshHome = process.env.DSH_HOME || join(homedir(), '.config', 'deepseek-harness')
|
|
38
|
+
return join(dshHome, 'tool-manager', 'exempt.json')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Keep only non-empty strings; a number or object in the file is a typo, not a name. */
|
|
42
|
+
function pickNames(value: unknown): string[] {
|
|
43
|
+
if (!Array.isArray(value)) return []
|
|
44
|
+
return value.filter((name): name is string => typeof name === 'string' && name.trim().length > 0).map((name) => name.trim())
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The whole file, with unknown keys dropped. */
|
|
48
|
+
export function pickExempt(raw: unknown): ExemptConfig {
|
|
49
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return { providers: [], sessions: [] }
|
|
50
|
+
const record = raw as Record<string, unknown>
|
|
51
|
+
// `providers` is required: a file that omits it says "no route is exempt",
|
|
52
|
+
// not "keep the old config list", because the file is the override.
|
|
53
|
+
return { providers: pickNames(record.providers), sessions: pickNames(record.sessions) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Read the exemption file; missing or corrupt is "no opinion", never a crash. */
|
|
57
|
+
export function readExemptFile(file: string, logger?: Logger): ExemptConfig | undefined {
|
|
58
|
+
let text: string
|
|
59
|
+
try {
|
|
60
|
+
text = readFileSync(file, 'utf8')
|
|
61
|
+
} catch (caught) {
|
|
62
|
+
const error = caught as NodeJS.ErrnoException
|
|
63
|
+
// ENOENT is the normal first run: no file means the plugin config decides.
|
|
64
|
+
if (error.code !== 'ENOENT') logger?.warn?.(`tool-manager: ignoring unreadable exemptions ${file}: ${error.message}`)
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
return pickExempt(JSON.parse(text))
|
|
69
|
+
} catch (caught) {
|
|
70
|
+
// A corrupt file must not silently restore the config list: the user
|
|
71
|
+
// intended the file to be in charge, so report and treat it as "no exempt".
|
|
72
|
+
logger?.warn?.(`tool-manager: unreadable exemptions ${file}: ${(caught as Error).message}`)
|
|
73
|
+
return { providers: [], sessions: [] }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Write the file atomically, so a crash cannot leave a half-written list. */
|
|
78
|
+
export function writeExemptFile(file: string, config: ExemptConfig, logger?: Logger): void {
|
|
79
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
80
|
+
const tmp = `${file}.tmp`
|
|
81
|
+
try {
|
|
82
|
+
writeFileSync(tmp, `${JSON.stringify({ providers: config.providers, sessions: config.sessions }, null, 2)}\n`)
|
|
83
|
+
renameSync(tmp, file)
|
|
84
|
+
} catch (caught) {
|
|
85
|
+
logger?.warn?.(`tool-manager: writing exemptions ${file} failed: ${(caught as Error).message}`)
|
|
86
|
+
throw caught
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Why a session is exempt, for the panel to show. */
|
|
91
|
+
export interface ExemptReason {
|
|
92
|
+
exempt: boolean
|
|
93
|
+
/** 'file:provider' | 'file:session' | 'config:provider' | '' */
|
|
94
|
+
source: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ExemptStore {
|
|
98
|
+
/** File the store reads and writes; the file itself is the override switch. */
|
|
99
|
+
file: string
|
|
100
|
+
/** True when the file exists, so the config provider list is ignored. */
|
|
101
|
+
present(): boolean
|
|
102
|
+
/** The lists in force: the file when it exists, else the plugin config. */
|
|
103
|
+
effective(): ExemptConfig
|
|
104
|
+
/** Why one session is exempt, if it is. */
|
|
105
|
+
reasonFor(provider: string | undefined, sessionId: string | undefined): ExemptReason
|
|
106
|
+
/** Add or remove one session, writing the file. Returns the lists in force. */
|
|
107
|
+
setSession(sessionId: string, exempt: boolean): ExemptConfig
|
|
108
|
+
/** Re-read the file, for after an external edit. Returns the lists in force. */
|
|
109
|
+
reload(): ExemptConfig
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Exemption store over one file, with the plugin config as the fallback layer.
|
|
114
|
+
* The file wins as a whole rather than per key: an operator writing the file
|
|
115
|
+
* means it, and a partial merge would leave entries they cannot see.
|
|
116
|
+
*/
|
|
117
|
+
export function createExemptStore({ file, configProviders = [], logger }: { file: string, configProviders?: readonly string[], logger?: Logger }): ExemptStore {
|
|
118
|
+
let fromFile = readExemptFile(file, logger)
|
|
119
|
+
const effective = (): ExemptConfig => fromFile ?? { providers: [...configProviders], sessions: [] }
|
|
120
|
+
return {
|
|
121
|
+
file,
|
|
122
|
+
present: () => fromFile !== undefined,
|
|
123
|
+
effective,
|
|
124
|
+
reasonFor(provider, sessionId) {
|
|
125
|
+
const current = effective()
|
|
126
|
+
if (provider !== undefined && current.providers.includes(provider)) {
|
|
127
|
+
return { exempt: true, source: fromFile !== undefined ? 'file:provider' : 'config:provider' }
|
|
128
|
+
}
|
|
129
|
+
if (sessionId !== undefined && current.sessions.includes(sessionId)) return { exempt: true, source: 'file:session' }
|
|
130
|
+
return { exempt: false, source: '' }
|
|
131
|
+
},
|
|
132
|
+
setSession(sessionId, exempt) {
|
|
133
|
+
// Editing starts from what is in force, so the first panel click writes a
|
|
134
|
+
// file that keeps the exemptions the user could already see.
|
|
135
|
+
const current = effective()
|
|
136
|
+
const sessions = new Set(current.sessions)
|
|
137
|
+
if (exempt) sessions.add(sessionId)
|
|
138
|
+
else sessions.delete(sessionId)
|
|
139
|
+
fromFile = { providers: [...current.providers], sessions: [...sessions] }
|
|
140
|
+
writeExemptFile(file, fromFile, logger)
|
|
141
|
+
return effective()
|
|
142
|
+
},
|
|
143
|
+
reload() {
|
|
144
|
+
fromFile = readExemptFile(file, logger)
|
|
145
|
+
return effective()
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,9 +10,14 @@
|
|
|
10
10
|
|
|
11
11
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
12
12
|
import { buildCatalog, resolveTarget, toolsUnder, UNCATEGORIZED } from './catalog.js'
|
|
13
|
+
import { createMetadataStore, defaultMetadataFile } from './metadata.js'
|
|
14
|
+
import { createExemptStore, defaultExemptFile } from './exempt.js'
|
|
15
|
+
import { defaultManifestFile, syncManifest } from './manifest.js'
|
|
16
|
+
import { createSettingsStore, defaultSettingsFile } from './settings.js'
|
|
13
17
|
import { createVisibilityController } from './visibility.js'
|
|
14
18
|
import { createRegistries, readManifestAnnotations, listPluginPackages } from './service.js'
|
|
15
|
-
import
|
|
19
|
+
import { registerRouter } from './router/index.js'
|
|
20
|
+
import type { Agent, Annotation, Catalog, CatalogNode, CoreConfig, Disposer, HostContext, Logger, RiskGate, SerializedState, ToolCatalogService, ToolEntry, ToolMeta, ToolSchema } from './types.js'
|
|
16
21
|
|
|
17
22
|
export type * from './types.js'
|
|
18
23
|
export { buildCatalog, resolveTarget, toolsUnder, estimateTokens, summary, shortName, structuralGroup, DEFAULT_CATEGORIES, UNCATEGORIZED, MISC_GROUP, OVERRIDE_PRIORITY } from './catalog.js'
|
|
@@ -25,6 +30,12 @@ export const inject = ['tools']
|
|
|
25
30
|
|
|
26
31
|
export const ENTRY_TOOLS = ['tools_ls', 'tools_load', 'tools_unload']
|
|
27
32
|
|
|
33
|
+
// Host transports that are not capabilities: never listed, loaded or restricted.
|
|
34
|
+
// `run_code` carries every call in DSH's PTC mode; the host refuses to restrict
|
|
35
|
+
// it and adds it to the view after all restrictions, so treating it as a hidden
|
|
36
|
+
// tool would block PTC mode outright.
|
|
37
|
+
export const HOST_RESERVED = ['run_code']
|
|
38
|
+
|
|
28
39
|
// Always visible and never listed in the directory. Names the host does not
|
|
29
40
|
// have are ignored, so this list survives DSH renames. Config `pinned` adds to
|
|
30
41
|
// it; config `unpin` removes from it.
|
|
@@ -57,13 +68,45 @@ function pad(text: string, width: number): string {
|
|
|
57
68
|
|
|
58
69
|
export function apply(ctx: HostContext, config: CoreConfig = {}) {
|
|
59
70
|
const logger: Logger = (typeof ctx.logger === 'function' ? ctx.logger('tool-manager') : ctx.logger) ?? console
|
|
71
|
+
// Values a UI may edit live in their own file, which wins per key over the
|
|
72
|
+
// plugin config: an operator editing cordis.patch.yml must not have a stale
|
|
73
|
+
// panel overwrite it on the next click, and a panel edit must not be undone
|
|
74
|
+
// by the next restart.
|
|
75
|
+
const settings = createSettingsStore({
|
|
76
|
+
file: config.settingsFile ?? defaultSettingsFile(),
|
|
77
|
+
base: { pinned: config.pinned, unpin: config.unpin, highRisk: config.highRisk },
|
|
78
|
+
logger,
|
|
79
|
+
})
|
|
80
|
+
// Panel-edited tool metadata (group, category, risk, summary). Applied after
|
|
81
|
+
// every other annotation, so an edit corrects a package declaration instead of
|
|
82
|
+
// being overruled by it.
|
|
83
|
+
const metadata = createMetadataStore({ file: config.metadataFile ?? defaultMetadataFile(), logger })
|
|
60
84
|
// Model routes whose tool list is handed to an external client once per
|
|
61
85
|
// session and never refreshed (Claude Code receives DSH tools through an MCP
|
|
62
86
|
// bridge at startup). Narrowing such a session hides tools it can then never
|
|
63
87
|
// load, and the client already lazy-loads schemas itself (ToolSearch), so
|
|
64
|
-
// these agents are left unrestricted.
|
|
65
|
-
//
|
|
66
|
-
|
|
88
|
+
// these agents are left unrestricted.
|
|
89
|
+
//
|
|
90
|
+
// The list in force comes from the exemption file when it exists, else from
|
|
91
|
+
// config `exemptProviders` (default ["claude-code"]; [] restricts every
|
|
92
|
+
// route). A file entry can also name a single session id, so one session can
|
|
93
|
+
// be exempted without touching the route it uses.
|
|
94
|
+
const exemptions = createExemptStore({
|
|
95
|
+
file: config.exemptFile ?? defaultExemptFile(),
|
|
96
|
+
configProviders: config.exemptProviders ?? ['claude-code'],
|
|
97
|
+
logger,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
/** Session id used to match a per-session exemption, with the agent id as fallback. */
|
|
101
|
+
function sessionIdOf(agent: Agent): string | undefined {
|
|
102
|
+
const id = agent?.session?.id ?? agent?.id
|
|
103
|
+
return id === undefined || id === null || id === '' ? undefined : String(id)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Why this agent is not narrowed, if it is not. */
|
|
107
|
+
function exemptionOf(agent: Agent) {
|
|
108
|
+
return exemptions.reasonFor(providerOf(agent), sessionIdOf(agent))
|
|
109
|
+
}
|
|
67
110
|
|
|
68
111
|
/** The route an agent's next request will use: latest model/selection, else its options. */
|
|
69
112
|
const selectionScan = new WeakMap<object, { seq: number, provider: string | undefined }>() // session -> incremental scan
|
|
@@ -92,12 +135,75 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
|
|
|
92
135
|
const riskGates = new Set<{ name: string }>()
|
|
93
136
|
|
|
94
137
|
const pinnedSet = () => {
|
|
95
|
-
const
|
|
96
|
-
|
|
138
|
+
const current = settings.effective()
|
|
139
|
+
const set = new Set([...DEFAULT_PINNED, ...current.pinned])
|
|
140
|
+
for (const n of current.unpin) set.delete(n)
|
|
97
141
|
for (const n of ENTRY_TOOLS) set.add(n)
|
|
98
142
|
return set
|
|
99
143
|
}
|
|
100
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Configuration as one object for UIs. `extraPinned`/`unpinned` show the
|
|
147
|
+
* settings file, so the panel edits exactly what it displays; `pinned` is the
|
|
148
|
+
* resulting always-on set, defaults and entry tools included.
|
|
149
|
+
*/
|
|
150
|
+
function configSnapshot() {
|
|
151
|
+
const current = settings.effective()
|
|
152
|
+
return {
|
|
153
|
+
pinned: [...pinnedSet()],
|
|
154
|
+
entryTools: [...ENTRY_TOOLS],
|
|
155
|
+
extraPinned: [...current.pinned],
|
|
156
|
+
unpinned: [...current.unpin],
|
|
157
|
+
highRisk: [...current.highRisk],
|
|
158
|
+
exemptProviders: [...exemptions.effective().providers],
|
|
159
|
+
exemptSessions: [...exemptions.effective().sessions],
|
|
160
|
+
exemptFile: exemptions.file,
|
|
161
|
+
hasExemptFile: exemptions.present(),
|
|
162
|
+
defaults: [...DEFAULT_PINNED],
|
|
163
|
+
settingsFile: settings.file,
|
|
164
|
+
// An empty file is not an override, so count keys rather than existence.
|
|
165
|
+
hasSettings: Object.keys(settings.stored()).length > 0,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* One tool's metadata plus every layer behind it, so the panel can show what
|
|
171
|
+
* is declared, what was edited, and what the model actually gets. A tool that
|
|
172
|
+
* is pinned away or not registered is reported as missing rather than absent,
|
|
173
|
+
* so the UI can explain why there is nothing to edit.
|
|
174
|
+
*/
|
|
175
|
+
function toolMetaSnapshot(name: string) {
|
|
176
|
+
const entry = getCatalog().tools.get(name)
|
|
177
|
+
const declared: ToolMeta = { ...annotationForLayers(name) }
|
|
178
|
+
return {
|
|
179
|
+
name,
|
|
180
|
+
found: entry !== undefined,
|
|
181
|
+
effective: entry?.annotation ?? declared,
|
|
182
|
+
edited: metadata.get(name) ?? {},
|
|
183
|
+
declared,
|
|
184
|
+
group: entry?.group ?? declared.group ?? '',
|
|
185
|
+
category: entry?.category ?? declared.category ?? '',
|
|
186
|
+
summary: entry?.summary ?? declared.summary ?? '',
|
|
187
|
+
highRisk: entry?.highRisk === true,
|
|
188
|
+
path: entry ? `/${entry.category}/${entry.group}/${entry.short}` : '',
|
|
189
|
+
missing: entry === undefined,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The declared metadata only: package.json first, runtime annotate() after,
|
|
195
|
+
* with panel edits left out so the UI can tell the two apart.
|
|
196
|
+
*/
|
|
197
|
+
function annotationForLayers(name: string): ToolMeta {
|
|
198
|
+
manifestAnnotations ??= readManifestAnnotations(ctx, logger)
|
|
199
|
+
const merged: ToolMeta = {}
|
|
200
|
+
for (const annotation of [...manifestAnnotations, ...registries.annotations]) {
|
|
201
|
+
const hit = typeof annotation.match === 'string' ? annotation.match === name : annotation.match.test(name)
|
|
202
|
+
if (hit) Object.assign(merged, annotation.meta)
|
|
203
|
+
}
|
|
204
|
+
return merged
|
|
205
|
+
}
|
|
206
|
+
|
|
101
207
|
// --- catalog -----------------------------------------------------------------
|
|
102
208
|
// The directory is built per agent: global tools plus tools registered in the
|
|
103
209
|
// agent's ancestor scopes (its preset). In the DSH web profile most tools
|
|
@@ -140,7 +246,7 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
|
|
|
140
246
|
for (const a of agent ? [agent] : visibility.agents()) {
|
|
141
247
|
for (const s of inheritedSchemas(a)) if (!byName.has(s.name)) byName.set(s.name, s)
|
|
142
248
|
}
|
|
143
|
-
const schemas = [...byName.values()].filter((s) => !pinned.has(s.name))
|
|
249
|
+
const schemas = [...byName.values()].filter((s) => !pinned.has(s.name) && !HOST_RESERVED.includes(s.name))
|
|
144
250
|
const key = schemas.map((s) => s.name).sort().join('\n')
|
|
145
251
|
const cached = catalogs.get(key)
|
|
146
252
|
if (cached) return cached
|
|
@@ -148,12 +254,19 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
|
|
|
148
254
|
manifestAnnotations ??= readManifestAnnotations(ctx, logger)
|
|
149
255
|
const catalog = buildCatalog(schemas, {
|
|
150
256
|
classifiers: [...registries.classifiers],
|
|
151
|
-
// Package declarations first, runtime annotate() after, so runtime wins
|
|
152
|
-
|
|
257
|
+
// Package declarations first, runtime annotate() after, so runtime wins —
|
|
258
|
+
// and panel edits last of all, so they win over both.
|
|
259
|
+
annotations: [...manifestAnnotations, ...registries.annotations, ...metadata.annotations()],
|
|
153
260
|
categories: Object.fromEntries(registries.categories),
|
|
154
|
-
highRisk:
|
|
261
|
+
highRisk: settings.effective().highRisk,
|
|
155
262
|
})
|
|
156
263
|
catalogs.set(key, catalog)
|
|
264
|
+
// A client that receives its tool list once per session cannot discover the
|
|
265
|
+
// directory by asking, so it gets the index as a file instead. Written here,
|
|
266
|
+
// after every rebuild, so it cannot drift from what tools_load would answer.
|
|
267
|
+
// An empty path turns it off.
|
|
268
|
+
const manifestFile = config.manifestFile ?? defaultManifestFile()
|
|
269
|
+
if (manifestFile) syncManifest(catalog, manifestFile, logger)
|
|
157
270
|
ctx.emit('tool-catalog/after-rebuild', { catalog })
|
|
158
271
|
return catalog
|
|
159
272
|
}
|
|
@@ -166,10 +279,7 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
|
|
|
166
279
|
// The catalog is rebuilt lazily after any registry change.
|
|
167
280
|
invalidate,
|
|
168
281
|
onStateChange: (agent, state) => ctx.emit('tool-catalog/state-changed', { agent, state }),
|
|
169
|
-
isExempt: (agent) =>
|
|
170
|
-
const provider = providerOf(agent)
|
|
171
|
-
return provider !== undefined && exemptProviders.has(provider)
|
|
172
|
-
},
|
|
282
|
+
isExempt: (agent) => exemptionOf(agent).exempt,
|
|
173
283
|
logger,
|
|
174
284
|
})
|
|
175
285
|
|
|
@@ -236,8 +346,11 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
|
|
|
236
346
|
/** Replace an agent's loaded state (e.g. restored after a restart). */
|
|
237
347
|
importState: (agent: Agent, state: Partial<SerializedState>) => visibility.importState(agent, state),
|
|
238
348
|
allowFor: (agent: Agent) => visibility.allowFor(agent),
|
|
349
|
+
deniedFor: (agent: Agent) => [...deniedFor(agent)],
|
|
239
350
|
/** True when the agent's model route is in `exemptProviders` (never narrowed). */
|
|
240
351
|
isExempt: (agent: Agent) => visibility.isExempt(agent),
|
|
352
|
+
/** Why an agent is exempt, so the panel can label a route-wide exemption. */
|
|
353
|
+
exemptionOf: (agent: Agent) => exemptionOf(agent),
|
|
241
354
|
/**
|
|
242
355
|
* Load/unload on behalf of an agent, through the same path as the entry
|
|
243
356
|
* tools (hooks, vetoes, risk gates and persistence all apply). Returns the
|
|
@@ -255,14 +368,69 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
|
|
|
255
368
|
return { agent, id: String(agent.id), parentId: parent ? String(parent.id) : null }
|
|
256
369
|
})
|
|
257
370
|
},
|
|
258
|
-
/** Effective configuration, for UI display. */
|
|
259
|
-
config: () => (
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
371
|
+
/** Effective configuration, for UI display and editing. */
|
|
372
|
+
config: () => configSnapshot(),
|
|
373
|
+
/**
|
|
374
|
+
* Persist UI-edited settings and apply them at once. The catalog is rebuilt
|
|
375
|
+
* because a changed always-on or high-risk list changes every directory.
|
|
376
|
+
*/
|
|
377
|
+
updateConfig(patch) {
|
|
378
|
+
settings.update(patch)
|
|
379
|
+
invalidate()
|
|
380
|
+
visibility.refresh()
|
|
381
|
+
ctx.emit('tool-catalog/settings-changed', { config: configSnapshot() })
|
|
382
|
+
return configSnapshot()
|
|
383
|
+
},
|
|
384
|
+
/** Re-read the settings file, for after an external edit. */
|
|
385
|
+
reloadConfig() {
|
|
386
|
+
settings.reload()
|
|
387
|
+
invalidate()
|
|
388
|
+
visibility.refresh()
|
|
389
|
+
ctx.emit('tool-catalog/settings-changed', { config: configSnapshot() })
|
|
390
|
+
return configSnapshot()
|
|
391
|
+
},
|
|
392
|
+
/**
|
|
393
|
+
* Exempt one session from narrowing, or stop exempting it. Written to the
|
|
394
|
+
* exemption file, so the choice survives a restart and is reversible by
|
|
395
|
+
* editing or deleting that one file.
|
|
396
|
+
*/
|
|
397
|
+
setExemptSession(sessionId: string, exempt: boolean) {
|
|
398
|
+
exemptions.setSession(String(sessionId), exempt)
|
|
399
|
+
// An exemption installs or lifts a restriction, so re-apply it now.
|
|
400
|
+
visibility.refresh()
|
|
401
|
+
ctx.emit('tool-catalog/settings-changed', { config: configSnapshot() })
|
|
402
|
+
return configSnapshot()
|
|
403
|
+
},
|
|
404
|
+
/** Re-read the exemption file, for after an external edit. */
|
|
405
|
+
reloadExempt() {
|
|
406
|
+
exemptions.reload()
|
|
407
|
+
visibility.refresh()
|
|
408
|
+
ctx.emit('tool-catalog/settings-changed', { config: configSnapshot() })
|
|
409
|
+
return configSnapshot()
|
|
410
|
+
},
|
|
411
|
+
/** Metadata of one tool, with the layers that decide each field. */
|
|
412
|
+
toolMeta: (name: string) => toolMetaSnapshot(String(name)),
|
|
413
|
+
/**
|
|
414
|
+
* Edit metadata from the panel. The store wins over the package
|
|
415
|
+
* declaration, and the catalog is rebuilt so the Directory tab moves the
|
|
416
|
+
* tool at once.
|
|
417
|
+
*/
|
|
418
|
+
updateToolMeta(name, meta) {
|
|
419
|
+
metadata.set(String(name), meta)
|
|
420
|
+
invalidate()
|
|
421
|
+
visibility.refresh()
|
|
422
|
+
ctx.emit('tool-catalog/metadata-changed', { name: String(name) })
|
|
423
|
+
return toolMetaSnapshot(String(name))
|
|
424
|
+
},
|
|
425
|
+
/** Drop every edited field of one tool, falling back to its declaration. */
|
|
426
|
+
clearToolMeta(name) {
|
|
427
|
+
metadata.clear(String(name))
|
|
428
|
+
invalidate()
|
|
429
|
+
visibility.refresh()
|
|
430
|
+
ctx.emit('tool-catalog/metadata-changed', { name: String(name) })
|
|
431
|
+
return toolMetaSnapshot(String(name))
|
|
432
|
+
},
|
|
433
|
+
metadataFile: () => metadata.file,
|
|
266
434
|
/** Enabled plugin packages on this host: `{ name, dir, manifest }[]`. */
|
|
267
435
|
packages: () => listPluginPackages(ctx),
|
|
268
436
|
/** Registered extension points, so a UI can show what is active. */
|
|
@@ -307,6 +475,14 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
|
|
|
307
475
|
} satisfies ToolCatalogService
|
|
308
476
|
ctx.provide('toolCatalog', service)
|
|
309
477
|
|
|
478
|
+
// --- the retrieval + routing layer ---------------------------------------------
|
|
479
|
+
//
|
|
480
|
+
// The router reuses the toolCatalog service just provided: it indexes the same
|
|
481
|
+
// catalog, loads through the same path, and reports the same token budget. It
|
|
482
|
+
// adds tools_find / tools_route / skills_find / skills_route on top of the
|
|
483
|
+
// directory tools above.
|
|
484
|
+
registerRouter(ctx, config, service)
|
|
485
|
+
|
|
310
486
|
// --- direct calls of a hidden tool -----------------------------------------------
|
|
311
487
|
//
|
|
312
488
|
// A tool this plugin hid is still a registered tool: the model may call it by
|
|
@@ -358,19 +534,46 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
|
|
|
358
534
|
return undefined
|
|
359
535
|
}
|
|
360
536
|
|
|
537
|
+
// --- tools another layer hides ---------------------------------------------------
|
|
538
|
+
// Our allowlist intersects with every other restriction on the agent (MCP
|
|
539
|
+
// connector governance, its per-session injection, other plugins), so a tool
|
|
540
|
+
// can be pinned or loaded here and still never reach the model. The host's
|
|
541
|
+
// final view tells them apart; without it nothing is reported.
|
|
542
|
+
function deniedFor(agent: Agent | undefined): Set<string> {
|
|
543
|
+
const allow = agent ? visibility.allowFor(agent) : null
|
|
544
|
+
if (!agent || !allow || typeof ctx.tools.view !== 'function') return new Set()
|
|
545
|
+
let visible: { has(name: string): boolean } | undefined
|
|
546
|
+
try {
|
|
547
|
+
visible = ctx.tools.view(agent).visible
|
|
548
|
+
} catch {
|
|
549
|
+
return new Set()
|
|
550
|
+
}
|
|
551
|
+
if (!visible) return new Set()
|
|
552
|
+
const cat = getCatalog(agent)
|
|
553
|
+
return new Set(allow.filter((name) => cat.tools.has(name) && !visible!.has(name)))
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const warnedDenied = new Set<string>()
|
|
557
|
+
function warnDenied(names: readonly string[]) {
|
|
558
|
+
const fresh = names.filter((name) => !warnedDenied.has(name))
|
|
559
|
+
if (!fresh.length) return
|
|
560
|
+
for (const name of fresh) warnedDenied.add(name)
|
|
561
|
+
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).`)
|
|
562
|
+
}
|
|
563
|
+
|
|
361
564
|
// --- listing -------------------------------------------------------------------
|
|
362
565
|
function visibleSetFor(agent: Agent | undefined): Set<string> | null {
|
|
363
566
|
const allow = agent ? visibility.allowFor(agent) : null
|
|
364
567
|
return allow ? new Set(allow) : null
|
|
365
568
|
}
|
|
366
569
|
|
|
367
|
-
function toolLine(tool: ToolEntry, visible: Set<string> | null, withPath = false): string {
|
|
570
|
+
function toolLine(tool: ToolEntry, visible: Set<string> | null, denied: Set<string>, withPath = false): string {
|
|
368
571
|
const marks = `${visible?.has(tool.name) ? '*' : ' '}${tool.highRisk ? '!' : ' '}`
|
|
369
572
|
const label = withPath ? `/${tool.category}/${tool.group}/${tool.short}` : tool.short
|
|
370
|
-
return ` ${marks} ${pad(label, withPath ? 48 : 34)}${pad(fmtTokens(tool.tokens), 11)}${tool.summary}`
|
|
573
|
+
return ` ${marks} ${pad(label, withPath ? 48 : 34)}${pad(fmtTokens(tool.tokens), 11)}${denied.has(tool.name) ? '[denied] ' : ''}${tool.summary}`
|
|
371
574
|
}
|
|
372
575
|
|
|
373
|
-
function listing(cat: Catalog, node: CatalogNode, visible: Set<string> | null): string {
|
|
576
|
+
function listing(cat: Catalog, node: CatalogNode, visible: Set<string> | null, denied: Set<string> = new Set()): string {
|
|
374
577
|
const count = (tools: readonly ToolEntry[]) => ({
|
|
375
578
|
n: tools.length,
|
|
376
579
|
loaded: tools.filter((t) => visible?.has(t.name)).length,
|
|
@@ -378,7 +581,7 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
|
|
|
378
581
|
})
|
|
379
582
|
const lines: string[] = []
|
|
380
583
|
if (node.kind === 'root') {
|
|
381
|
-
lines.push('/ (categories; * = loaded, ! = high risk; tools_ls with query searches everything)')
|
|
584
|
+
lines.push('/ (categories; * = loaded, ! = high risk, [denied] = loaded but blocked by another plugin; tools_ls with query searches everything)')
|
|
382
585
|
for (const category of cat.categories.values()) {
|
|
383
586
|
const c = count(category.groups.flatMap((g) => g.tools))
|
|
384
587
|
if (c.n === 0 && category.name !== UNCATEGORIZED) continue
|
|
@@ -392,10 +595,10 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
|
|
|
392
595
|
}
|
|
393
596
|
} else if (node.kind === 'group') {
|
|
394
597
|
lines.push(`/${node.group.category}/${node.group.name}/`)
|
|
395
|
-
for (const tool of node.group.tools) lines.push(toolLine(tool, visible))
|
|
598
|
+
for (const tool of node.group.tools) lines.push(toolLine(tool, visible, denied))
|
|
396
599
|
} else if (node.kind === 'tool') {
|
|
397
600
|
const t = node.tool
|
|
398
|
-
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' : ''}`)
|
|
601
|
+
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' : ''}`)
|
|
399
602
|
}
|
|
400
603
|
return lines.join('\n')
|
|
401
604
|
}
|
|
@@ -410,11 +613,11 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
|
|
|
410
613
|
})
|
|
411
614
|
}
|
|
412
615
|
|
|
413
|
-
function search(cat: Catalog, node: CatalogNode, query: string, visible: Set<string> | null): string {
|
|
616
|
+
function search(cat: Catalog, node: CatalogNode, query: string, visible: Set<string> | null, denied: Set<string> = new Set()): string {
|
|
414
617
|
const hits = matchingTools(cat, node, query)
|
|
415
618
|
if (!hits.length) return `No tools match "${query}".`
|
|
416
619
|
const shown = hits.slice(0, 40)
|
|
417
|
-
return [`${hits.length} match(es) for "${query}" (load by path or full name):`, ...shown.map((t) => toolLine(t, visible, true)), hits.length > shown.length ? ` … ${hits.length - shown.length} more; narrow the query or the path` : ''].filter(Boolean).join('\n')
|
|
620
|
+
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')
|
|
418
621
|
}
|
|
419
622
|
|
|
420
623
|
function agentOf(exec: { agent?: unknown } | undefined): Agent {
|
|
@@ -483,10 +686,15 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
|
|
|
483
686
|
const after = visibility.allowFor(agent) ?? []
|
|
484
687
|
const added = after.filter((n) => !before.has(n))
|
|
485
688
|
ctx.emit('tool-catalog/after-load', { agent, targets, added })
|
|
486
|
-
const
|
|
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) warnDenied(blocked)
|
|
693
|
+
const tokens = callable.reduce((sum, n) => sum + (cat.tools.get(n)?.tokens ?? 0), 0)
|
|
487
694
|
return [
|
|
488
|
-
|
|
489
|
-
...
|
|
695
|
+
callable.length ? `Loaded ${callable.length} tool(s), ${fmtTokens(tokens)}; callable from your next step:` : 'Nothing new loaded.',
|
|
696
|
+
...callable.map((n) => ` ${n}`),
|
|
697
|
+
...(blocked.length ? [`Denied by another plugin (e.g. an MCP connector rule), not callable even when loaded: ${blocked.join(', ')}`] : []),
|
|
490
698
|
...notes,
|
|
491
699
|
].join('\n')
|
|
492
700
|
}
|
|
@@ -548,8 +756,10 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
|
|
|
548
756
|
const cat = getCatalog(exec?.agent as Agent | undefined)
|
|
549
757
|
const node = resolveTarget(cat, args.path ?? '/', [...registries.resolvers])
|
|
550
758
|
if (node.kind === 'missing') return `No such path: ${node.target}. Start from tools_ls("/") or search with tools_ls({query}).`
|
|
551
|
-
const
|
|
552
|
-
|
|
759
|
+
const agent = exec?.agent as Agent | undefined
|
|
760
|
+
const visible = visibleSetFor(agent)
|
|
761
|
+
const denied = deniedFor(agent)
|
|
762
|
+
return args.query ? search(cat, node, args.query, visible, denied) : listing(cat, node, visible, denied)
|
|
553
763
|
},
|
|
554
764
|
}))
|
|
555
765
|
|