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

259 lines
8.5 KiB
Go

// Package tools wires the 9 harborforge_* tool implementations to
// the plugin's runtime state (config, telemetry collector, monitor
// bridge, calendar scheduler). Each tool is a CallTool dispatch
// branch in main.go's plugin; this package holds the shared logic.
package tools
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
sdkplugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin"
"git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/calendar"
"git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/config"
"git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/monitor"
"git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/telemetry"
)
// Deps is the bundle main.go passes when constructing the tool router.
type Deps struct {
Config config.Resolved
Version string
Collect func() telemetry.Snapshot
Bridge *monitor.Bridge
Pusher *monitor.Pusher
Scheduler *calendar.Scheduler
Host sdkplugin.HostAPI
// AgentIDFromCtx returns the agent id the call belongs to. Plexum
// 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.
// Returns the canonical text response. Errors come through as
// is_error=true ToolResult rather than RPC errors so the model sees
// human-readable detail.
func Dispatch(ctx context.Context, deps Deps, name string, input json.RawMessage) (sdkplugin.ToolResult, error) {
switch name {
case "harborforge_status":
return toolStatus(deps)
case "harborforge_telemetry":
return toolTelemetry(deps)
case "harborforge_monitor_telemetry":
return toolMonitorTelemetry(deps)
case "harborforge_calendar_status":
return toolCalendarStatus(deps)
case "harborforge_calendar_complete":
return toolCalendarComplete(ctx, deps, input)
case "harborforge_calendar_abort":
return toolCalendarAbort(ctx, deps, input)
case "harborforge_calendar_pause":
return toolCalendarPause(ctx, deps, input)
case "harborforge_calendar_resume":
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,
Content: []sdkplugin.ContentBlock{{Type: "text", Text: "unknown tool: " + name}},
}, nil
}
func toolStatus(deps Deps) (sdkplugin.ToolResult, error) {
bs := deps.Bridge.Stats()
sch := deps.Scheduler.Status()
out := map[string]any{
"plugin": map[string]any{
"name": "harbor-forge",
"version": deps.Version,
"backend": "plexum",
},
"config": map[string]any{
"backend_url": deps.Config.BackendURL,
"identifier": deps.Config.Identifier,
"monitor_port": deps.Config.MonitorPort,
"calendar_enabled": deps.Config.CalendarEnabled,
"calendar_backendurl": deps.Config.CalendarBackendURL,
},
"monitor_bridge": map[string]any{
"listening": bs.Listening,
"port": bs.Port,
"queries": bs.Queries,
"last_query": bs.LastQuery,
},
"monitor_push": monitorPushSummary(deps),
"calendar": sch,
}
return jsonResult(out)
}
// monitorPushSummary returns the pusher's last-known state in the same
// JSON layout the status/monitor_telemetry tools surface. Nil-safe: if
// no pusher is wired (testing, push disabled), reports enabled=false.
func monitorPushSummary(deps Deps) map[string]any {
out := map[string]any{
"enabled": deps.Config.MonitorPushEnabled,
"interval_seconds": deps.Config.MonitorPushIntervalSeconds,
"endpoint": deps.Config.BackendURL + "/monitor/server/heartbeat",
}
if deps.Pusher != nil {
st := deps.Pusher.Stats()
out["last_sent_at"] = st.LastSentAt
out["last_status"] = st.LastStatus
out["last_err"] = st.LastErr
out["success_count"] = st.SuccessCount
out["error_count"] = st.ErrorCount
}
return out
}
func toolTelemetry(deps Deps) (sdkplugin.ToolResult, error) {
return jsonResult(deps.Collect())
}
func toolMonitorTelemetry(deps Deps) (sdkplugin.ToolResult, error) {
bs := deps.Bridge.Stats()
return jsonResult(map[string]any{
"bridge": map[string]any{
"port": bs.Port,
"listening": bs.Listening,
"queries": bs.Queries,
"last_query": bs.LastQuery,
"last_snapshot": bs.LastSnap,
},
"push": monitorPushSummary(deps),
})
}
func toolCalendarStatus(deps Deps) (sdkplugin.ToolResult, error) {
return jsonResult(deps.Scheduler.Status())
}
func toolCalendarComplete(ctx context.Context, deps Deps, input json.RawMessage) (sdkplugin.ToolResult, error) {
var args struct{ Summary string `json:"summary"` }
_ = json.Unmarshal(input, &args)
agentID := deps.AgentIDFromCtx(ctx)
if agentID == "" {
return errResult("calendar_complete: no agent context")
}
if err := deps.Scheduler.CompleteForAgent(ctx, agentID, args.Summary); err != nil {
if errors.Is(err, calendar.ErrNoActiveSlot) {
return errResult("no active slot for agent " + agentID)
}
return errResult("complete failed: " + err.Error())
}
return okResult("slot marked completed")
}
func toolCalendarAbort(ctx context.Context, deps Deps, input json.RawMessage) (sdkplugin.ToolResult, error) {
var args struct{ Reason string `json:"reason"` }
_ = json.Unmarshal(input, &args)
agentID := deps.AgentIDFromCtx(ctx)
if agentID == "" {
return errResult("calendar_abort: no agent context")
}
if err := deps.Scheduler.AbortForAgent(ctx, agentID, args.Reason); err != nil {
if errors.Is(err, calendar.ErrNoActiveSlot) {
return errResult("no active slot for agent " + agentID)
}
return errResult("abort failed: " + err.Error())
}
return okResult("slot aborted")
}
func toolCalendarPause(ctx context.Context, deps Deps, input json.RawMessage) (sdkplugin.ToolResult, error) {
var args struct{ Reason string `json:"reason"` }
_ = json.Unmarshal(input, &args)
agentID := deps.AgentIDFromCtx(ctx)
if agentID == "" {
return errResult("calendar_pause: no agent context")
}
if err := deps.Scheduler.PauseForAgent(ctx, agentID, args.Reason); err != nil {
if errors.Is(err, calendar.ErrNoActiveSlot) {
return errResult("no active slot for agent " + agentID)
}
return errResult("pause failed: " + err.Error())
}
return okResult("slot paused")
}
func toolCalendarResume(ctx context.Context, deps Deps) (sdkplugin.ToolResult, error) {
agentID := deps.AgentIDFromCtx(ctx)
if agentID == "" {
return errResult("calendar_resume: no agent context")
}
if err := deps.Scheduler.ResumeForAgent(ctx, agentID); err != nil {
if errors.Is(err, calendar.ErrNoActiveSlot) {
return errResult("no active slot for agent " + agentID)
}
return errResult("resume failed: " + err.Error())
}
return okResult("slot resumed")
}
func toolRestartStatus(deps Deps) (sdkplugin.ToolResult, error) {
// HarborForge backend doesn't expose a restart-pending endpoint
// (verified via /openapi.json) so we report the most recent
// heartbeat freshness instead. Useful for operators sanity-
// checking that the plugin's calendar loop is still alive.
sch := deps.Scheduler.Status()
return jsonResult(map[string]any{
"pending": false,
"last_heartbeats": sch.LastHeartbeats,
"observed_at": time.Now().UTC(),
})
}
// ---- result helpers ----
func jsonResult(v any) (sdkplugin.ToolResult, error) {
raw, err := json.MarshalIndent(v, "", " ")
if err != nil {
return sdkplugin.ToolResult{}, fmt.Errorf("encode tool result: %w", err)
}
return sdkplugin.ToolResult{
Content: []sdkplugin.ContentBlock{{Type: "text", Text: string(raw)}},
}, nil
}
func okResult(text string) (sdkplugin.ToolResult, error) {
return sdkplugin.ToolResult{
Content: []sdkplugin.ContentBlock{{Type: "text", Text: text}},
}, nil
}
func errResult(text string) (sdkplugin.ToolResult, error) {
if !strings.HasPrefix(text, "harborforge_") {
text = "harborforge: " + text
}
return sdkplugin.ToolResult{
IsError: true,
Content: []sdkplugin.ContentBlock{{Type: "text", Text: text}},
}, nil
}