The previous commit shipped renderFaded + tick + seed on Block but left them dead code because openclaw's `before_prompt_build` hook ctx doesn't carry a currentTurn signal — the hook called plain render() and turnFor() returned 0, so add() always stamped last_refresh_at_turn=0 and fade distance never moved. This adds a small per-session turn tracker (tools/turn-tracker.ts) that the hook bumps each fire. On the first bump after plugin start the counter is seeded from max(entry.last_refresh_at_turn) on the loaded block, so a restart doesn't regress accumulated fade for surviving entries. dynamic-kb-cache now reads currentTurnForSession via the same counter so a fact cached on turn N reads back at d=0 on turn N+1. The hook now: bumps turn, ticks (drop entries past the m% threshold, log + save when anything drops), and renderFaded() into appendSystemContext. Same algorithm as the Plexum-side RenderDynamicSubblock path. KBDeps.turnFor signature changed from (agentId) → (sessionId) since turn is session-scoped — fixed the one cache-tool call site. Verified end-to-end with a standalone smoke replaying the full hook+tool flow against a temp profile: stale entry (d=201) drops on turn 1, fresh entry (d=1) survives untouched, restart re-seeds the counter from the surviving entry's last_refresh.
284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
// 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<string | null>) | 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 for a session; 0 is acceptable.
|
|
* Wired against the same per-session counter that the
|
|
* before_prompt_build hook bumps, so a fact added during turn N
|
|
* reads back at d=0 on turn N+1.
|
|
*/
|
|
turnFor: (sessionId: 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<ToolResult> {
|
|
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: "<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<ToolResult> {
|
|
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: [<id>, ...]}) 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<ToolResult> {
|
|
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: [<id>, ...]}) 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<ToolResult> {
|
|
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.sessionId);
|
|
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<ToolResult> {
|
|
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.',
|
|
}),
|
|
);
|
|
},
|
|
};
|
|
}
|