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>
286 lines
8.6 KiB
Go
286 lines
8.6 KiB
Go
// 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=<code>] 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=<code>]. 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{})
|