Files
HarborForge.PlexumPlugin/internal/kbclient/client.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

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{})