From 9e43021ba514e9045312aa5d4a31340fc89d3a68 Mon Sep 17 00:00:00 2001 From: hzhang Date: Fri, 5 Jun 2026 20:15:34 +0100 Subject: [PATCH] feat(kb): dynamic-kb-* tool family + subblock provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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" subblock internal/kbblock/ (new): - Per-session storage at /agents//sessions/ /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 source=topic:>... (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=] (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 ... Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/plexum-harborforge-plugin/main.go | 68 +++++- internal/kbblock/kbblock.go | 256 +++++++++++++++++++++++ internal/kbblock/kbblock_test.go | 139 +++++++++++++ internal/kbclient/client.go | 285 ++++++++++++++++++++++++++ internal/tools/kb.go | 284 +++++++++++++++++++++++++ internal/tools/tools.go | 16 ++ manifest.json | 28 +++ 7 files changed, 1075 insertions(+), 1 deletion(-) create mode 100644 internal/kbblock/kbblock.go create mode 100644 internal/kbblock/kbblock_test.go create mode 100644 internal/kbclient/client.go create mode 100644 internal/tools/kb.go diff --git a/cmd/plexum-harborforge-plugin/main.go b/cmd/plexum-harborforge-plugin/main.go index 5b60850..ac27edd 100644 --- a/cmd/plexum-harborforge-plugin/main.go +++ b/cmd/plexum-harborforge-plugin/main.go @@ -28,6 +28,8 @@ import ( "git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/calendar" hfcfg "git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/config" + "git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/kbblock" + "git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/kbclient" "git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/monitor" "git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/telemetry" "git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/tools" @@ -36,17 +38,31 @@ import ( // Version is injected via -ldflags "-X main.Version=…" at build time. var Version = "0.1.0" -// harborForgePlugin satisfies sdkplugin.ToolPlugin. +// harborForgePlugin satisfies sdkplugin.ToolPlugin AND +// sdkplugin.DynamicBlockProvider (the subblock contributor +// per Plexum DESIGN-DYNAMIC-BLOCK.md §3.3). type harborForgePlugin struct { host sdkplugin.HostAPI cfg hfcfg.Resolved bridge *monitor.Bridge pusher *monitor.Pusher sched *calendar.Scheduler + kbClient *kbclient.Client deps tools.Deps cancelBg context.CancelFunc wg sync.WaitGroup agentCache sync.Map // sessionID/turnID → agentID stash (best-effort) + + // profileRoot caches PLEXUM_PROFILE_ROOT for kbblock path resolution. + profileRoot string + + // agentSession maps agentID → sessionID, populated each turn by + // the host's plexum/plugin/dynamic-block/render RPC (which carries + // both ids). dynamic-kb-cache + dynamic-kb-evict read this to + // resolve the per-session kb-block.json path. Best-effort — + // before the first RenderDynamicSubblock fires for an agent the + // map is empty and the kb tools surface a clear error. + agentSession sync.Map // agentID (string) → sessionID (string) } func (p *harborForgePlugin) Manifest() sdkplugin.Manifest { @@ -61,6 +77,7 @@ func (p *harborForgePlugin) Init(ctx context.Context, host sdkplugin.HostAPI) er home, _ := os.UserHomeDir() profileRoot = filepath.Join(home, ".plexum") } + p.profileRoot = profileRoot raw, err := hfcfg.Load(profileRoot) if err != nil { return fmt.Errorf("load harbor-forge config: %w", err) @@ -151,6 +168,14 @@ func (p *harborForgePlugin) Init(ctx context.Context, host sdkplugin.HostAPI) er host.Log("info", "calendar scheduler disabled by config", nil) } + // KB HTTP client — shares plugin-level APIKey via Bearer (per-agent + // hf-token resolution is a TODO when SDK exposes secret-mgr access). + if p.cfg.BackendURL != "" && p.cfg.APIKey != "" { + p.kbClient = kbclient.New(p.cfg.BackendURL, p.cfg.APIKey) + } else { + host.Log("info", "kb client not initialized (need BackendURL + APIKey); dynamic-kb-* tools will return backend-unavailable", nil) + } + p.deps = tools.Deps{ Config: p.cfg, Version: Version, @@ -171,11 +196,52 @@ func (p *harborForgePlugin) Init(ctx context.Context, host sdkplugin.HostAPI) er // attribute the call to that slot's owner. return p.sched.SingleActiveAgentID() }, + KB: tools.KBDeps{ + Client: p.kbClient, + ProfileRoot: profileRoot, + SessionFor: func(agentID string) string { + if v, ok := p.agentSession.Load(agentID); ok { + return v.(string) + } + return "" + }, + Turn: func(agentID string) int { return 0 }, // best-effort; turn ctx not available to plugins yet + }, } return nil } +// RenderDynamicSubblock implements sdkplugin.DynamicBlockProvider. The +// host calls this once per turn per declared subblock name from this +// plugin's manifest (DESIGN-DYNAMIC-BLOCK.md §2 / §3.3). For +// "kb-block" we open the per-session kb-block.json and return its +// rendered body (host wraps in ...). Empty block +// → return "" → host omits the subblock for this turn. +// +// Side effect: stash agentID → sessionID into p.agentSession so the +// dynamic-kb-cache + dynamic-kb-evict tools can resolve the same +// kb-block file when they fire later in the same turn. +func (p *harborForgePlugin) RenderDynamicSubblock(ctx context.Context, agentID, sessionID, name string) (string, error) { + if agentID == "" || sessionID == "" { + return "", nil + } + // Cache the (agent, session) pair for tool dispatch this turn. + p.agentSession.Store(agentID, sessionID) + + if name != "kb-block" { + return "", nil + } + block, err := kbblock.Open(p.profileRoot, agentID, sessionID) + if err != nil { + p.host.Log("warn", "open kb-block", map[string]any{ + "agent": agentID, "session": sessionID, "err": err.Error(), + }) + return "", nil + } + return block.Render(), nil +} + func (p *harborForgePlugin) CallTool(ctx context.Context, name string, input json.RawMessage) (sdkplugin.ToolResult, error) { return tools.Dispatch(ctx, p.deps, name, input) } diff --git a/internal/kbblock/kbblock.go b/internal/kbblock/kbblock.go new file mode 100644 index 0000000..5aed39a --- /dev/null +++ b/internal/kbblock/kbblock.go @@ -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 +// +// source=topic:>content +// +// (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 +// +// /agents//sessions// +// 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: /agents//sessions// +// 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 /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 subblock — a flat +// sequence of ` source=topic:>\n +// \n` 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 .... +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, "\n") + sb.WriteString(e.Content) + if !strings.HasSuffix(e.Content, "\n") { + sb.WriteByte('\n') + } + sb.WriteString("\n") + } + return sb.String() +} diff --git a/internal/kbblock/kbblock_test.go b/internal/kbblock/kbblock_test.go new file mode 100644 index 0000000..2237561 --- /dev/null +++ b/internal/kbblock/kbblock_test.go @@ -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, "\n") { + t.Errorf("head wrong: %q", out) + } + if !strings.HasSuffix(out, "\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, "") { + 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) + } +} diff --git a/internal/kbclient/client.go b/internal/kbclient/client.go new file mode 100644 index 0000000..4d176aa --- /dev/null +++ b/internal/kbclient/client.go @@ -0,0 +1,285 @@ +// Package kbclient is a typed HTTP client for the HarborForge.Backend +// knowledge-base REST API. Used by the dynamic-kb-* tools to power +// agent browse + cache flows. +// +// Route shapes verified against +// HarborForge.Backend/app/api/routers/knowledge.py: +// +// GET /knowledge-bases[?project=] list KBs +// GET /knowledge-bases/{kb_id_or_code}/topics list topics in a KB +// GET /knowledge-bases/{kb_id_or_code}/tree full hierarchy +// GET /knowledge-facts/{fact_id} single fact +// +// HF's KB hierarchy is KB → Topics → Categories → Facts. ListFacts +// uses the /tree endpoint and filters client-side to the requested +// topic ids (the backend has no flat per-topic-facts route as of +// the route audit on 2026-06-08). +// +// Auth: v1 uses plugin-level APIKey via Bearer header. Per-agent +// token resolution (matching agent's hf-token from secret-mgr) is a +// TODO — needs SDK extension for plugin → secret-mgr access. +package kbclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Client is the HTTP wrapper. Construct once at plugin init; safe to +// share across goroutines. +type Client struct { + BaseURL string + APIKey string + HTTP *http.Client +} + +// New returns a client with a 30s default timeout. BaseURL has any +// trailing slash trimmed. +func New(baseURL, apiKey string) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + APIKey: apiKey, + HTTP: &http.Client{Timeout: 30 * time.Second}, + } +} + +// ---- Payload types matching HF backend response shapes ---- + +// KBSummary is one row from GET /knowledge-bases. +type KBSummary struct { + ID int `json:"id"` + KnowledgeBaseCode string `json:"knowledge_base_code"` + Title string `json:"title"` + Description string `json:"description"` +} + +// TopicSummary is one row from GET /knowledge-bases/{id}/topics. +type TopicSummary struct { + ID int `json:"id"` + Topic string `json:"topic"` + Description string `json:"description"` +} + +// FactSummary is one item the dynamic-kb-list-facts tool surfaces to +// the agent. Built client-side from the tree response — title + a +// snippet of the content. Kept light so list-facts output stays +// scan-able even on large KBs. +type FactSummary struct { + ID int `json:"id"` + TopicID int `json:"topic_id"` + TopicSlug string `json:"topic_slug"` + Title string `json:"title"` + Snippet string `json:"snippet"` +} + +// Fact is the full fact body fetched by dynamic-kb-cache before +// writing into kb-block.json. +type Fact struct { + ID int `json:"id"` + KBCode string `json:"kb_code"` + TopicSlug string `json:"topic_slug"` + Content string `json:"content"` +} + +// ---- HTTP methods ---- + +// ListKBs hits GET /knowledge-bases[?project=]. Empty +// projectCode = unfiltered. +func (c *Client) ListKBs(ctx context.Context, projectCode string) ([]KBSummary, error) { + path := "/knowledge-bases" + if projectCode != "" { + path += "?project=" + url.QueryEscape(projectCode) + } + var out []KBSummary + if err := c.getJSON(ctx, path, &out); err != nil { + return nil, err + } + return out, nil +} + +// ListTopics hits GET /knowledge-bases/{kbCode}/topics. kbCode is the +// human KB code (e.g. "KB-PAYROT"); the backend resolves either int +// id or code in the same path slot. +func (c *Client) ListTopics(ctx context.Context, kbCode string) ([]TopicSummary, error) { + if kbCode == "" { + return nil, fmt.Errorf("kbclient: kb-code required") + } + path := "/knowledge-bases/" + url.PathEscape(kbCode) + "/topics" + var out []TopicSummary + if err := c.getJSON(ctx, path, &out); err != nil { + return nil, err + } + return out, nil +} + +// kbTree mirrors the shape of GET /knowledge-bases/{id}/tree. We only +// pull the fields we need to build FactSummary; unknown fields are +// dropped silently. +type kbTree struct { + KnowledgeBaseCode string `json:"knowledge_base_code"` + Topics []treeNode `json:"topics"` +} + +type treeNode struct { + ID int `json:"id"` + Topic string `json:"topic"` // topic-level + Category string `json:"category"` // category-level + Title string `json:"title"` // fact-level + Content string `json:"content"` // fact-level + Categories []treeNode `json:"categories"` // children of topic / category + Facts []treeNode `json:"facts"` // fact children +} + +// ListFacts hits GET /knowledge-bases/{kbCode}/tree, walks the tree, +// and flattens facts under the requested topic ids into FactSummary +// rows. Snippet = first 120 chars of content (UTF-8 safe-truncated). +func (c *Client) ListFacts(ctx context.Context, kbCode string, topicIDs []int) ([]FactSummary, error) { + if kbCode == "" { + return nil, fmt.Errorf("kbclient: kb-code required") + } + if len(topicIDs) == 0 { + return nil, fmt.Errorf("kbclient: topic-ids required") + } + path := "/knowledge-bases/" + url.PathEscape(kbCode) + "/tree" + var tree kbTree + if err := c.getJSON(ctx, path, &tree); err != nil { + return nil, err + } + wantTopic := map[int]bool{} + for _, id := range topicIDs { + wantTopic[id] = true + } + var out []FactSummary + for _, topic := range tree.Topics { + if !wantTopic[topic.ID] { + continue + } + walkFactsInto(&out, topic.ID, topic.Topic, topic.Categories, topic.Facts) + } + return out, nil +} + +// walkFactsInto descends category subtree, collecting facts. cur is +// the topic context (id + slug) for tagging. +func walkFactsInto(out *[]FactSummary, topicID int, topicSlug string, cats, facts []treeNode) { + for _, f := range facts { + *out = append(*out, FactSummary{ + ID: f.ID, + TopicID: topicID, + TopicSlug: topicSlug, + Title: f.Title, + Snippet: snippet(f.Content, 120), + }) + } + for _, c := range cats { + walkFactsInto(out, topicID, topicSlug, c.Categories, c.Facts) + } +} + +// GetFacts pulls each fact's full content by ID. Calls +// /knowledge-facts/{id} per fact; rate-limited to sequential issue +// (small N expected — agents cache a handful per call). Pass kbCode +// for tagging the returned Fact records; backend doesn't return it. +func (c *Client) GetFacts(ctx context.Context, kbCode string, factIDs []int) ([]Fact, error) { + if kbCode == "" { + return nil, fmt.Errorf("kbclient: kb-code required") + } + out := make([]Fact, 0, len(factIDs)) + for _, id := range factIDs { + path := "/knowledge-facts/" + strconv.Itoa(id) + var node struct { + ID int `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + TopicID int `json:"topic_id"` + TopicSlug string `json:"topic"` + CategoryID int `json:"category_id"` + } + if err := c.getJSON(ctx, path, &node); err != nil { + // 404 → skip silently; other errors fail the batch + if isNotFound(err) { + continue + } + return nil, err + } + out = append(out, Fact{ + ID: node.ID, + KBCode: kbCode, + TopicSlug: node.TopicSlug, + Content: node.Content, + }) + } + return out, nil +} + +// ---- transport plumbing ---- + +func (c *Client) getJSON(ctx context.Context, path string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) + if err != nil { + return err + } + if c.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+c.APIKey) + } + req.Header.Set("Accept", "application/json") + res, err := c.HTTP.Do(req) + if err != nil { + return fmt.Errorf("GET %s: %w", path, err) + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + if res.StatusCode == 404 { + return notFoundErr{path: path} + } + if res.StatusCode >= 400 { + return fmt.Errorf("GET %s: HTTP %d: %s", path, res.StatusCode, truncate(body, 200)) + } + if len(body) == 0 { + return nil + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("GET %s decode: %w (body=%q)", path, err, truncate(body, 200)) + } + return nil +} + +type notFoundErr struct{ path string } + +func (e notFoundErr) Error() string { return "GET " + e.path + ": 404 not found" } + +func isNotFound(err error) bool { + _, ok := err.(notFoundErr) + return ok +} + +func truncate(b []byte, n int) string { + if len(b) > n { + return string(b[:n]) + "..." + } + return string(b) +} + +// snippet returns the first n bytes of s, padded with "..." if cut. +// Naive byte slice; HF content is UTF-8 and we accept the small risk +// of cutting mid-rune for log-level rendering (the agent gets full +// content via GetFacts before caching). +func snippet(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// Compile-time guard: caller code expected to wrap bytes.Buffer usage +// (currently unused but here for future POST/PATCH endpoints). +var _ = bytes.NewReader([]byte{}) diff --git a/internal/tools/kb.go b/internal/tools/kb.go new file mode 100644 index 0000000..b2569a9 --- /dev/null +++ b/internal/tools/kb.go @@ -0,0 +1,284 @@ +// 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 // + 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: \"\"}) 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: [, ...]}) 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: [, ...]}) 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 +} diff --git a/internal/tools/tools.go b/internal/tools/tools.go index 73d7bc3..19dd68f 100644 --- a/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -35,6 +35,12 @@ type Deps struct { // host injects this via the tool dispatch context; main.go's // CallTool reads it from the ctx and stashes here. AgentIDFromCtx func(ctx context.Context) string + + // KB wires the dynamic-kb-* tool family (DESIGN-DYNAMIC-BLOCK.md + // §3.3 / §4.4). Zero value when HF KB backend is unconfigured; + // each KB tool then returns a graceful "backend unavailable" + // error rather than crashing. + KB KBDeps } // Dispatch is the entry point main.go's ToolPlugin.CallTool calls. @@ -61,6 +67,16 @@ func Dispatch(ctx context.Context, deps Deps, name string, input json.RawMessage return toolCalendarResume(ctx, deps) case "harborforge_restart_status": return toolRestartStatus(deps) + case "dynamic-kb-list-kbs": + return ToolKBListKBs(ctx, deps.KB, input) + case "dynamic-kb-list-topics": + return ToolKBListTopics(ctx, deps.KB, input) + case "dynamic-kb-list-facts": + return ToolKBListFacts(ctx, deps.KB, input) + case "dynamic-kb-cache": + return ToolKBCache(ctx, deps.KB, deps.AgentIDFromCtx(ctx), input) + case "dynamic-kb-evict": + return ToolKBEvict(ctx, deps.KB, deps.AgentIDFromCtx(ctx), input) } return sdkplugin.ToolResult{ IsError: true, diff --git a/manifest.json b/manifest.json index 64fbb3d..8c82eb0 100644 --- a/manifest.json +++ b/manifest.json @@ -49,7 +49,35 @@ "name": "harborforge_restart_status", "description": "Check whether a Plexum host restart is pending (backend-driven flag). Reports last poll time and pending flag.", "inputSchema": {"type": "object"} + }, + { + "name": "dynamic-kb-list-kbs", + "description": "List HarborForge Knowledge Bases the agent can access. Optional project-code filter (equivalent to `hf knowledge-base list [--project ]`). Returns code/title/description per KB. Browse — follow up with dynamic-kb-list-topics to drill into one.", + "inputSchema": {"type": "object", "properties": {"project-code": {"type": "string"}}} + }, + { + "name": "dynamic-kb-list-topics", + "description": "List topics in one KB by code. Returns topic id/slug/description per row. Browse — follow up with dynamic-kb-list-facts.", + "inputSchema": {"type": "object", "properties": {"kb-code": {"type": "string"}}, "required": ["kb-code"]} + }, + { + "name": "dynamic-kb-list-facts", + "description": "List facts in given topics. Returns lightweight previews (id/topic/title/snippet). Browse — feed into dynamic-kb-cache to commit selected facts to your kb-block.", + "inputSchema": {"type": "object", "properties": {"kb-code": {"type": "string"}, "topic-ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["kb-code", "topic-ids"]} + }, + { + "name": "dynamic-kb-cache", + "description": "Cache specific KB facts into your per-session kb-block (visible in System prompt next turn). Pass previous-dynamic-id to consume the matching list-facts browse output.", + "inputSchema": {"type": "object", "properties": {"kb-code": {"type": "string"}, "fact-ids": {"type": "array", "items": {"type": "integer"}}, "previous-dynamic-id": {"type": "string"}}, "required": ["kb-code", "fact-ids"]} + }, + { + "name": "dynamic-kb-evict", + "description": "Remove cached facts from your kb-block (free ctx). Takes effect starting your next turn.", + "inputSchema": {"type": "object", "properties": {"fact-ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["fact-ids"]} } + ], + "dynamicSubblocks": [ + {"name": "kb-block"} ] } }