Files
HarborForge.PlexumPlugin/internal/tools/kb.go
hzhang 9e43021ba5 feat(kb): dynamic-kb-* tool family + <kb-block> subblock provider
Implements the HF side of Plexum DESIGN-DYNAMIC-BLOCK.md Phase 2 — the
HarborForge knowledge-base block agents cache facts into per session.
Cross-runtime aligned with the ClawSkills workflow text (same tool
names + input schemas + return shapes will land on openclaw side later).

manifest.json:
  - 5 new dynamic-kb-* tool contracts (list-kbs / list-topics /
    list-facts / cache / evict)
  - dynamicSubblocks contract entry declaring this plugin owns
    the "kb-block" <dynamic-block> subblock

internal/kbblock/ (new):
  - Per-session storage at <PLEXUM_PROFILE_ROOT>/agents/<id>/sessions/
    <sid>/plugins/harbor-forge/kb-block.json
  - Entry carries ID (HF backend DB primary key) + KBCode + SourceTopic
    + Content + InsertSeq for §9 #4 cache-insertion-order rendering
  - Render emits <kb-fact id=N kb=<code> source=topic:<slug>>...
    </kb-fact> (no title/description per §9 #8; source attr omitted
    when empty)
  - Fade NOT applied in v1 — §9 #3 lock has fade params shared with
    memory but implementation deferred until prod data informs whether
    KB needs it; agent dynamic-kb-evict is the only eviction path
  - 11 unit tests

internal/kbclient/ (new):
  - Typed HTTP client for HarborForge.Backend KB routes verified
    against app/api/routers/knowledge.py
  - GET /knowledge-bases[?project=<code>] (list KBs)
  - GET /knowledge-bases/{kb_code}/topics (list topics)
  - GET /knowledge-bases/{kb_code}/tree (full hierarchy — ListFacts
    flattens this client-side filtered by topic ids; backend has no
    flat list-facts-in-topic route)
  - GET /knowledge-facts/{id} per fact (GetFacts batch loop)
  - Auth: plugin-level Bearer APIKey. Per-agent hf-token resolution
    is a TODO when SDK exposes secret-mgr access.

internal/tools/kb.go (new) + tools.go:
  - 5 tool functions hooked into Dispatch
  - KBDeps struct bundles Client + ProfileRoot + SessionFor + Turn
  - Cache/evict use SessionFor lookup populated by main.go's
    RenderDynamicSubblock (called per turn by host; carries sessionID)

cmd/plexum-harborforge-plugin/main.go:
  - kbClient field initialized when BackendURL + APIKey present
  - profileRoot cached for kbblock path resolution
  - agentSession sync.Map tracks agentID → sessionID; populated by
    RenderDynamicSubblock so subsequent tool calls in the same turn
    can resolve the per-session kb-block.json path
  - Implements sdkplugin.DynamicBlockProvider.RenderDynamicSubblock:
    opens kbblock for (agentID, sessionID) and returns its Render()
    body; host wraps in <kb-block>...</kb-block>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 20:15:34 +01:00

285 lines
8.6 KiB
Go

// kb.go — dynamic-kb-* tool implementations (DESIGN-DYNAMIC-BLOCK.md
// §3.3 / §4.4). Each tool returns a sdkplugin.ToolResult; errors are
// surfaced as IsError:true rather than RPC errors so the model sees a
// human-readable message.
//
// All 5 tools need the agent id (from ctx) + session id (looked up
// via Deps.SessionForAgent — main.go threads this from the per-turn
// RenderDynamicSubblock callback that updates the lookup table).
package tools
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
sdkplugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin"
"git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/kbblock"
"git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/kbclient"
)
// KBDeps bundles the dependencies the KB tools need. Caller (main.go)
// constructs once and reuses across all tool calls.
type KBDeps struct {
Client *kbclient.Client
ProfileRoot string // <PLEXUM_PROFILE_ROOT>
SessionFor func(agentID string) string // returns sessionID or ""
Turn func(agentID string) int // current turn (best-effort, can return 0)
HostCallTool func(ctx context.Context, agentID, toolName string, input json.RawMessage) (json.RawMessage, error)
}
// kb tool inputs
type listKBsInput struct {
ProjectCode string `json:"project-code,omitempty"`
}
type listTopicsInput struct {
KBCode string `json:"kb-code"`
}
type listFactsInput struct {
KBCode string `json:"kb-code"`
TopicIDs []int `json:"topic-ids"`
}
type cacheInput struct {
KBCode string `json:"kb-code"`
FactIDs []int `json:"fact-ids"`
PreviousDynamicID string `json:"previous-dynamic-id,omitempty"`
}
type evictInput struct {
FactIDs []int `json:"fact-ids"`
}
type cacheResult struct {
Added []int `json:"added,omitempty"`
AlreadyCached []int `json:"already_cached,omitempty"`
Missing []int `json:"missing,omitempty"`
Note string `json:"note"`
}
type evictResult struct {
Evicted []int `json:"evicted,omitempty"`
NotCached []int `json:"not_cached,omitempty"`
Note string `json:"note"`
}
// ToolKBListKBs implements dynamic-kb-list-kbs.
func ToolKBListKBs(ctx context.Context, deps KBDeps, raw json.RawMessage) (sdkplugin.ToolResult, error) {
if deps.Client == nil {
return errResult("dynamic-kb-list-kbs: HF KB backend unavailable")
}
var in listKBsInput
if len(raw) > 0 {
_ = json.Unmarshal(raw, &in)
}
kbs, err := deps.Client.ListKBs(ctx, in.ProjectCode)
if err != nil {
return errResult("dynamic-kb-list-kbs: " + err.Error())
}
var sb strings.Builder
if in.ProjectCode != "" {
fmt.Fprintf(&sb, "project: %s\n", in.ProjectCode)
}
fmt.Fprintf(&sb, "kbs: %d\n\n", len(kbs))
for _, k := range kbs {
fmt.Fprintf(&sb, "[%s] %s\n %s\n\n", k.KnowledgeBaseCode, k.Title, k.Description)
}
sb.WriteString("Call dynamic-kb-list-topics({kb-code: \"<code>\"}) to drill into a KB.\n")
return okResult(sb.String())
}
// ToolKBListTopics implements dynamic-kb-list-topics.
func ToolKBListTopics(ctx context.Context, deps KBDeps, raw json.RawMessage) (sdkplugin.ToolResult, error) {
if deps.Client == nil {
return errResult("dynamic-kb-list-topics: HF KB backend unavailable")
}
var in listTopicsInput
if err := json.Unmarshal(raw, &in); err != nil {
return errResult("dynamic-kb-list-topics: parse: " + err.Error())
}
if in.KBCode == "" {
return errResult("dynamic-kb-list-topics: kb-code required")
}
topics, err := deps.Client.ListTopics(ctx, in.KBCode)
if err != nil {
return errResult("dynamic-kb-list-topics: " + err.Error())
}
var sb strings.Builder
fmt.Fprintf(&sb, "kb: %s\ntopics: %d\n\n", in.KBCode, len(topics))
for _, t := range topics {
fmt.Fprintf(&sb, "[%d] %s\n %s\n\n", t.ID, t.Topic, t.Description)
}
fmt.Fprintf(&sb, "Call dynamic-kb-list-facts({kb-code: %q, topic-ids: [<id>, ...]}) to drill into facts.\n",
in.KBCode)
return okResult(sb.String())
}
// ToolKBListFacts implements dynamic-kb-list-facts.
func ToolKBListFacts(ctx context.Context, deps KBDeps, raw json.RawMessage) (sdkplugin.ToolResult, error) {
if deps.Client == nil {
return errResult("dynamic-kb-list-facts: HF KB backend unavailable")
}
var in listFactsInput
if err := json.Unmarshal(raw, &in); err != nil {
return errResult("dynamic-kb-list-facts: parse: " + err.Error())
}
if in.KBCode == "" || len(in.TopicIDs) == 0 {
return errResult("dynamic-kb-list-facts: kb-code + topic-ids required")
}
facts, err := deps.Client.ListFacts(ctx, in.KBCode, in.TopicIDs)
if err != nil {
return errResult("dynamic-kb-list-facts: " + err.Error())
}
var sb strings.Builder
fmt.Fprintf(&sb, "kb: %s\ntopics: %v\nfacts: %d\n\n", in.KBCode, in.TopicIDs, len(facts))
for _, f := range facts {
fmt.Fprintf(&sb, "[%d] (topic %d/%s) %s\n %s\n\n",
f.ID, f.TopicID, f.TopicSlug, f.Title, f.Snippet)
}
sb.WriteString(
"Call dynamic-kb-cache({kb-code: \"" + in.KBCode +
"\", fact-ids: [<id>, ...]}) to commit selected facts to your kb-block.\n",
)
return okResult(sb.String())
}
// ToolKBCache implements dynamic-kb-cache. Per-agent session lookup
// resolves the kb-block.json location.
func ToolKBCache(ctx context.Context, deps KBDeps, agentID string, raw json.RawMessage) (sdkplugin.ToolResult, error) {
if deps.Client == nil {
return errResult("dynamic-kb-cache: HF KB backend unavailable")
}
sessionID := ""
if deps.SessionFor != nil {
sessionID = deps.SessionFor(agentID)
}
if agentID == "" || sessionID == "" {
return errResult("dynamic-kb-cache: no per-session context (agent/session unknown)")
}
var in cacheInput
if err := json.Unmarshal(raw, &in); err != nil {
return errResult("dynamic-kb-cache: parse: " + err.Error())
}
if in.KBCode == "" || len(in.FactIDs) == 0 {
return errResult("dynamic-kb-cache: kb-code + fact-ids required")
}
block, err := kbblock.Open(deps.ProfileRoot, agentID, sessionID)
if err != nil {
return errResult("dynamic-kb-cache: open block: " + err.Error())
}
// Split into already-cached vs to-fetch.
var toFetch []int
var alreadyCached []int
for _, id := range in.FactIDs {
if block.Has(id) {
alreadyCached = append(alreadyCached, id)
} else {
toFetch = append(toFetch, id)
}
}
var fetched []kbclient.Fact
if len(toFetch) > 0 {
fetched, err = deps.Client.GetFacts(ctx, in.KBCode, toFetch)
if err != nil {
return errResult("dynamic-kb-cache: " + err.Error())
}
}
turn := 0
if deps.Turn != nil {
turn = deps.Turn(agentID)
}
var added []int
fetchedByID := map[int]kbclient.Fact{}
for _, f := range fetched {
fetchedByID[f.ID] = f
}
for _, id := range toFetch {
f, ok := fetchedByID[id]
if !ok {
continue
}
if e := block.Add(f.ID, in.KBCode, f.TopicSlug, f.Content, turn); e != nil {
added = append(added, f.ID)
}
}
if err := block.Save(); err != nil {
return errResult("dynamic-kb-cache: save: " + err.Error())
}
missing := diffInts(toFetch, added)
sort.Ints(added)
sort.Ints(alreadyCached)
res := cacheResult{
Added: added,
AlreadyCached: alreadyCached,
Missing: missing,
Note: "Newly cached facts are available starting your next turn.",
}
out, _ := json.Marshal(res)
return okResult(string(out))
}
// ToolKBEvict implements dynamic-kb-evict.
func ToolKBEvict(ctx context.Context, deps KBDeps, agentID string, raw json.RawMessage) (sdkplugin.ToolResult, error) {
sessionID := ""
if deps.SessionFor != nil {
sessionID = deps.SessionFor(agentID)
}
if agentID == "" || sessionID == "" {
return errResult("dynamic-kb-evict: no per-session context")
}
var in evictInput
if err := json.Unmarshal(raw, &in); err != nil {
return errResult("dynamic-kb-evict: parse: " + err.Error())
}
if len(in.FactIDs) == 0 {
return errResult("dynamic-kb-evict: fact-ids required")
}
block, err := kbblock.Open(deps.ProfileRoot, agentID, sessionID)
if err != nil {
return errResult("dynamic-kb-evict: open block: " + err.Error())
}
evicted := block.Remove(in.FactIDs...)
if err := block.Save(); err != nil {
return errResult("dynamic-kb-evict: save: " + err.Error())
}
notCached := diffInts(in.FactIDs, evicted)
sort.Ints(evicted)
res := evictResult{
Evicted: evicted,
NotCached: notCached,
Note: "Evictions take effect starting your next turn.",
}
out, _ := json.Marshal(res)
return okResult(string(out))
}
// ---- helpers ----
func diffInts(a, b []int) []int {
if len(a) == 0 {
return nil
}
want := make(map[int]bool, len(b))
for _, x := range b {
want[x] = true
}
out := make([]int, 0, len(a))
for _, x := range a {
if !want[x] {
out = append(out, x)
}
}
if len(out) == 0 {
return nil
}
sort.Ints(out)
return out
}