diff --git a/src/tools.ts b/src/tools.ts index 7acc293..3fbb38e 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -35,6 +35,30 @@ const GREETING_DELAY_MS = (() => { return Number.isFinite(v) && v >= 0 ? v : 500; })(); +// PaddedCell tools-cache integration (cross-runtime alignment with +// Plexum decision #37; openclaw lacks a before_outgoing_tools hook so we +// opt in per-plugin). All Fabric tools are gate-able by default — agents +// must `dynamic-cache-tools` them before the model sees them. +// `gatedRegister` wraps openclaw's api.registerTool: each factory invocation +// (1) registers the tool's name+description into PaddedCell's catalog +// so dynamic-list-tools / dynamic-search-tools surface it +// (2) returns null if the per-session cache doesn't include the name +// Buffer-drain pattern handles plugin load order — if PaddedCell hasn't +// loaded yet, the stub default-allows + queues catalog entries; PaddedCell +// drains the queue when it installs the real API. +function ensurePaddedStub(): void { + const g = globalThis as unknown as { __padded?: Record & { _pendingCatalog?: Array<{ name: string; description: string }>; allowTool?: unknown; registerCatalogEntry?: unknown } }; + if (g.__padded) return; + const buf: Array<{ name: string; description: string }> = []; + g.__padded = { + _pendingCatalog: buf, + registerCatalogEntry(name: string, description: string): void { + buf.push({ name, description }); + }, + allowTool: () => true, // fail-open until PaddedCell installs the real API + }; +} + export function registerFabricTools( api: ToolApi, client: FabricClient, @@ -42,6 +66,21 @@ export function registerFabricTools( store: SubDiscussionStore, cfg: ToolsCfg, ): void { + ensurePaddedStub(); + const seenForCatalog = new Set(); + const gatedRegister = (factory: (ctx: Ctx) => unknown | null): void => { + api.registerTool((ctx: Ctx) => { + const tool = factory(ctx) as { name?: string; description?: string } | null; + if (!tool || !tool.name) return tool; + const padded = (globalThis as unknown as { __padded?: { allowTool?: (n: string, c: Ctx) => boolean; registerCatalogEntry?: (n: string, d: string) => void } }).__padded; + if (padded?.registerCatalogEntry && !seenForCatalog.has(tool.name)) { + padded.registerCatalogEntry(tool.name, tool.description ?? ''); + seenForCatalog.add(tool.name); + } + if (padded?.allowTool && !padded.allowTool(tool.name, ctx)) return null; + return tool; + }); + }; // Resolve the calling agent's Fabric session + a guild's token/endpoint. const ctxGuild = async (agentId: string, guildNodeId: string) => { const entry = identity.findByAgentId(agentId); @@ -65,7 +104,7 @@ export function registerFabricTools( // still exists for one-time bootstrap before the gateway runs, but // recruitment's `register-agent` script should prefer this tool path // so the new agent's socket is live before `interviewer` fires. - api.registerTool((ctx: Ctx) => ({ + gatedRegister((ctx: Ctx) => ({ name: 'fabric-register', description: 'Bind an agent to a Fabric Center API key. Validates the key, writes ' + @@ -128,7 +167,7 @@ export function registerFabricTools( })); const makeCreate = (kind: 'chat' | 'work' | 'report' | 'discussion') => - api.registerTool((ctx: Ctx) => ({ + gatedRegister((ctx: Ctx) => ({ name: `create-${kind}-channel`, description: `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}). ` + @@ -184,7 +223,7 @@ export function registerFabricTools( // discussion-complete: post a summary then close the channel (Guild // /channels/:id/close — history stays readable, new posts -> 409). - api.registerTool((ctx: Ctx) => ({ + gatedRegister((ctx: Ctx) => ({ name: 'discussion-complete', description: 'Conclude a discussion: post a summary then close the channel.', parameters: { @@ -242,7 +281,7 @@ export function registerFabricTools( // session prompt whenever a turn in this channel fires — so the // two roles see different instructions, no shared guide file. // ─────────────────────────────────────────────────────────────────── - api.registerTool((ctx: Ctx) => ({ + gatedRegister((ctx: Ctx) => ({ name: 'create-sub-discussion', description: 'Open a host-driven sub-discussion channel (x_type=discuss) hanging off your current channel, ' + @@ -363,7 +402,7 @@ export function registerFabricTools( // author, not the host's personal account — and can wake the host on // the parent channel to continue whatever workflow opened the sub. // ─────────────────────────────────────────────────────────────────── - api.registerTool((ctx: Ctx) => ({ + gatedRegister((ctx: Ctx) => ({ name: 'close-sub-discussion', description: 'Close a sub-discussion channel you opened (host-only) and write a callback to the parent ' + @@ -460,7 +499,7 @@ export function registerFabricTools( // fabric-canvas: share / update / read / close the channel's single // pinned canvas document (one tool, four actions). update/close are // sharer-only server-side (the guild returns 403 otherwise). - api.registerTool((ctx: Ctx) => ({ + gatedRegister((ctx: Ctx) => ({ name: 'fabric-canvas', description: "Manage a channel's pinned canvas document. action: " + @@ -537,7 +576,7 @@ export function registerFabricTools( })); // fabric-channel: channel membership (one tool, three actions). - api.registerTool((ctx: Ctx) => ({ + gatedRegister((ctx: Ctx) => ({ name: 'fabric-channel', description: 'Channel membership. action: members (list channel member userIds) | ' + @@ -589,7 +628,7 @@ export function registerFabricTools( // workload to #agents-room, or triage agent following up on an // already-routed task by commenting in #updates. // ----------------------------------------------------------------- - api.registerTool((ctx: Ctx) => ({ + gatedRegister((ctx: Ctx) => ({ name: 'fabric-send-message', description: 'Send a text message into a specific Fabric channel. Author is the calling agent. ' + @@ -634,7 +673,7 @@ export function registerFabricTools( // config → tool returns ok:false with a clear error, no fall-through // to regular agent posting. // ─────────────────────────────────────────────────────────────────── - api.registerTool((ctx: Ctx) => ({ + gatedRegister((ctx: Ctx) => ({ name: 'fabric-send-sys-msg', description: 'Send a SYSTEM-AUTHORED message into a Fabric channel (author = guild sentinel, not you). ' + @@ -708,7 +747,7 @@ export function registerFabricTools( // agent is a member of. Returns id / name / xType per channel so the // agent can pick a channelId for fabric-send-message etc. // ----------------------------------------------------------------- - api.registerTool((ctx: Ctx) => ({ + gatedRegister((ctx: Ctx) => ({ name: 'fabric-channel-list', description: 'List channels visible to the calling agent in a guild. Optional ' + @@ -769,7 +808,7 @@ export function registerFabricTools( // workflow says "find the right guild for X" — pick by purpose, // then fabric-channel-list to find the right channel inside it. // ----------------------------------------------------------------- - api.registerTool((ctx: Ctx) => ({ + gatedRegister((ctx: Ctx) => ({ name: 'fabric-guild-list', description: 'List guilds the calling agent is a member of. Returns ' + @@ -830,7 +869,7 @@ export function registerFabricTools( // channel must be public). Use this to backfill purpose on existing // channels, or to refine it after a channel's role evolves. // ----------------------------------------------------------------- - api.registerTool((ctx: Ctx) => ({ + gatedRegister((ctx: Ctx) => ({ name: 'fabric-channel-set-purpose', description: "Set or update a channel's free-form purpose description. " + @@ -873,7 +912,7 @@ export function registerFabricTools( // gated; verify a previous message went through; lookup recent // duplicates before opening a new task in triage. // ----------------------------------------------------------------- - api.registerTool((ctx: Ctx) => ({ + gatedRegister((ctx: Ctx) => ({ name: 'fabric-message-history', description: "Read a channel's recent message history. Omit seqFrom/seqTo to " +