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"
|
||||
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/telemetry"
|
||||
"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.
|
||||
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 {
|
||||
host sdkplugin.HostAPI
|
||||
cfg hfcfg.Resolved
|
||||
bridge *monitor.Bridge
|
||||
pusher *monitor.Pusher
|
||||
sched *calendar.Scheduler
|
||||
kbClient *kbclient.Client
|
||||
deps tools.Deps
|
||||
cancelBg context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
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 {
|
||||
@@ -61,6 +77,7 @@ func (p *harborForgePlugin) Init(ctx context.Context, host sdkplugin.HostAPI) er
|
||||
home, _ := os.UserHomeDir()
|
||||
profileRoot = filepath.Join(home, ".plexum")
|
||||
}
|
||||
p.profileRoot = profileRoot
|
||||
raw, err := hfcfg.Load(profileRoot)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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{
|
||||
Config: p.cfg,
|
||||
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.
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return tools.Dispatch(ctx, p.deps, name, input)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user