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>
This commit is contained in:
h z
2026-06-05 20:15:34 +01:00
parent 046a7753e6
commit 9e43021ba5
7 changed files with 1075 additions and 1 deletions

256
internal/kbblock/kbblock.go Normal file
View File

@@ -0,0 +1,256 @@
// Package kbblock implements the per-session knowledge-base block
// owned by the HarborForge Plexum plugin per Plexum DESIGN-DYNAMIC-
// BLOCK.md §3.3 / §4.4.
//
// The kb-block stores HarborForge KB facts the agent has cached for
// the current session. Each entry renders as
//
// <kb-fact id=N kb=<code> source=topic:<slug>>content</kb-fact>
//
// (id = HF backend's DB primary key for the fact, NOT a per-session
// monotonic). InsertSeq controls cache-insertion-order rendering per
// §9 #4. Duplicate Add(id, ...) silently no-ops per §9 #4.
//
// Storage location: plugin-managed file at
//
// <PLEXUM_PROFILE_ROOT>/agents/<agentID>/sessions/<sessionID>/
// plugins/harbor-forge/kb-block.json
//
// Plexum core's session cleanup (del-session) cascades this directory
// alongside the rest of the session state.
//
// Fade-out (§9 #3 — KB fade params = memory fade params n=5/w=10/m=70)
// is NOT applied in v1. Phase 2 v1 stores raw content + agent
// dynamic-kb-evict is the only eviction path. Fade will be added once
// we have prod data on whether KB facts need it.
package kbblock
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
const FileName = "kb-block.json"
const currentVersion = 1
// Entry is one cached KB fact.
type Entry struct {
ID int `json:"id"` // HF backend DB primary key
KBCode string `json:"kb_code"` // e.g. "KB-PAYROT"
SourceTopic string `json:"source_topic"` // human slug from HF topic, e.g. "debugging"
Content string `json:"content"`
InsertSeq int `json:"insert_seq"` // per-session monotonic, for render order
AddedAtTurn int `json:"added_at_turn"`
LastRefreshAtTurn int `json:"last_refresh_at_turn"`
}
// Block is the in-memory representation of kb-block.json.
type Block struct {
mu sync.Mutex
path string
NextSeq int `json:"next_seq"`
Entries []*Entry `json:"entries"`
}
// SessionDir returns the plugin-scoped session subdir under the Plexum
// profile root: <profileRoot>/agents/<agentID>/sessions/<sessionID>/
// plugins/harbor-forge.
func SessionDir(profileRoot, agentID, sessionID string) string {
return filepath.Join(profileRoot, "agents", agentID, "sessions", sessionID, "plugins", "harbor-forge")
}
// Open loads the block at <SessionDir>/kb-block.json. Missing file →
// empty block (NextSeq=1). Caller is expected to Close-free; Block
// can be re-Opened cheaply per-call (small JSON).
func Open(profileRoot, agentID, sessionID string) (*Block, error) {
if profileRoot == "" || agentID == "" || sessionID == "" {
return nil, fmt.Errorf("kbblock.Open: profileRoot/agentID/sessionID all required")
}
path := filepath.Join(SessionDir(profileRoot, agentID, sessionID), FileName)
b := &Block{path: path, NextSeq: 1}
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return b, nil
}
return nil, fmt.Errorf("kbblock: read %s: %w", path, err)
}
if len(raw) == 0 {
return b, nil
}
var loaded struct {
NextSeq int `json:"next_seq"`
Entries []*Entry `json:"entries"`
}
if err := json.Unmarshal(raw, &loaded); err != nil {
return nil, fmt.Errorf("kbblock: parse %s: %w", path, err)
}
if loaded.NextSeq < 1 {
loaded.NextSeq = 1
}
b.NextSeq = loaded.NextSeq
b.Entries = loaded.Entries
return b, nil
}
// Path returns the underlying file path.
func (b *Block) Path() string { return b.path }
// Len returns the entry count.
func (b *Block) Len() int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.Entries)
}
// Has reports whether a fact with this ID is already cached.
func (b *Block) Has(id int) bool {
b.mu.Lock()
defer b.mu.Unlock()
for _, e := range b.Entries {
if e.ID == id {
return true
}
}
return false
}
// Add appends a new entry. Duplicate ID → silent no-op, returns nil.
// Does NOT persist; caller must Save.
func (b *Block) Add(id int, kbCode, sourceTopic, content string, atTurn int) *Entry {
b.mu.Lock()
defer b.mu.Unlock()
for _, e := range b.Entries {
if e.ID == id {
return nil
}
}
e := &Entry{
ID: id,
KBCode: kbCode,
SourceTopic: sourceTopic,
Content: content,
InsertSeq: b.NextSeq,
AddedAtTurn: atTurn,
LastRefreshAtTurn: atTurn,
}
b.NextSeq++
b.Entries = append(b.Entries, e)
return e
}
// Remove drops entries by ID. Returns the IDs actually removed.
func (b *Block) Remove(ids ...int) []int {
b.mu.Lock()
defer b.mu.Unlock()
want := map[int]bool{}
for _, id := range ids {
want[id] = true
}
kept := b.Entries[:0]
var removed []int
for _, e := range b.Entries {
if want[e.ID] {
removed = append(removed, e.ID)
continue
}
kept = append(kept, e)
}
b.Entries = kept
return removed
}
// Lookup returns the entry with the given ID, or nil.
func (b *Block) Lookup(id int) *Entry {
b.mu.Lock()
defer b.mu.Unlock()
for _, e := range b.Entries {
if e.ID == id {
return e
}
}
return nil
}
// AllIDs returns the cached fact IDs sorted by InsertSeq ascending.
func (b *Block) AllIDs() []int {
b.mu.Lock()
defer b.mu.Unlock()
out := make([]*Entry, len(b.Entries))
copy(out, b.Entries)
sort.Slice(out, func(i, j int) bool { return out[i].InsertSeq < out[j].InsertSeq })
ids := make([]int, len(out))
for i, e := range out {
ids[i] = e.ID
}
return ids
}
// Save atomically writes (tmp+rename, 0600). Empty block with no prior
// file = no-op. Parent dirs auto-created.
func (b *Block) Save() error {
b.mu.Lock()
defer b.mu.Unlock()
if len(b.Entries) == 0 {
if _, err := os.Stat(b.path); os.IsNotExist(err) {
return nil
}
}
if err := os.MkdirAll(filepath.Dir(b.path), 0o755); err != nil {
return err
}
payload := struct {
Version int `json:"version"`
NextSeq int `json:"next_seq"`
Entries []*Entry `json:"entries"`
}{Version: currentVersion, NextSeq: b.NextSeq, Entries: b.Entries}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
tmp := b.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("kbblock: write tmp: %w", err)
}
return os.Rename(tmp, b.path)
}
// Render returns the inner body of the <kb-block> subblock — a flat
// sequence of `<kb-fact id=N kb=<code> source=topic:<slug>>\n<content>
// \n</kb-fact>` separated by single blank lines, ordered by InsertSeq
// (DESIGN-DYNAMIC-BLOCK.md §9 #4). Empty block returns "".
//
// The plugin's DynamicBlockProvider.RenderDynamicSubblock returns this
// to the Plexum host, which wraps in <kb-block>...</kb-block>.
func (b *Block) Render() string {
b.mu.Lock()
defer b.mu.Unlock()
if len(b.Entries) == 0 {
return ""
}
ordered := make([]*Entry, len(b.Entries))
copy(ordered, b.Entries)
sort.Slice(ordered, func(i, j int) bool { return ordered[i].InsertSeq < ordered[j].InsertSeq })
var sb strings.Builder
for i, e := range ordered {
if i > 0 {
sb.WriteByte('\n')
}
fmt.Fprintf(&sb, "<kb-fact id=%d kb=%s", e.ID, e.KBCode)
if e.SourceTopic != "" {
fmt.Fprintf(&sb, " source=topic:%s", e.SourceTopic)
}
sb.WriteString(">\n")
sb.WriteString(e.Content)
if !strings.HasSuffix(e.Content, "\n") {
sb.WriteByte('\n')
}
sb.WriteString("</kb-fact>\n")
}
return sb.String()
}

View File

@@ -0,0 +1,139 @@
package kbblock
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSessionDir(t *testing.T) {
got := SessionDir("/root/.plexum", "alice", "s_1")
want := "/root/.plexum/agents/alice/sessions/s_1/plugins/harbor-forge"
if got != want {
t.Errorf("SessionDir = %q want %q", got, want)
}
}
func TestOpenMissingFile(t *testing.T) {
b, err := Open(t.TempDir(), "alice", "s_1")
if err != nil {
t.Fatal(err)
}
if b.Len() != 0 || b.NextSeq != 1 {
t.Errorf("empty open: Len=%d NextSeq=%d", b.Len(), b.NextSeq)
}
}
func TestOpenRequiresAllArgs(t *testing.T) {
if _, err := Open("", "a", "s"); err == nil {
t.Error("want error on empty profileRoot")
}
if _, err := Open("/x", "", "s"); err == nil {
t.Error("want error on empty agentID")
}
if _, err := Open("/x", "a", ""); err == nil {
t.Error("want error on empty sessionID")
}
}
func TestAddAndDuplicate(t *testing.T) {
b, _ := Open(t.TempDir(), "a", "s")
e := b.Add(42, "KB-PAYROT", "debugging", "OOM fix", 3)
if e == nil || e.ID != 42 || e.KBCode != "KB-PAYROT" || e.SourceTopic != "debugging" ||
e.InsertSeq != 1 || e.AddedAtTurn != 3 {
t.Errorf("entry wrong: %+v", e)
}
if b.Add(42, "X", "y", "z", 7) != nil {
t.Error("dup should return nil")
}
if b.Len() != 1 {
t.Errorf("Len=%d", b.Len())
}
}
func TestRoundtrip(t *testing.T) {
dir := t.TempDir()
b, _ := Open(dir, "a", "s")
b.Add(101, "KB-A", "topic-a", "a", 1)
b.Add(202, "KB-A", "topic-b", "b", 2)
if err := b.Save(); err != nil {
t.Fatal(err)
}
b2, _ := Open(dir, "a", "s")
if b2.Len() != 2 || b2.NextSeq != 3 {
t.Errorf("reopen Len=%d NextSeq=%d", b2.Len(), b2.NextSeq)
}
if !b2.Has(101) || !b2.Has(202) {
t.Error("entries missing after reopen")
}
}
func TestRenderInsertOrder(t *testing.T) {
b, _ := Open(t.TempDir(), "a", "s")
b.Add(999, "K", "t", "first", 0)
b.Add(1, "K", "t", "second", 0)
out := b.Render()
idx999 := strings.Index(out, "id=999")
idx1 := strings.Index(out, "id=1 ")
if !(idx999 >= 0 && idx1 > idx999) {
t.Errorf("order broken: %q", out)
}
}
func TestRenderAttributes(t *testing.T) {
b, _ := Open(t.TempDir(), "a", "s")
b.Add(42, "KB-PAYROT", "debugging", "OOM fix: bump heap", 0)
out := b.Render()
if !strings.HasPrefix(out, "<kb-fact id=42 kb=KB-PAYROT source=topic:debugging>\n") {
t.Errorf("head wrong: %q", out)
}
if !strings.HasSuffix(out, "</kb-fact>\n") {
t.Errorf("tail wrong: %q", out)
}
}
func TestRenderOmitsSourceWhenEmpty(t *testing.T) {
b, _ := Open(t.TempDir(), "a", "s")
b.Add(7, "KB-X", "", "raw", 0)
out := b.Render()
if strings.Contains(out, "source=topic:") {
t.Errorf("source attr leaked: %q", out)
}
if !strings.Contains(out, "<kb-fact id=7 kb=KB-X>") {
t.Errorf("tight tag missing: %q", out)
}
}
func TestRemove(t *testing.T) {
b, _ := Open(t.TempDir(), "a", "s")
b.Add(1, "K", "t", "a", 0)
b.Add(2, "K", "t", "b", 0)
b.Add(3, "K", "t", "c", 0)
removed := b.Remove(2, 99, 3)
if len(removed) != 2 {
t.Errorf("removed=%v", removed)
}
if b.Len() != 1 || !b.Has(1) {
t.Errorf("post-state wrong: Len=%d", b.Len())
}
}
func TestRenderEmpty(t *testing.T) {
b, _ := Open(t.TempDir(), "a", "s")
if b.Render() != "" {
t.Error("empty Render should be \"\"")
}
}
func TestSaveEmptyNoFile(t *testing.T) {
dir := t.TempDir()
b, _ := Open(dir, "a", "s")
if err := b.Save(); err != nil {
t.Fatal(err)
}
path := filepath.Join(SessionDir(dir, "a", "s"), FileName)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("file should not exist: %v", err)
}
}