@huanlin/dsh-plugin-tools-manager 0.2.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 +17 -0
- package/README.md +109 -0
- package/cordis.patch.yml +9 -0
- package/lib/client.js +490 -0
- package/lib/config.js +26 -0
- package/lib/gateway.js +180 -0
- package/lib/index.js +49 -0
- package/lib/policy.js +81 -0
- package/lib/registry.js +314 -0
- package/lib/settings.js +79 -0
- package/package.json +109 -0
package/lib/gateway.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gateway.ts — host-side HTTP gateway exposing the tool tree + disabled set
|
|
3
|
+
* to the browser through a self-hosted `/tools-manager/api` route.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the dsh-interpreters / dsh-better-sidebar pattern: `ctx.webServer.
|
|
6
|
+
* register` claims a prefix route, the handler reads/writes the settings seam
|
|
7
|
+
* in-process (no wire-layer allowlist gate), and the browser reaches it
|
|
8
|
+
* through `fetch('/tools-manager/api/<method>')`.
|
|
9
|
+
*
|
|
10
|
+
* Route shape:
|
|
11
|
+
* POST /tools-manager/api/list
|
|
12
|
+
* → { ok: true, value: { plugins: [{ name, tools: [{ name, description, disabled }] }] } }
|
|
13
|
+
* POST /tools-manager/api/set body: { toolName, disabled }
|
|
14
|
+
* → { ok: true, value: { plugins: [...] } } (refreshed full tree)
|
|
15
|
+
* Errors carry { ok: false, error: { code, message } }.
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-tools-manager/gateway
|
|
18
|
+
*/
|
|
19
|
+
import { SETTINGS_NAMESPACE } from './settings.js';
|
|
20
|
+
/** HTTP route prefix owning every tools-manager API request. */
|
|
21
|
+
const API_PREFIX = '/tools-manager/api';
|
|
22
|
+
/**
|
|
23
|
+
* Register the `/tools-manager/api` HTTP route on the host's web server.
|
|
24
|
+
*
|
|
25
|
+
* The route reads the tool tree from the registry and reads/writes the
|
|
26
|
+
* disabled set through the settings bridge. The settings service is optional:
|
|
27
|
+
* when absent, `list` still works (degraded to entry-source disabled set) and
|
|
28
|
+
* `set` returns a clear error.
|
|
29
|
+
* @param ctx - host context carrying `webServer`.
|
|
30
|
+
* @param registry - the tool attribution registry.
|
|
31
|
+
* @param bridge - the settings bridge the route reads through.
|
|
32
|
+
*/
|
|
33
|
+
export function registerHttpGateway(ctx, registry, bridge) {
|
|
34
|
+
let settings;
|
|
35
|
+
ctx.inject(['settings'], (sctx) => {
|
|
36
|
+
settings = sctx.settings;
|
|
37
|
+
return () => { settings = undefined; };
|
|
38
|
+
});
|
|
39
|
+
ctx.effect(() => ctx.webServer.register({
|
|
40
|
+
kind: 'prefix',
|
|
41
|
+
path: API_PREFIX,
|
|
42
|
+
handler: async (req, res) => {
|
|
43
|
+
if (req.method !== 'POST') {
|
|
44
|
+
writeJson(res, 405, envelopeError('method-not-allowed', 'POST only'));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
|
|
48
|
+
const method = pathname.startsWith(`${API_PREFIX}/`)
|
|
49
|
+
? pathname.slice(`${API_PREFIX}/`.length)
|
|
50
|
+
: undefined;
|
|
51
|
+
if (method === undefined || method.includes('/')) {
|
|
52
|
+
writeJson(res, 404, envelopeError('not-found', 'unknown tools-manager API method'));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const body = await readJsonBody(req);
|
|
57
|
+
if (method === 'list') {
|
|
58
|
+
const view = buildView(registry, bridge);
|
|
59
|
+
writeJson(res, 200, envelopeOk(view));
|
|
60
|
+
}
|
|
61
|
+
else if (method === 'set') {
|
|
62
|
+
const view = await handleSet(body, settings, registry, bridge);
|
|
63
|
+
writeJson(res, 200, envelopeOk(view));
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
writeJson(res, 404, envelopeError('not-found', `unknown tools-manager API method "${method}"`));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
71
|
+
writeJson(res, 500, envelopeError('internal', message));
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}), 'tools-manager: /tools-manager/api routes');
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Handle the `set` method: validate the patch, write the user layer, return
|
|
78
|
+
* the refreshed tree.
|
|
79
|
+
* @param body - the parsed JSON body from the request.
|
|
80
|
+
* @param settings - the live settings service (undefined when unavailable).
|
|
81
|
+
* @param registry - the tool attribution registry.
|
|
82
|
+
* @param bridge - the settings bridge for reading the source.
|
|
83
|
+
* @returns the refreshed tree view.
|
|
84
|
+
* @throws when the settings service is unavailable or the body is invalid.
|
|
85
|
+
*/
|
|
86
|
+
export async function handleSet(body, settings, registry, bridge) {
|
|
87
|
+
const patch = extractSetPatch(body);
|
|
88
|
+
if (settings === undefined) {
|
|
89
|
+
throw new Error('tools-manager: settings service is unavailable — the disabled set cannot be written');
|
|
90
|
+
}
|
|
91
|
+
const current = bridge.source();
|
|
92
|
+
let nextDisabled;
|
|
93
|
+
if (patch.disabled === true) {
|
|
94
|
+
if (!current.disabled.includes(patch.toolName)) {
|
|
95
|
+
nextDisabled = [...current.disabled, patch.toolName];
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
nextDisabled = [...current.disabled];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
nextDisabled = current.disabled.filter(name => name !== patch.toolName);
|
|
103
|
+
}
|
|
104
|
+
await settings.update(SETTINGS_NAMESPACE, { disabled: nextDisabled });
|
|
105
|
+
return buildView(registry, bridge);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Extract and validate the `set` patch from the request body.
|
|
109
|
+
*
|
|
110
|
+
* JSON wire boundary: the body must be `{ toolName: string, disabled: boolean }`.
|
|
111
|
+
* Unknown keys are dropped; missing or mistyped fields produce a 400-style
|
|
112
|
+
* error (thrown as an Error, caught by the handler and returned as
|
|
113
|
+
* `internal`).
|
|
114
|
+
* @param body - the parsed JSON body.
|
|
115
|
+
* @returns the normalized patch.
|
|
116
|
+
* @throws when the body is missing required fields or has wrong types.
|
|
117
|
+
*/
|
|
118
|
+
export function extractSetPatch(body) {
|
|
119
|
+
if (!isObject(body)) {
|
|
120
|
+
throw new Error('tools-manager: set body must be a JSON object { toolName, disabled }');
|
|
121
|
+
}
|
|
122
|
+
const toolName = Reflect.get(body, 'toolName');
|
|
123
|
+
const disabled = Reflect.get(body, 'disabled');
|
|
124
|
+
if (typeof toolName !== 'string' || toolName === '') {
|
|
125
|
+
throw new Error('tools-manager: set body `toolName` must be a non-empty string');
|
|
126
|
+
}
|
|
127
|
+
if (typeof disabled !== 'boolean') {
|
|
128
|
+
throw new Error('tools-manager: set body `disabled` must be a boolean');
|
|
129
|
+
}
|
|
130
|
+
return { toolName, disabled };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Build the full tree view from the registry + bridge.
|
|
134
|
+
* @param registry - the tool attribution registry.
|
|
135
|
+
* @param bridge - the settings bridge (for the disabled set).
|
|
136
|
+
* @returns the tree view with each tool tagged `disabled`.
|
|
137
|
+
*/
|
|
138
|
+
export function buildView(registry, bridge) {
|
|
139
|
+
const disabledSet = new Set(bridge.source().disabled);
|
|
140
|
+
const groups = registry.getTree();
|
|
141
|
+
return {
|
|
142
|
+
plugins: groups.map(group => ({
|
|
143
|
+
name: group.name,
|
|
144
|
+
tools: group.tools.map((tool) => ({
|
|
145
|
+
name: tool.name,
|
|
146
|
+
description: tool.description,
|
|
147
|
+
disabled: disabledSet.has(tool.name),
|
|
148
|
+
})),
|
|
149
|
+
})),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Read and parse a JSON body from a node:http request. */
|
|
153
|
+
async function readJsonBody(req) {
|
|
154
|
+
const chunks = [];
|
|
155
|
+
for await (const chunk of req) {
|
|
156
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
157
|
+
}
|
|
158
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
159
|
+
if (text === '')
|
|
160
|
+
return {};
|
|
161
|
+
return JSON.parse(text);
|
|
162
|
+
}
|
|
163
|
+
/** Write a JSON response envelope. */
|
|
164
|
+
function writeJson(res, status, body) {
|
|
165
|
+
const json = JSON.stringify(body);
|
|
166
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
167
|
+
res.end(json);
|
|
168
|
+
}
|
|
169
|
+
/** Build a success envelope. */
|
|
170
|
+
function envelopeOk(value) {
|
|
171
|
+
return { ok: true, value };
|
|
172
|
+
}
|
|
173
|
+
/** Build an error envelope. */
|
|
174
|
+
function envelopeError(code, message) {
|
|
175
|
+
return { ok: false, error: { code, message } };
|
|
176
|
+
}
|
|
177
|
+
/** Narrow unknown to a non-null object. */
|
|
178
|
+
function isObject(value) {
|
|
179
|
+
return typeof value === 'object' && value !== null;
|
|
180
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.ts — dsh-tools-manager host plugin entry.
|
|
3
|
+
*
|
|
4
|
+
* Wires together three halves:
|
|
5
|
+
* - `ToolRegistry` attributes each registered tool to its source plugin by
|
|
6
|
+
* snapshot-diffing `ctx.tools.schemas()` on every `tools/change`.
|
|
7
|
+
* - `installPolicy` installs the two-layer enable/disable gate:
|
|
8
|
+
* `system-prompt/assemble` hides disabled tools from the model, and
|
|
9
|
+
* `ctx.tools.guard()` denies their execution. Both read the same
|
|
10
|
+
* `disabled` set so the two layers can never drift.
|
|
11
|
+
* - `registerHttpGateway` exposes `/tools-manager/api/list|set` for the
|
|
12
|
+
* browser settings card.
|
|
13
|
+
*
|
|
14
|
+
* The disabled set persists through the `tools-manager` settings namespace in
|
|
15
|
+
* `$DSH_HOME/settings.yaml`; runtime edits update the in-memory set and the
|
|
16
|
+
* next assembly / guard call reads the fresh value — no restart needed.
|
|
17
|
+
*
|
|
18
|
+
* @module @huanlin/dsh-plugin-tools-manager
|
|
19
|
+
*/
|
|
20
|
+
import { registerHttpGateway } from './gateway.js';
|
|
21
|
+
import { installPolicy } from './policy.js';
|
|
22
|
+
import { ToolRegistry } from './registry.js';
|
|
23
|
+
import { installToolsManagerSettings } from './settings.js';
|
|
24
|
+
export { Config, resolveConfig } from './config.js';
|
|
25
|
+
export { registerHttpGateway } from './gateway.js';
|
|
26
|
+
export { installPolicy, filterTools, disabledReason, DISABLED_REASON_PREFIX } from './policy.js';
|
|
27
|
+
export { ToolRegistry, BASELINE_GROUP, UNKNOWN_GROUP } from './registry.js';
|
|
28
|
+
export { SETTINGS_NAMESPACE } from './settings.js';
|
|
29
|
+
export const name = 'tools-manager';
|
|
30
|
+
export const inject = ['tools', 'webServer', 'systemPrompt'];
|
|
31
|
+
/**
|
|
32
|
+
* Plugin body: build the registry, install the policy, register the HTTP
|
|
33
|
+
* gateway, and keep the disabled set in sync with settings changes.
|
|
34
|
+
* @param ctx - host context carrying `tools`, `webServer`, and `systemPrompt`.
|
|
35
|
+
* @param config - resolved composition config (seed).
|
|
36
|
+
*/
|
|
37
|
+
export function apply(ctx, config = {}) {
|
|
38
|
+
ctx.logger('tools-manager').info('apply() called, config=', JSON.stringify(config));
|
|
39
|
+
const bridge = installToolsManagerSettings(ctx, config);
|
|
40
|
+
const disabledSet = () => new Set(bridge.source().disabled);
|
|
41
|
+
const registry = new ToolRegistry(ctx);
|
|
42
|
+
const disposePolicy = installPolicy(ctx, disabledSet);
|
|
43
|
+
registerHttpGateway(ctx, registry, bridge);
|
|
44
|
+
ctx.logger('tools-manager').info('registry + policy + gateway installed');
|
|
45
|
+
ctx.effect(() => () => {
|
|
46
|
+
disposePolicy();
|
|
47
|
+
registry.dispose();
|
|
48
|
+
}, 'tools-manager: cleanup');
|
|
49
|
+
}
|
package/lib/policy.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* policy.ts — global tool enable/disable, two layers consistent.
|
|
3
|
+
*
|
|
4
|
+
* One `disabled: Set<string>` drives both seams so the model-visible schema
|
|
5
|
+
* and the execution gate can never drift:
|
|
6
|
+
*
|
|
7
|
+
* | Layer | Seam | Effect |
|
|
8
|
+
* |------------------|-------------------------------|--------|
|
|
9
|
+
* | Hidden (model) | `system-prompt/assemble` | Filter `assembly.tools`, return transformed assembly |
|
|
10
|
+
* | Denied (execute) | `ctx.tools.guard()` (plain) | Return a denial reason for disabled tools |
|
|
11
|
+
*
|
|
12
|
+
* `restrict()` is intentionally NOT used: it requires a scoped ctx and cannot
|
|
13
|
+
* express a process-global filter. `guard()` is monotonic and applies to
|
|
14
|
+
* every agent's calls; `system-prompt/assemble` is a waterfall that can
|
|
15
|
+
* transform `assembly.tools` and delegate the rest to downstream listeners.
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-tools-manager/policy
|
|
18
|
+
*/
|
|
19
|
+
/** Canonical denial reason returned by the guard for a disabled tool. */
|
|
20
|
+
export const DISABLED_REASON_PREFIX = 'tool is disabled by tools-manager:';
|
|
21
|
+
/**
|
|
22
|
+
* Build the canonical denial reason string for a disabled tool.
|
|
23
|
+
* @param toolName - the disabled tool's name.
|
|
24
|
+
* @returns the reason handed to {@link ToolGuard}.
|
|
25
|
+
*/
|
|
26
|
+
export function disabledReason(toolName) {
|
|
27
|
+
return `${DISABLED_REASON_PREFIX} ${JSON.stringify(toolName)}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Install the two-layer enable/disable policy.
|
|
31
|
+
*
|
|
32
|
+
* Both layers read the same `disabled` thunk so they stay consistent across
|
|
33
|
+
* settings changes without any extra notification wiring: the host entry
|
|
34
|
+
* swaps the thunk's backing store in place when settings commit, and the
|
|
35
|
+
* next assembly / guard call reads the fresh value.
|
|
36
|
+
* @param ctx - host context carrying `tools` and `systemPrompt`.
|
|
37
|
+
* @param disabled - a thunk returning the current disabled-name set.
|
|
38
|
+
* @returns the disposer that removes both layers.
|
|
39
|
+
*/
|
|
40
|
+
export function installPolicy(ctx, disabled) {
|
|
41
|
+
// Filter the FINAL assembly.tools (after delegating to downstream listeners)
|
|
42
|
+
// so the disabled set is honoured regardless of listener ordering. The
|
|
43
|
+
// waterfall's `next()` returns the assembly handed back by later listeners;
|
|
44
|
+
// we filter that result and return the transformed copy.
|
|
45
|
+
const disposeAssemble = ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
|
46
|
+
const result = await next();
|
|
47
|
+
const set = disabled();
|
|
48
|
+
if (set.size === 0)
|
|
49
|
+
return result;
|
|
50
|
+
const filteredTools = result.tools.filter(tool => !set.has(tool.name));
|
|
51
|
+
if (filteredTools.length === result.tools.length)
|
|
52
|
+
return result;
|
|
53
|
+
return { ...result, tools: filteredTools };
|
|
54
|
+
});
|
|
55
|
+
const guard = (exec) => {
|
|
56
|
+
const set = disabled();
|
|
57
|
+
if (set.size === 0)
|
|
58
|
+
return undefined;
|
|
59
|
+
if (set.has(exec.name))
|
|
60
|
+
return disabledReason(exec.name);
|
|
61
|
+
return undefined;
|
|
62
|
+
};
|
|
63
|
+
const disposeGuard = ctx.tools.guard(guard);
|
|
64
|
+
return () => {
|
|
65
|
+
disposeAssemble();
|
|
66
|
+
disposeGuard();
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Pure helper: filter a tool schema list by a disabled-name set.
|
|
71
|
+
* Exported for unit tests so the filter logic can be exercised without
|
|
72
|
+
* spinning up a cordis context.
|
|
73
|
+
* @param tools - the input tool schemas.
|
|
74
|
+
* @param disabled - the disabled-name set.
|
|
75
|
+
* @returns the input list with disabled tools removed.
|
|
76
|
+
*/
|
|
77
|
+
export function filterTools(tools, disabled) {
|
|
78
|
+
if (disabled.size === 0)
|
|
79
|
+
return [...tools];
|
|
80
|
+
return tools.filter(tool => !disabled.has(tool.name));
|
|
81
|
+
}
|
package/lib/registry.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* registry.ts — tool-to-plugin attribution registry.
|
|
3
|
+
*
|
|
4
|
+
* ## Attribution challenge
|
|
5
|
+
*
|
|
6
|
+
* dsh-tools' `ctx.tools.schemas()` returns only `{ name, description,
|
|
7
|
+
* parameters }` — there is **no source-plugin field**. The `ToolRuntime`
|
|
8
|
+
* internally tracks which scope (fiber) registered each tool via
|
|
9
|
+
* `ScopedLayers`, but that mapping is private and scope keys are opaque
|
|
10
|
+
* objects, not plugin names.
|
|
11
|
+
*
|
|
12
|
+
* ## Approach (zero source-code modification)
|
|
13
|
+
*
|
|
14
|
+
* Two mechanisms work together:
|
|
15
|
+
*
|
|
16
|
+
* 1. **`internal/plugin` + `internal/status`** — tracks which plugin is
|
|
17
|
+
* currently loading (fiber created → `apply()` running → ACTIVE). The
|
|
18
|
+
* pending-stack top is the best attribution candidate for any tool
|
|
19
|
+
* registered during that window.
|
|
20
|
+
*
|
|
21
|
+
* 2. **`tools/change` diff** — on every registry mutation, the registry
|
|
22
|
+
* diffs `ctx.tools.schemas()` against its last snapshot; new tools are
|
|
23
|
+
* attributed to the current pending-stack top (or {@link UNKNOWN_GROUP}
|
|
24
|
+
* when no plugin is pending).
|
|
25
|
+
*
|
|
26
|
+
* 3. **`ctx.registry.values()`** — enumerates all loaded plugin runtimes
|
|
27
|
+
* (name + fibers), so we can list known plugin names in the tree even
|
|
28
|
+
* when we cannot attribute specific tools to them.
|
|
29
|
+
*
|
|
30
|
+
* `ctx.tools.register` is deliberately NOT wrapped: the Cordis traceable
|
|
31
|
+
* proxy rebinds `this.ctx` to each caller's context on every property
|
|
32
|
+
* access, which is how `scopeOf(this.ctx)` routes a registration to the
|
|
33
|
+
* correct scope layer. Monkey-patching the method (e.g. `.bind(proxy)`)
|
|
34
|
+
* pins `this.ctx` to this plugin's unscoped context, collapsing every
|
|
35
|
+
* scoped registration onto the global layer and producing "already
|
|
36
|
+
* registered" collisions when agent presets mount their tool rows.
|
|
37
|
+
*
|
|
38
|
+
* ## Known limitations
|
|
39
|
+
*
|
|
40
|
+
* - **Baseline tools**: tools registered before this plugin loaded cannot be
|
|
41
|
+
* attributed to their source plugin — their `internal/plugin` event fired
|
|
42
|
+
* before we subscribed. They fall to {@link BASELINE_GROUP}.
|
|
43
|
+
* - **Async registration**: a plugin that registers tools after `apply()`
|
|
44
|
+
* returns (e.g. in a `setTimeout` or async callback) may have already left
|
|
45
|
+
* the pending stack — those tools fall to {@link UNKNOWN_GROUP}.
|
|
46
|
+
* - **Concurrent loads**: the most recently created pending fiber wins.
|
|
47
|
+
*
|
|
48
|
+
* @module dsh-tools-manager/registry
|
|
49
|
+
*/
|
|
50
|
+
/** Group name for tools registered before this plugin loaded. */
|
|
51
|
+
export const BASELINE_GROUP = '(baseline)';
|
|
52
|
+
/** Group name for tools whose source plugin could not be determined. */
|
|
53
|
+
export const UNKNOWN_GROUP = '(unknown)';
|
|
54
|
+
/**
|
|
55
|
+
* Fiber lifecycle states (cordis `FiberState` const enum, erased at compile
|
|
56
|
+
* time). Matched numerically: 0 PENDING, 1 LOADING, 2 ACTIVE, 3 FAILED,
|
|
57
|
+
* 4 DISPOSED, 5 UNLOADING.
|
|
58
|
+
*/
|
|
59
|
+
const STATE_ACTIVE = 2;
|
|
60
|
+
const STATE_FAILED = 3;
|
|
61
|
+
const STATE_DISPOSED = 4;
|
|
62
|
+
/**
|
|
63
|
+
* The tool-to-plugin attribution registry.
|
|
64
|
+
*
|
|
65
|
+
* Construction is an effect: it registers `internal/plugin`,
|
|
66
|
+
* `internal/status`, and `tools/change` listeners on the supplied context.
|
|
67
|
+
* All listeners are fibre-scoped effects and auto-clean on unload.
|
|
68
|
+
*/
|
|
69
|
+
export class ToolRegistry {
|
|
70
|
+
ctx;
|
|
71
|
+
/** pluginName → tools (preserves registration order). */
|
|
72
|
+
byPlugin = new Map();
|
|
73
|
+
/** toolName → pluginName (reverse index for fast removal). */
|
|
74
|
+
toolToPlugin = new Map();
|
|
75
|
+
/** Last schemas() snapshot (name → entry), used for removal detection. */
|
|
76
|
+
lastSnapshot = new Map();
|
|
77
|
+
/** Stack of plugin names whose fibres are still loading (not yet ACTIVE/FAILED/DISPOSED). */
|
|
78
|
+
pendingPlugins = [];
|
|
79
|
+
constructor(ctx) {
|
|
80
|
+
this.ctx = ctx;
|
|
81
|
+
this.snapshotBaseline();
|
|
82
|
+
ctx.on('internal/plugin', (fiber) => this.onPluginEvent(fiber));
|
|
83
|
+
ctx.on('internal/status', (fiber) => this.onStatusEvent(fiber));
|
|
84
|
+
ctx.on('tools/change', () => this.reconcile());
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The plugin currently in its `apply()` (pending-stack top), or undefined.
|
|
88
|
+
* @returns the name of the most recently created loading plugin.
|
|
89
|
+
*/
|
|
90
|
+
currentLoadingPlugin() {
|
|
91
|
+
return this.pendingPlugins.length > 0
|
|
92
|
+
? this.pendingPlugins[this.pendingPlugins.length - 1]
|
|
93
|
+
: undefined;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Take the initial schemas() snapshot and attribute every visible tool to
|
|
97
|
+
* {@link BASELINE_GROUP}. Also enumerate loaded plugins via ctx.registry
|
|
98
|
+
* so their names appear as empty groups in the tree (informational).
|
|
99
|
+
*/
|
|
100
|
+
snapshotBaseline() {
|
|
101
|
+
const schemas = this.readSchemas();
|
|
102
|
+
const map = new Map();
|
|
103
|
+
const entries = [];
|
|
104
|
+
for (const s of schemas) {
|
|
105
|
+
const entry = { name: s.name, description: s.description, parameters: s.parameters };
|
|
106
|
+
map.set(s.name, entry);
|
|
107
|
+
entries.push(entry);
|
|
108
|
+
this.toolToPlugin.set(s.name, BASELINE_GROUP);
|
|
109
|
+
}
|
|
110
|
+
this.lastSnapshot = map;
|
|
111
|
+
if (entries.length > 0)
|
|
112
|
+
this.byPlugin.set(BASELINE_GROUP, entries);
|
|
113
|
+
// Enumerate loaded plugins so their names appear as groups (even if empty).
|
|
114
|
+
this.enumerateLoadedPlugins();
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Enumerate all loaded plugin runtimes via `ctx.registry.values()` and
|
|
118
|
+
* register their names as (possibly empty) groups in the tree. This gives
|
|
119
|
+
* the user visibility into all loaded plugins, even when we cannot
|
|
120
|
+
* attribute specific tools to them.
|
|
121
|
+
*/
|
|
122
|
+
enumerateLoadedPlugins() {
|
|
123
|
+
const registry = this.ctx.registry;
|
|
124
|
+
if (registry === undefined || typeof registry.values !== 'function')
|
|
125
|
+
return;
|
|
126
|
+
try {
|
|
127
|
+
for (const runtime of registry.values()) {
|
|
128
|
+
const name = runtime.name;
|
|
129
|
+
if (name === undefined || name === 'root')
|
|
130
|
+
continue;
|
|
131
|
+
// Check if any fiber of this runtime is still active.
|
|
132
|
+
const hasActive = runtime.fibers.some(f => f.uid !== null && f.state !== STATE_DISPOSED);
|
|
133
|
+
if (!hasActive)
|
|
134
|
+
continue;
|
|
135
|
+
// Register as an empty group if not already present.
|
|
136
|
+
if (!this.byPlugin.has(name)) {
|
|
137
|
+
this.byPlugin.set(name, []);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// Registry enumeration is best-effort; failures are non-fatal.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* `internal/plugin` fires on fibre creation (uid just assigned) and on
|
|
147
|
+
* disposal (uid cleared). Creation pushes the name onto the pending stack;
|
|
148
|
+
* disposal is also handled by `internal/status` (DISPOSED), so this path
|
|
149
|
+
* only guards against a missing status event.
|
|
150
|
+
*/
|
|
151
|
+
onPluginEvent(fiber) {
|
|
152
|
+
if (fiber.uid !== null) {
|
|
153
|
+
// Created: push onto pending stack (deduped — a restart fires creation
|
|
154
|
+
// again before the old DISPOSED clears).
|
|
155
|
+
if (!this.pendingPlugins.includes(fiber.name)) {
|
|
156
|
+
this.pendingPlugins.push(fiber.name);
|
|
157
|
+
}
|
|
158
|
+
// Register as an empty group so it appears in the tree immediately.
|
|
159
|
+
if (!this.byPlugin.has(fiber.name) && fiber.name !== 'root') {
|
|
160
|
+
this.byPlugin.set(fiber.name, []);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
this.removeFromPending(fiber.name);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* `internal/status` fires on every state transition. When a fibre reaches
|
|
169
|
+
* ACTIVE, FAILED, or DISPOSED it is no longer "pending" — its apply() has
|
|
170
|
+
* finished (or thrown) and tools registered during apply() have already
|
|
171
|
+
* fired their `tools/change`.
|
|
172
|
+
*/
|
|
173
|
+
onStatusEvent(fiber) {
|
|
174
|
+
if (fiber.state === STATE_ACTIVE || fiber.state === STATE_FAILED || fiber.state === STATE_DISPOSED) {
|
|
175
|
+
this.removeFromPending(fiber.name);
|
|
176
|
+
}
|
|
177
|
+
// On DISPOSED, also drop the plugin's group from the tree — its tools
|
|
178
|
+
// auto-unregister via effect cleanup, so the next tools/change reconcile
|
|
179
|
+
// will remove them, but the empty group lingers unless we clear it here.
|
|
180
|
+
if (fiber.state === STATE_DISPOSED) {
|
|
181
|
+
this.byPlugin.delete(fiber.name);
|
|
182
|
+
}
|
|
183
|
+
// On ACTIVE, re-enumerate plugins to catch any that loaded while we
|
|
184
|
+
// were processing.
|
|
185
|
+
if (fiber.state === STATE_ACTIVE) {
|
|
186
|
+
this.enumerateLoadedPlugins();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** Remove a plugin name from the pending stack (preserves order of the rest). */
|
|
190
|
+
removeFromPending(name) {
|
|
191
|
+
if (this.pendingPlugins.length === 0)
|
|
192
|
+
return;
|
|
193
|
+
this.pendingPlugins = this.pendingPlugins.filter(n => n !== name);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Diff the current `ctx.tools.schemas()` against the last snapshot and
|
|
197
|
+
* update the attribution map.
|
|
198
|
+
*
|
|
199
|
+
* New tools → attributed via pending attribution (from register wrapper)
|
|
200
|
+
* or pending-stack top, or {@link UNKNOWN_GROUP}.
|
|
201
|
+
* Removed tools → deleted from the map and from their plugin group.
|
|
202
|
+
* Existing tools → description / parameters refreshed in place.
|
|
203
|
+
*/
|
|
204
|
+
reconcile() {
|
|
205
|
+
const schemas = this.readSchemas();
|
|
206
|
+
const current = new Map();
|
|
207
|
+
for (const s of schemas) {
|
|
208
|
+
current.set(s.name, { name: s.name, description: s.description, parameters: s.parameters });
|
|
209
|
+
}
|
|
210
|
+
// Added or changed tools.
|
|
211
|
+
for (const [name, entry] of current) {
|
|
212
|
+
const prev = this.lastSnapshot.get(name);
|
|
213
|
+
if (prev === undefined) {
|
|
214
|
+
this.attributeNew(name, entry);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
this.refreshExisting(name, entry);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Removed tools.
|
|
221
|
+
for (const [name] of this.lastSnapshot) {
|
|
222
|
+
if (!current.has(name)) {
|
|
223
|
+
this.removeTool(name);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
this.lastSnapshot = current;
|
|
227
|
+
}
|
|
228
|
+
/** Attribute a newly registered tool using pending attribution or pending-stack top. */
|
|
229
|
+
attributeNew(name, entry) {
|
|
230
|
+
// Attribute to the plugin currently in its apply() (pending-stack top),
|
|
231
|
+
// or UNKNOWN_GROUP when no plugin is pending.
|
|
232
|
+
const owner = this.currentLoadingPlugin() ?? UNKNOWN_GROUP;
|
|
233
|
+
// If the tool was previously attributed (e.g. re-registered after a
|
|
234
|
+
// dispose/reload), remove it from its old group first.
|
|
235
|
+
const prevOwner = this.toolToPlugin.get(name);
|
|
236
|
+
if (prevOwner !== undefined && prevOwner !== owner) {
|
|
237
|
+
this.removeToolFromGroup(name, prevOwner);
|
|
238
|
+
}
|
|
239
|
+
const list = this.byPlugin.get(owner) ?? [];
|
|
240
|
+
list.push(entry);
|
|
241
|
+
this.byPlugin.set(owner, list);
|
|
242
|
+
this.toolToPlugin.set(name, owner);
|
|
243
|
+
}
|
|
244
|
+
/** Refresh an existing tool's description/parameters in its current group. */
|
|
245
|
+
refreshExisting(name, entry) {
|
|
246
|
+
const owner = this.toolToPlugin.get(name);
|
|
247
|
+
if (owner === undefined) {
|
|
248
|
+
// Previously unseen but in the snapshot — attribute now.
|
|
249
|
+
this.attributeNew(name, entry);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const list = this.byPlugin.get(owner);
|
|
253
|
+
if (list === undefined)
|
|
254
|
+
return;
|
|
255
|
+
const idx = list.findIndex(e => e.name === name);
|
|
256
|
+
if (idx >= 0) {
|
|
257
|
+
list[idx] = entry;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
list.push(entry);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/** Remove a tool from the attribution map and its plugin group. */
|
|
264
|
+
removeTool(name) {
|
|
265
|
+
const owner = this.toolToPlugin.get(name);
|
|
266
|
+
if (owner === undefined)
|
|
267
|
+
return;
|
|
268
|
+
this.removeToolFromGroup(name, owner);
|
|
269
|
+
this.toolToPlugin.delete(name);
|
|
270
|
+
}
|
|
271
|
+
/** Remove a tool from one specific plugin group (helper). */
|
|
272
|
+
removeToolFromGroup(name, owner) {
|
|
273
|
+
const list = this.byPlugin.get(owner);
|
|
274
|
+
if (list === undefined)
|
|
275
|
+
return;
|
|
276
|
+
const idx = list.findIndex(e => e.name === name);
|
|
277
|
+
if (idx >= 0)
|
|
278
|
+
list.splice(idx, 1);
|
|
279
|
+
// Don't delete empty groups — they may represent loaded plugins with
|
|
280
|
+
// no tools yet (from enumerateLoadedPlugins / internal/plugin).
|
|
281
|
+
}
|
|
282
|
+
/** Read `ctx.tools.schemas()` and narrow to the fields we use. */
|
|
283
|
+
readSchemas() {
|
|
284
|
+
const schemas = this.ctx.tools.schemas();
|
|
285
|
+
return Array.isArray(schemas) ? schemas : [];
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* The attribution tree: one entry per plugin group, in insertion order,
|
|
289
|
+
* each carrying its tools in registration order.
|
|
290
|
+
* @returns a snapshot of the current tree.
|
|
291
|
+
*/
|
|
292
|
+
getTree() {
|
|
293
|
+
return [...this.byPlugin.entries()].map(([name, tools]) => ({
|
|
294
|
+
name,
|
|
295
|
+
tools: tools.map(t => ({ ...t })),
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* The set of disabled tool names (delegated to {@link ToolPolicy} via the
|
|
300
|
+
* host entry). Exposed for the gateway to tag each tool row with its
|
|
301
|
+
* disabled state.
|
|
302
|
+
*/
|
|
303
|
+
isDisabled(name, disabled) {
|
|
304
|
+
return disabled.has(name);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* No-op disposal hook. All listeners are registered through `ctx.on()`
|
|
308
|
+
* (fibre-scoped effects) and auto-clean on unload; there is no manually
|
|
309
|
+
* installed wrapper to restore.
|
|
310
|
+
*/
|
|
311
|
+
dispose() {
|
|
312
|
+
// intentionally empty — listeners are fibre-scoped effects.
|
|
313
|
+
}
|
|
314
|
+
}
|