diff --git a/plugin/index.ts b/plugin/index.ts index e13d0f9..8d8ec13 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -12,6 +12,13 @@ import { queueDynamicTrim, drainTrimQueueForSession, } from './tools/session-rewrite.js'; +import { + installGlobalApi as installPaddedGlobalApi, + createListToolsTool, + createSearchToolsTool, + createCacheToolsTool, + createEvictToolsTool, +} from './tools/dynamic-tools-cache.js'; import { safeRestart, createSafeRestartTool, @@ -51,6 +58,12 @@ function register(api: OpenClawPluginApi): void { logger.info('PaddedCell plugin initializing...'); + // Install globalThis.__padded EARLY so other plugins loaded after + // PaddedCell can register catalog entries / query allowTool. Plugins + // loaded BEFORE PaddedCell can pre-fill _pendingCatalog on a stub; + // installGlobalApi drains it. + installPaddedGlobalApi(); + const pluginConfig = getPluginConfig(api); const openclawPath = resolveOpenclawPath(pluginConfig as { openclawProfilePath?: string }); const proxyAllowlist = resolveProxyAllowlist(pluginConfig as { proxyAllowlist?: unknown; 'proxy-allowlist'?: unknown }); @@ -241,6 +254,27 @@ function register(api: OpenClawPluginApi): void { } as any; }); + // dynamic-{list,search,cache,evict}-tools — per-session tools-cache + // family (cross-runtime alignment with Plexum decision #37). All four + // are essentials (always exposed). The cache they manage gates other + // opt-in plugins via globalThis.__padded.allowTool — see + // tools/dynamic-tools-cache.ts and plans/OPENCLAW_TOOLS_FILTER_HOOK.md. + const cacheToolFactories = [ + createListToolsTool, + createSearchToolsTool, + createCacheToolsTool, + createEvictToolsTool, + ]; + for (const make of cacheToolFactories) { + api.registerTool((ctx) => { + const tool = make(); + const inner = tool.execute; + tool.execute = async (callId: string, params: any) => + inner(callId, params, { agentId: ctx.agentId, sessionId: ctx.sessionId }); + return tool; + }); + } + // Register /ego-mgr slash command if the host exposes the (non-standard) hook. // This API is not part of the current OpenClawPluginApi surface; the guard // makes the plugin a no-op for slash commands when the host doesn't support diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index bfd460e..b18c8ff 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -10,7 +10,11 @@ "pcexec", "proxy-pcexec", "safe_restart", - "dynamic-trim" + "dynamic-trim", + "dynamic-list-tools", + "dynamic-search-tools", + "dynamic-cache-tools", + "dynamic-evict-tools" ] }, "configSchema": { diff --git a/plugin/tools/dynamic-tools-cache.ts b/plugin/tools/dynamic-tools-cache.ts new file mode 100644 index 0000000..8522bf6 --- /dev/null +++ b/plugin/tools/dynamic-tools-cache.ts @@ -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 +// /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-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; + 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-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) }] }; + }, + }; +}