Implements the openclaw side of Plexum decision #37: agent-driven per-session whitelist of which tools the model sees. Cross-runtime aligned with Plexum's host implementation (same tool names, same input schemas, takes-effect-next-turn). New surface: - tools/dynamic-tools-cache.ts: Catalog (in-memory map), Cache file (atomic tmp+rename at <openclaw>/agents/<id>/sessions/<sid>/dynamic-tools-cache.json), 4 tool factories (list/search/cache/evict), allowTool gate, installGlobalApi for globalThis.__padded with buffer-drain - index.ts: install __padded early; register the 4 cache tools as factories wrapping ctx into execute - openclaw.plugin.json contracts.tools += 4 names Other Hangman-Lab plugins (Fabric / HF / Dialectic) opt in to the gate by wrapping their registerTool calls (separate commits in those repos). Built-in openclaw tools and non-opt-in plugins remain always-on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
404 lines
13 KiB
TypeScript
404 lines
13 KiB
TypeScript
// `dynamic-list-tools` / `dynamic-search-tools` / `dynamic-cache-tools` /
|
|
// `dynamic-evict-tools` — agent-driven per-session tool visibility gate.
|
|
//
|
|
// openclaw plugin SDK has no `before_outgoing_tools` hook (see PaddedCell
|
|
// plans/OPENCLAW_TOOLS_FILTER_HOOK.md for the gap analysis), so this
|
|
// implementation relies on a per-plugin opt-in pattern via the global
|
|
// __padded API:
|
|
//
|
|
// - PaddedCell exposes:
|
|
// globalThis.__padded.registerCatalogEntry(name, description)
|
|
// globalThis.__padded.allowTool(name, ctx) => boolean
|
|
// - Other Hangman-Lab plugins wrap their tool factories:
|
|
// api.registerTool((ctx) => {
|
|
// if (!globalThis.__padded?.allowTool?.(NAME, ctx)) return null;
|
|
// return realTool;
|
|
// });
|
|
// and at init:
|
|
// globalThis.__padded?.registerCatalogEntry?.(NAME, DESC);
|
|
//
|
|
// Built-in openclaw tools (read/edit/grep/bash) and plugins that don't
|
|
// opt in to the gate are always-on. The 4 tools below are themselves
|
|
// always-on (essentials, hardcoded). Per-session cache state lives at
|
|
// <openclaw>/agents/<agentId>/sessions/<sessionId>/dynamic-tools-cache.json.
|
|
//
|
|
// Cross-runtime aligned with Plexum's decision #37 family — same tool
|
|
// names, same input schemas, same takes-effect-next-turn semantics.
|
|
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
|
|
/** Names of tools always exposed (the 4 cache tools themselves). */
|
|
export const ESSENTIAL_CACHE_TOOLS = new Set([
|
|
'dynamic-list-tools',
|
|
'dynamic-search-tools',
|
|
'dynamic-cache-tools',
|
|
'dynamic-evict-tools',
|
|
]);
|
|
|
|
/** Catalog entry registered by an opt-in plugin. */
|
|
export interface CatalogEntry {
|
|
name: string;
|
|
description: string;
|
|
}
|
|
|
|
interface CacheFileShape {
|
|
version: number;
|
|
cached: Array<{ name: string; added_at_turn: number }>;
|
|
}
|
|
|
|
const CACHE_FILE_NAME = 'dynamic-tools-cache.json';
|
|
|
|
function openclawRoot(): string {
|
|
const env = process.env.OPENCLAW_PATH;
|
|
if (env) return env;
|
|
const home = process.env.HOME || os.homedir();
|
|
return path.join(home, '.openclaw');
|
|
}
|
|
|
|
function cacheFilePath(agentId: string | undefined, sessionId: string | undefined): string | null {
|
|
if (!agentId || !sessionId) return null;
|
|
return path.join(openclawRoot(), 'agents', agentId, 'sessions', sessionId, CACHE_FILE_NAME);
|
|
}
|
|
|
|
function readCache(filePath: string): CacheFileShape {
|
|
try {
|
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
if (!raw) return { version: 1, cached: [] };
|
|
const parsed = JSON.parse(raw) as Partial<CacheFileShape>;
|
|
return { version: parsed.version ?? 1, cached: parsed.cached ?? [] };
|
|
} catch (err: unknown) {
|
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
return { version: 1, cached: [] };
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function writeCache(filePath: string, data: CacheFileShape): void {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
const tmp = `${filePath}.tmp`;
|
|
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
fs.renameSync(tmp, filePath);
|
|
}
|
|
|
|
/** In-process catalog of opt-in plugin tools. */
|
|
class Catalog {
|
|
private entries = new Map<string, string>();
|
|
|
|
register(name: string, description: string): void {
|
|
if (!name) return;
|
|
this.entries.set(name, description ?? '');
|
|
}
|
|
|
|
has(name: string): boolean {
|
|
return this.entries.has(name);
|
|
}
|
|
|
|
list(): CatalogEntry[] {
|
|
return Array.from(this.entries, ([name, description]) => ({ name, description }));
|
|
}
|
|
|
|
search(query: string): CatalogEntry[] {
|
|
const q = query.toLowerCase().trim();
|
|
if (!q) return [];
|
|
return this.list().filter(
|
|
(e) => e.name.toLowerCase().includes(q) || e.description.toLowerCase().includes(q),
|
|
);
|
|
}
|
|
}
|
|
|
|
const catalog = new Catalog();
|
|
|
|
/**
|
|
* Drain any catalog entries that were queued before PaddedCell finished
|
|
* initializing. The buffer-drain pattern lets other plugins call
|
|
* `globalThis.__padded.registerCatalogEntry` at load time without
|
|
* worrying about load order; PaddedCell installs an early stub with
|
|
* `_pendingCatalog: []`, and on real init we slurp those into `catalog`.
|
|
*/
|
|
export function drainPendingCatalog(): number {
|
|
const stub = (globalThis as any).__padded;
|
|
if (!stub || !Array.isArray(stub._pendingCatalog)) return 0;
|
|
let n = 0;
|
|
for (const e of stub._pendingCatalog) {
|
|
if (e?.name) {
|
|
catalog.register(String(e.name), String(e.description ?? ''));
|
|
n++;
|
|
}
|
|
}
|
|
stub._pendingCatalog = [];
|
|
return n;
|
|
}
|
|
|
|
/**
|
|
* allowTool — the gate every opt-in plugin's factory queries. Returns
|
|
* true iff: the name is an essential cache tool, OR it's in the
|
|
* per-session cache file. ctx must carry agentId + sessionId (factories
|
|
* receive them from openclaw).
|
|
*/
|
|
export function allowTool(name: string, ctx: { agentId?: string; sessionId?: string }): boolean {
|
|
if (ESSENTIAL_CACHE_TOOLS.has(name)) return true;
|
|
const fp = cacheFilePath(ctx.agentId, ctx.sessionId);
|
|
if (!fp) return true; // no session context => can't gate, default-allow
|
|
try {
|
|
const cache = readCache(fp);
|
|
return cache.cached.some((e) => e.name === name);
|
|
} catch {
|
|
return true; // any read error => default-allow (don't break agent)
|
|
}
|
|
}
|
|
|
|
/** Install the global API onto globalThis. Idempotent — drains a pre-existing stub. */
|
|
export function installGlobalApi(): void {
|
|
const existing = (globalThis as any).__padded;
|
|
const api = {
|
|
registerCatalogEntry(name: string, description: string): void {
|
|
catalog.register(name, description);
|
|
},
|
|
allowTool,
|
|
_pendingCatalog: [] as Array<{ name: string; description: string }>,
|
|
};
|
|
(globalThis as any).__padded = api;
|
|
if (existing?._pendingCatalog?.length) {
|
|
for (const e of existing._pendingCatalog) {
|
|
if (e?.name) catalog.register(String(e.name), String(e.description ?? ''));
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Test hook — wipe catalog state between tests. */
|
|
export function _resetCatalogForTest(): void {
|
|
(catalog as any).entries = new Map();
|
|
}
|
|
|
|
// ----- tool factories -----
|
|
|
|
interface ToolCtx {
|
|
agentId?: string;
|
|
sessionId?: string;
|
|
}
|
|
|
|
function currentTurn(): number {
|
|
// openclaw plugin ctx doesn't carry turn number; record 0 as a
|
|
// placeholder. The field is informational (sorting / debug) — the
|
|
// gate semantics don't depend on it.
|
|
return 0;
|
|
}
|
|
|
|
function classifySource(name: string, ctx: ToolCtx): 'essentials' | 'cached' | 'available' {
|
|
if (ESSENTIAL_CACHE_TOOLS.has(name)) return 'essentials';
|
|
const fp = cacheFilePath(ctx.agentId, ctx.sessionId);
|
|
if (fp) {
|
|
try {
|
|
const c = readCache(fp);
|
|
if (c.cached.some((e) => e.name === name)) return 'cached';
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
return 'available';
|
|
}
|
|
|
|
function renderCatalogText(
|
|
entries: CatalogEntry[],
|
|
ctx: ToolCtx,
|
|
header: string,
|
|
): string {
|
|
let ess = 0, cac = 0, avl = 0;
|
|
const tagged = entries.map((e) => {
|
|
const src = classifySource(e.name, ctx);
|
|
if (src === 'essentials') ess++;
|
|
else if (src === 'cached') cac++;
|
|
else avl++;
|
|
return { ...e, source: src };
|
|
});
|
|
const sourceRank = (s: string) => (s === 'essentials' ? 0 : s === 'cached' ? 1 : 2);
|
|
tagged.sort((a, b) => {
|
|
const ra = sourceRank(a.source), rb = sourceRank(b.source);
|
|
if (ra !== rb) return ra - rb;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
const lines: string[] = [];
|
|
lines.push(header);
|
|
lines.push(`total: ${tagged.length} (essentials: ${ess}, cached: ${cac}, available: ${avl})`);
|
|
lines.push('');
|
|
for (const t of tagged) {
|
|
lines.push(`[${t.source}] ${t.name}`);
|
|
lines.push(` ${t.description}`);
|
|
lines.push('');
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
export function createListToolsTool(): any {
|
|
return {
|
|
name: 'dynamic-list-tools',
|
|
description:
|
|
'List the per-session tool catalog (essentials / cached / available). Pair with dynamic-cache-tools to enable specific tools for this session. Cache takes effect next turn.',
|
|
parameters: { type: 'object', properties: {}, required: [] },
|
|
async execute(_callId: string, _params: any, ctx?: ToolCtx) {
|
|
const c = ctx ?? {};
|
|
const entries = catalog.list();
|
|
// Also include essential cache tools themselves so the catalog is honest
|
|
for (const n of ESSENTIAL_CACHE_TOOLS) {
|
|
if (!entries.some((e) => e.name === n)) {
|
|
entries.push({ name: n, description: '(PaddedCell cache control tool)' });
|
|
}
|
|
}
|
|
const text = renderCatalogText(
|
|
entries,
|
|
c,
|
|
'Call dynamic-cache-tools({names: [...]}) to make available tools usable this session.',
|
|
);
|
|
return { content: [{ type: 'text', text }] };
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createSearchToolsTool(): any {
|
|
return {
|
|
name: 'dynamic-search-tools',
|
|
description:
|
|
'Substring search (case-insensitive) over tool names + descriptions. Returns matches grouped by source.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: { query: { type: 'string' } },
|
|
required: ['query'],
|
|
},
|
|
async execute(_callId: string, params: any, ctx?: ToolCtx) {
|
|
const c = ctx ?? {};
|
|
const query = String(params?.query ?? '').trim();
|
|
if (!query) {
|
|
return {
|
|
content: [{ type: 'text', text: 'dynamic-search-tools: query is required' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
const matched = catalog.search(query);
|
|
const text = renderCatalogText(
|
|
matched,
|
|
c,
|
|
`query: "${query}" — matches: ${matched.length}`,
|
|
);
|
|
return { content: [{ type: 'text', text }] };
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createCacheToolsTool(): any {
|
|
return {
|
|
name: 'dynamic-cache-tools',
|
|
description:
|
|
'Add the given tool names to this session\'s tools-cache. Takes effect starting your next turn (this iteration\'s tool list is frozen).',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
names: { type: 'array', items: { type: 'string' } },
|
|
},
|
|
required: ['names'],
|
|
},
|
|
async execute(_callId: string, params: any, ctx?: ToolCtx) {
|
|
const c = ctx ?? {};
|
|
const names = Array.isArray(params?.names) ? params.names.map(String) : [];
|
|
if (names.length === 0) {
|
|
return {
|
|
content: [{ type: 'text', text: 'dynamic-cache-tools: names is required (non-empty)' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
const fp = cacheFilePath(c.agentId, c.sessionId);
|
|
if (!fp) {
|
|
return {
|
|
content: [{ type: 'text', text: 'dynamic-cache-tools: no session context' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
const cache = readCache(fp);
|
|
const known = new Set(catalog.list().map((e) => e.name));
|
|
// Essentials are always allowed; recognize them as "known" too.
|
|
for (const n of ESSENTIAL_CACHE_TOOLS) known.add(n);
|
|
const added: string[] = [];
|
|
const alreadyCached: string[] = [];
|
|
const unknown: string[] = [];
|
|
const turn = currentTurn();
|
|
for (const n of names) {
|
|
if (!known.has(n)) {
|
|
unknown.push(n);
|
|
continue;
|
|
}
|
|
if (cache.cached.some((e) => e.name === n)) {
|
|
alreadyCached.push(n);
|
|
continue;
|
|
}
|
|
cache.cached.push({ name: n, added_at_turn: turn });
|
|
added.push(n);
|
|
}
|
|
writeCache(fp, cache);
|
|
const result = {
|
|
added,
|
|
already_cached: alreadyCached,
|
|
unknown,
|
|
note: 'Newly cached tools are available starting your next turn.',
|
|
};
|
|
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createEvictToolsTool(): any {
|
|
return {
|
|
name: 'dynamic-evict-tools',
|
|
description:
|
|
'Remove the given tool names from this session\'s tools-cache. Essentials are silently un-evictable. Takes effect starting your next turn.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
names: { type: 'array', items: { type: 'string' } },
|
|
},
|
|
required: ['names'],
|
|
},
|
|
async execute(_callId: string, params: any, ctx?: ToolCtx) {
|
|
const c = ctx ?? {};
|
|
const names = Array.isArray(params?.names) ? params.names.map(String) : [];
|
|
if (names.length === 0) {
|
|
return {
|
|
content: [{ type: 'text', text: 'dynamic-evict-tools: names is required (non-empty)' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
const fp = cacheFilePath(c.agentId, c.sessionId);
|
|
if (!fp) {
|
|
return {
|
|
content: [{ type: 'text', text: 'dynamic-evict-tools: no session context' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
const cache = readCache(fp);
|
|
const evicted: string[] = [];
|
|
const notCached: string[] = [];
|
|
for (const n of names) {
|
|
if (ESSENTIAL_CACHE_TOOLS.has(n)) {
|
|
notCached.push(n);
|
|
continue;
|
|
}
|
|
const idx = cache.cached.findIndex((e) => e.name === n);
|
|
if (idx < 0) {
|
|
notCached.push(n);
|
|
continue;
|
|
}
|
|
cache.cached.splice(idx, 1);
|
|
evicted.push(n);
|
|
}
|
|
writeCache(fp, cache);
|
|
const result = {
|
|
evicted,
|
|
not_cached: notCached,
|
|
note: 'Evictions take effect starting your next turn.',
|
|
};
|
|
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
},
|
|
};
|
|
}
|