initial: HarborForge plugin for Plexum (port of OpenclawPlugin)
Plugin id `harbor-forge` mirrors the OpenClaw counterpart's runtime
surface on top of the Plexum SDK:
* eager activation — Monitor bridge + Calendar scheduler boot at
host start, before any agent turn fires
* monitor bridge: HTTP 127.0.0.1:<monitor_port> serving /telemetry
+ /health for HarborForge.Monitor
* calendar scheduler: heartbeats <backendUrl>/calendar/agent/
heartbeat, dispatches returned slots via HostAPI.WakeAgent
(state-aware queue, depth-1 replace-newest), tracks active slot
state in-memory, terminal status pushed back to backend
* 9 harborforge_* tools (status / telemetry / monitor_telemetry /
calendar_{status,complete,abort,pause,resume} / restart_status)
Key differences from OpenClaw equivalent:
* api.spawn → HostAPI.WakeAgent (new SDK primitive)
* api.getAgentStatus → HostAPI.ReadAgentState (existing)
* --install-monitor / --install-cli not included; Monitor + hf CLI
deploy via the HangmanLab.Server.T3 docker compose layer
Initial drop. TODO before v1 ship:
* tool ctx → calling-agent-id: SDK doesn't currently expose; v1
falls back to a single-active-slot heuristic in
main.bestEffortAgentID
* tests for the bridge + scheduler
This commit is contained in:
213
internal/tools/tools.go
Normal file
213
internal/tools/tools.go
Normal file
@@ -0,0 +1,213 @@
|
||||
// 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
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
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,
|
||||
},
|
||||
"calendar": sch,
|
||||
}
|
||||
return jsonResult(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{
|
||||
"port": bs.Port,
|
||||
"listening": bs.Listening,
|
||||
"queries": bs.Queries,
|
||||
"last_query": bs.LastQuery,
|
||||
"last_snapshot": bs.LastSnap,
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
sch := deps.Scheduler.Status()
|
||||
return jsonResult(map[string]any{
|
||||
"pending": sch.RestartPending,
|
||||
"last_heartbeat": sch.LastHeartbeat,
|
||||
"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
|
||||
}
|
||||
Reference in New Issue
Block a user