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:
284
internal/tools/kb.go
Normal file
284
internal/tools/kb.go
Normal file
@@ -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 // <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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user