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:
@@ -28,6 +28,8 @@ import (
|
|||||||
|
|
||||||
"git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/calendar"
|
"git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/calendar"
|
||||||
hfcfg "git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/config"
|
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/monitor"
|
||||||
"git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/telemetry"
|
"git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/telemetry"
|
||||||
"git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/tools"
|
"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.
|
// Version is injected via -ldflags "-X main.Version=…" at build time.
|
||||||
var Version = "0.1.0"
|
var Version = "0.1.0"
|
||||||
|
|
||||||
// harborForgePlugin satisfies sdkplugin.ToolPlugin.
|
// harborForgePlugin satisfies sdkplugin.ToolPlugin AND
|
||||||
|
// sdkplugin.DynamicBlockProvider (the <kb-block> subblock contributor
|
||||||
|
// per Plexum DESIGN-DYNAMIC-BLOCK.md §3.3).
|
||||||
type harborForgePlugin struct {
|
type harborForgePlugin struct {
|
||||||
host sdkplugin.HostAPI
|
host sdkplugin.HostAPI
|
||||||
cfg hfcfg.Resolved
|
cfg hfcfg.Resolved
|
||||||
bridge *monitor.Bridge
|
bridge *monitor.Bridge
|
||||||
pusher *monitor.Pusher
|
pusher *monitor.Pusher
|
||||||
sched *calendar.Scheduler
|
sched *calendar.Scheduler
|
||||||
|
kbClient *kbclient.Client
|
||||||
deps tools.Deps
|
deps tools.Deps
|
||||||
cancelBg context.CancelFunc
|
cancelBg context.CancelFunc
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
agentCache sync.Map // sessionID/turnID → agentID stash (best-effort)
|
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 {
|
func (p *harborForgePlugin) Manifest() sdkplugin.Manifest {
|
||||||
@@ -61,6 +77,7 @@ func (p *harborForgePlugin) Init(ctx context.Context, host sdkplugin.HostAPI) er
|
|||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
profileRoot = filepath.Join(home, ".plexum")
|
profileRoot = filepath.Join(home, ".plexum")
|
||||||
}
|
}
|
||||||
|
p.profileRoot = profileRoot
|
||||||
raw, err := hfcfg.Load(profileRoot)
|
raw, err := hfcfg.Load(profileRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load harbor-forge config: %w", err)
|
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)
|
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{
|
p.deps = tools.Deps{
|
||||||
Config: p.cfg,
|
Config: p.cfg,
|
||||||
Version: Version,
|
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.
|
// attribute the call to that slot's owner.
|
||||||
return p.sched.SingleActiveAgentID()
|
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
|
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 <kb-block>...</kb-block>). 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) {
|
func (p *harborForgePlugin) CallTool(ctx context.Context, name string, input json.RawMessage) (sdkplugin.ToolResult, error) {
|
||||||
return tools.Dispatch(ctx, p.deps, name, input)
|
return tools.Dispatch(ctx, p.deps, name, input)
|
||||||
}
|
}
|
||||||
|
|||||||
256
internal/kbblock/kbblock.go
Normal file
256
internal/kbblock/kbblock.go
Normal file
@@ -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
|
||||||
|
//
|
||||||
|
// <kb-fact id=N kb=<code> source=topic:<slug>>content</kb-fact>
|
||||||
|
//
|
||||||
|
// (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
|
||||||
|
//
|
||||||
|
// <PLEXUM_PROFILE_ROOT>/agents/<agentID>/sessions/<sessionID>/
|
||||||
|
// 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: <profileRoot>/agents/<agentID>/sessions/<sessionID>/
|
||||||
|
// 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 <SessionDir>/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 <kb-block> subblock — a flat
|
||||||
|
// sequence of `<kb-fact id=N kb=<code> source=topic:<slug>>\n<content>
|
||||||
|
// \n</kb-fact>` 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 <kb-block>...</kb-block>.
|
||||||
|
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, "<kb-fact id=%d kb=%s", e.ID, e.KBCode)
|
||||||
|
if e.SourceTopic != "" {
|
||||||
|
fmt.Fprintf(&sb, " source=topic:%s", e.SourceTopic)
|
||||||
|
}
|
||||||
|
sb.WriteString(">\n")
|
||||||
|
sb.WriteString(e.Content)
|
||||||
|
if !strings.HasSuffix(e.Content, "\n") {
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
sb.WriteString("</kb-fact>\n")
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
139
internal/kbblock/kbblock_test.go
Normal file
139
internal/kbblock/kbblock_test.go
Normal file
@@ -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, "<kb-fact id=42 kb=KB-PAYROT source=topic:debugging>\n") {
|
||||||
|
t.Errorf("head wrong: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(out, "</kb-fact>\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, "<kb-fact id=7 kb=KB-X>") {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
285
internal/kbclient/client.go
Normal file
285
internal/kbclient/client.go
Normal file
@@ -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=<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{})
|
||||||
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
|
// host injects this via the tool dispatch context; main.go's
|
||||||
// CallTool reads it from the ctx and stashes here.
|
// CallTool reads it from the ctx and stashes here.
|
||||||
AgentIDFromCtx func(ctx context.Context) string
|
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.
|
// 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)
|
return toolCalendarResume(ctx, deps)
|
||||||
case "harborforge_restart_status":
|
case "harborforge_restart_status":
|
||||||
return toolRestartStatus(deps)
|
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{
|
return sdkplugin.ToolResult{
|
||||||
IsError: true,
|
IsError: true,
|
||||||
|
|||||||
@@ -49,7 +49,35 @@
|
|||||||
"name": "harborforge_restart_status",
|
"name": "harborforge_restart_status",
|
||||||
"description": "Check whether a Plexum host restart is pending (backend-driven flag). Reports last poll time and pending flag.",
|
"description": "Check whether a Plexum host restart is pending (backend-driven flag). Reports last poll time and pending flag.",
|
||||||
"inputSchema": {"type": "object"}
|
"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 <code>]`). 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"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user