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>
285 lines
8.6 KiB
Go
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
|
|
}
|