// `dynamic-tools-list` / `dynamic-tools-search` / `dynamic-tools-cache` / // `dynamic-tools-evict` — 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 // /agents//sessions//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-tools-list', 'dynamic-tools-search', 'dynamic-tools-cache', 'dynamic-tools-evict', ]); /** 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; 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(); 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-tools-list', description: 'List the per-session tool catalog (essentials / cached / available). Pair with dynamic-tools-cache 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-tools-cache({names: [...]}) to make available tools usable this session.', ); return { content: [{ type: 'text', text }] }; }, }; } export function createSearchToolsTool(): any { return { name: 'dynamic-tools-search', 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-tools-search: 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-tools-cache', 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-tools-cache: names is required (non-empty)' }], isError: true, }; } const fp = cacheFilePath(c.agentId, c.sessionId); if (!fp) { return { content: [{ type: 'text', text: 'dynamic-tools-cache: 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-tools-evict', 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-tools-evict: names is required (non-empty)' }], isError: true, }; } const fp = cacheFilePath(c.agentId, c.sessionId); if (!fp) { return { content: [{ type: 'text', text: 'dynamic-tools-evict: 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) }] }; }, }; }