feat(dynamic-tools-cache): 4 cache tools + global __padded API

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>
This commit is contained in:
h z
2026-06-05 15:38:26 +01:00
parent a7ee414bca
commit a39fe5c2d0
3 changed files with 442 additions and 1 deletions

View File

@@ -0,0 +1,403 @@
// `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) }] };
},
};
}