// 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 }