// dynamic-kb.ts โ€” 5 host tools (dynamic-kb-list-kbs / list-topics / // list-facts / cache / evict) implementing the agent-side knowledge- // base browse + cache flow per DESIGN-DYNAMIC-BLOCK.md ยง4.4. // // Cross-runtime aligned with Plexum's dynmem/kb_tools.go โ€” same tool // names, same input shapes, same return shapes (so ClawSkills // workflow text reads identically on either runtime). // // Returns ToolResult.content[].text for both ok and error cases. The // `isError: true` flag surfaces errors to the model rather than RPC- // failing the tool call. import { Block as KBBlock } from './kbblock.js'; import { KBClient } from './kbclient.js'; interface ToolCtx { agentId?: string; sessionId?: string; } interface ToolResult { content: Array<{ type: 'text'; text: string }>; isError?: boolean; } function ok(text: string): ToolResult { return { content: [{ type: 'text', text }] }; } function err(text: string): ToolResult { return { isError: true, content: [{ type: 'text', text }] }; } /** Dependencies the 5 KB tools share. Built once in index.ts. */ export interface KBDeps { /** HF backend KB HTTP client. */ client: KBClient | null; /** Resolve hf-token for the agent at call time (via secret-mgr). null โ†’ no auth */ tokenFor: ((agentId: string) => Promise) | null; /** Build new client given a token (so each call gets fresh per-agent client). */ makeClient: ((token: string) => KBClient) | null; /** Best-effort current turn number; 0 is acceptable. */ turnFor: (agentId: string) => number; } /** * Resolve a per-agent KBClient. Returns null with reason if no client * (no agent context / no token / no backend URL). */ async function clientFor(deps: KBDeps, ctx: ToolCtx): Promise<{ c: KBClient | null; reason: string }> { if (!ctx.agentId) return { c: null, reason: 'no agent context' }; if (deps.makeClient && deps.tokenFor) { const tok = await deps.tokenFor(ctx.agentId); if (!tok) return { c: null, reason: 'agent has no hf-token in secret-mgr' }; return { c: deps.makeClient(tok), reason: '' }; } if (deps.client) return { c: deps.client, reason: '' }; return { c: null, reason: 'HF KB backend unavailable' }; } // ----- 5 tool factories ----- export function createListKBsTool(deps: KBDeps) { return { name: 'dynamic-kb-list-kbs', description: 'List HarborForge Knowledge Bases the agent can access. Optional project-code filter.', parameters: { type: 'object', properties: { 'project-code': { type: 'string' } }, }, async execute(_id: string, params: any, ctxArg?: ToolCtx): Promise { const ctx = ctxArg ?? {}; const r = await clientFor(deps, ctx); if (!r.c) return err('dynamic-kb-list-kbs: ' + r.reason); const proj = params?.['project-code']; try { const kbs = await r.c.listKBs(typeof proj === 'string' ? proj : undefined); const lines: string[] = []; if (proj) lines.push(`project: ${proj}`); lines.push(`kbs: ${kbs.length}`, ''); for (const k of kbs) { lines.push(`[${k.knowledge_base_code}] ${k.title}`); lines.push(` ${k.description || ''}`, ''); } lines.push(`Call dynamic-kb-list-topics({kb-code: ""}) to drill into a KB.`); return ok(lines.join('\n')); } catch (e: any) { return err('dynamic-kb-list-kbs: ' + (e?.message || String(e))); } }, }; } export function createListTopicsTool(deps: KBDeps) { return { name: 'dynamic-kb-list-topics', description: 'List topics in one HF KB by code.', parameters: { type: 'object', properties: { 'kb-code': { type: 'string' } }, required: ['kb-code'], }, async execute(_id: string, params: any, ctxArg?: ToolCtx): Promise { const ctx = ctxArg ?? {}; const r = await clientFor(deps, ctx); if (!r.c) return err('dynamic-kb-list-topics: ' + r.reason); const kbCode = String(params?.['kb-code'] ?? ''); if (!kbCode) return err('dynamic-kb-list-topics: kb-code required'); try { const ts = await r.c.listTopics(kbCode); const lines = [ `kb: ${kbCode}`, `topics: ${ts.length}`, '', ...ts.flatMap((t) => [`[${t.id}] ${t.topic}`, ` ${t.description || ''}`, '']), `Call dynamic-kb-list-facts({kb-code: "${kbCode}", topic-ids: [, ...]}) to drill into facts.`, ]; return ok(lines.join('\n')); } catch (e: any) { return err('dynamic-kb-list-topics: ' + (e?.message || String(e))); } }, }; } export function createListFactsTool(deps: KBDeps) { return { name: 'dynamic-kb-list-facts', description: 'List fact previews (id/topic/title/snippet) in given topics. Feed into dynamic-kb-cache.', parameters: { type: 'object', properties: { 'kb-code': { type: 'string' }, 'topic-ids': { type: 'array', items: { type: 'integer' } }, }, required: ['kb-code', 'topic-ids'], }, async execute(_id: string, params: any, ctxArg?: ToolCtx): Promise { const ctx = ctxArg ?? {}; const r = await clientFor(deps, ctx); if (!r.c) return err('dynamic-kb-list-facts: ' + r.reason); const kbCode = String(params?.['kb-code'] ?? ''); const topicIds = Array.isArray(params?.['topic-ids']) ? params['topic-ids'].map((x: any) => Number(x)) : []; if (!kbCode || topicIds.length === 0) { return err('dynamic-kb-list-facts: kb-code + topic-ids required'); } try { const facts = await r.c.listFacts(kbCode, topicIds); const lines = [ `kb: ${kbCode}`, `topics: [${topicIds.join(', ')}]`, `facts: ${facts.length}`, '', ...facts.flatMap((f) => [ `[${f.id}] (topic ${f.topic_id}/${f.topic_slug}) ${f.title}`, ` ${f.snippet}`, '', ]), `Call dynamic-kb-cache({kb-code: "${kbCode}", fact-ids: [, ...]}) to commit selected facts to your kb-block.`, ]; return ok(lines.join('\n')); } catch (e: any) { return err('dynamic-kb-list-facts: ' + (e?.message || String(e))); } }, }; } export function createCacheTool(deps: KBDeps) { return { name: 'dynamic-kb-cache', description: 'Cache KB facts into your per-session kb-block (visible next turn).', parameters: { type: 'object', properties: { 'kb-code': { type: 'string' }, 'fact-ids': { type: 'array', items: { type: 'integer' } }, 'previous-dynamic-id': { type: 'string' }, }, required: ['kb-code', 'fact-ids'], }, async execute(_id: string, params: any, ctxArg?: ToolCtx): Promise { const ctx = ctxArg ?? {}; if (!ctx.agentId || !ctx.sessionId) { return err('dynamic-kb-cache: no per-session context'); } const r = await clientFor(deps, ctx); if (!r.c) return err('dynamic-kb-cache: ' + r.reason); const kbCode = String(params?.['kb-code'] ?? ''); const factIds = Array.isArray(params?.['fact-ids']) ? params['fact-ids'].map((x: any) => Number(x)) : []; if (!kbCode || factIds.length === 0) { return err('dynamic-kb-cache: kb-code + fact-ids required'); } let block: KBBlock; try { block = KBBlock.open(ctx.agentId, ctx.sessionId); } catch (e: any) { return err('dynamic-kb-cache: open block: ' + (e?.message || String(e))); } const toFetch: number[] = []; const alreadyCached: number[] = []; for (const id of factIds) { if (block.has(id)) alreadyCached.push(id); else toFetch.push(id); } let fetched: Array<{ id: number; kb_code: string; topic_slug: string; content: string }> = []; if (toFetch.length > 0) { try { fetched = await r.c.getFacts(kbCode, toFetch); } catch (e: any) { return err('dynamic-kb-cache: ' + (e?.message || String(e))); } } const turn = deps.turnFor(ctx.agentId); const fetchedByID = new Map(fetched.map((f) => [f.id, f])); const added: number[] = []; for (const id of toFetch) { const f = fetchedByID.get(id); if (!f) continue; if (block.add(f.id, kbCode, f.topic_slug, f.content, turn)) added.push(f.id); } try { block.save(); } catch (e: any) { return err('dynamic-kb-cache: save: ' + (e?.message || String(e))); } const missing = toFetch.filter((id) => !added.includes(id)); return ok( JSON.stringify({ added: added.sort((a, b) => a - b), already_cached: alreadyCached.sort((a, b) => a - b), missing: missing.length ? missing.sort((a, b) => a - b) : undefined, note: 'Newly cached facts are available starting your next turn.', }), ); }, }; } export function createEvictTool(_deps: KBDeps) { return { name: 'dynamic-kb-evict', description: "Remove cached facts from your kb-block. Takes effect next turn.", parameters: { type: 'object', properties: { 'fact-ids': { type: 'array', items: { type: 'integer' } } }, required: ['fact-ids'], }, async execute(_id: string, params: any, ctxArg?: ToolCtx): Promise { const ctx = ctxArg ?? {}; if (!ctx.agentId || !ctx.sessionId) { return err('dynamic-kb-evict: no per-session context'); } const factIds = Array.isArray(params?.['fact-ids']) ? params['fact-ids'].map((x: any) => Number(x)) : []; if (factIds.length === 0) return err('dynamic-kb-evict: fact-ids required'); let block: KBBlock; try { block = KBBlock.open(ctx.agentId, ctx.sessionId); } catch (e: any) { return err('dynamic-kb-evict: open block: ' + (e?.message || String(e))); } const evicted = block.remove(factIds); try { block.save(); } catch (e: any) { return err('dynamic-kb-evict: save: ' + (e?.message || String(e))); } const notCached = factIds.filter((id: number) => !evicted.includes(id)); return ok( JSON.stringify({ evicted: evicted.sort((a, b) => a - b), not_cached: notCached.length ? notCached.sort((a: number, b: number) => a - b) : undefined, note: 'Evictions take effect starting your next turn.', }), ); }, }; }