Port of Dialectic.OpenclawPlugin to the Plexum SDK. 8 dialectic_*
tools wired to Dialectic.Backend over HTTP:
list_topics, topic_detail, list_arguments, propose_topic,
signup, post_argument, submit_verdict, view_verdict
Differences from the OpenClaw port worth noting:
- Per-agent API key storage: OpenClaw used secret-mgr (one entry
per agent's keyspace). Plexum has no secret-mgr; v1 stores
keys directly in plugin config (apiKey + agentKeys map).
- Agent identity at tool dispatch: OpenClaw framework surfaces
ctx.agentId; Plexum SDK doesn't yet plumb the calling agent
through ToolPlugin.CallTool. v1 falls back to
config.defaultAgentID — same stop-gap HarborForge.PlexumPlugin
is on. Tracked as upstream SDK work.
- HF on_call coverage pre-check on signup: stub that always
returns "skipped", matching OpenClaw v1's behavior (HarborForge
never shipped the cross-plugin coverage query). pre_validated
is sent as false so the backend records audit honestly.
DIALECTIC_PLUGIN_BYPASS_HF=1 env retains parity with OpenClaw.
- Activation: lazy (no background services, unlike HarborForge's
eager-spawn for the calendar scheduler + monitor bridge).
Backend client follows the bearer-auth contract OpenClaw's
backend-client.ts established; endpoint shapes are unchanged.
355 lines
10 KiB
Go
355 lines
10 KiB
Go
// Package tools wires the 8 dialectic_* tool implementations.
|
|
// Each tool is one HTTP call against Dialectic.Backend; errors come
|
|
// back as is_error=true ToolResult so the agent sees a usable message
|
|
// rather than an RPC failure.
|
|
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
|
|
sdkplugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin"
|
|
|
|
"git.hangman-lab.top/zhi/Dialectic.PlexumPlugin/internal/backend"
|
|
"git.hangman-lab.top/zhi/Dialectic.PlexumPlugin/internal/config"
|
|
"git.hangman-lab.top/zhi/Dialectic.PlexumPlugin/internal/hfprecheck"
|
|
)
|
|
|
|
type Deps struct {
|
|
Config config.Resolved
|
|
Host sdkplugin.HostAPI
|
|
|
|
// AgentIDFromCtx resolves the calling agent's id. v1: returns
|
|
// config.DefaultAgentID (single-agent claws); multi-agent claws
|
|
// must wait for SDK ctx plumbing — see ../../README.md.
|
|
AgentIDFromCtx func(ctx context.Context) string
|
|
}
|
|
|
|
func Dispatch(ctx context.Context, deps Deps, name string, input json.RawMessage) (sdkplugin.ToolResult, error) {
|
|
switch name {
|
|
case "dialectic_list_topics":
|
|
return toolListTopics(ctx, deps, input)
|
|
case "dialectic_topic_detail":
|
|
return toolTopicDetail(ctx, deps, input)
|
|
case "dialectic_list_arguments":
|
|
return toolListArguments(ctx, deps, input)
|
|
case "dialectic_propose_topic":
|
|
return toolProposeTopic(ctx, deps, input)
|
|
case "dialectic_signup":
|
|
return toolSignup(ctx, deps, input)
|
|
case "dialectic_post_argument":
|
|
return toolPostArgument(ctx, deps, input)
|
|
case "dialectic_submit_verdict":
|
|
return toolSubmitVerdict(ctx, deps, input)
|
|
case "dialectic_view_verdict":
|
|
return toolViewVerdict(ctx, deps, input)
|
|
}
|
|
return errResult("unknown tool: " + name), nil
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
func clientFor(deps Deps, agentID string) (*backend.Client, error) {
|
|
key := deps.Config.ResolveAPIKey(agentID)
|
|
if key == "" {
|
|
return nil, fmt.Errorf("dialectic api key not configured for agent %q (set plugins.dialectic.config.apiKey or agentKeys[%q])", agentID, agentID)
|
|
}
|
|
return backend.New(deps.Config.BackendURL, key), nil
|
|
}
|
|
|
|
func okResult(body json.RawMessage) sdkplugin.ToolResult {
|
|
if len(body) == 0 {
|
|
return sdkplugin.NewTextResult("null")
|
|
}
|
|
return sdkplugin.NewTextResult(string(body))
|
|
}
|
|
|
|
func errResult(msg string) sdkplugin.ToolResult {
|
|
return sdkplugin.ToolResult{
|
|
IsError: true,
|
|
Content: []sdkplugin.ContentBlock{{Type: "text", Text: "error: " + msg}},
|
|
}
|
|
}
|
|
|
|
// ---- tools ----
|
|
|
|
type listTopicsIn struct {
|
|
Status string `json:"status,omitempty"`
|
|
Visibility string `json:"visibility,omitempty"`
|
|
Limit *int `json:"limit,omitempty"`
|
|
Offset *int `json:"offset,omitempty"`
|
|
}
|
|
|
|
func toolListTopics(ctx context.Context, deps Deps, in json.RawMessage) (sdkplugin.ToolResult, error) {
|
|
var p listTopicsIn
|
|
if len(in) > 0 {
|
|
if err := json.Unmarshal(in, &p); err != nil {
|
|
return errResult("invalid input: " + err.Error()), nil
|
|
}
|
|
}
|
|
cli, err := clientFor(deps, deps.AgentIDFromCtx(ctx))
|
|
if err != nil {
|
|
return errResult(err.Error()), nil
|
|
}
|
|
q := url.Values{}
|
|
if p.Status != "" {
|
|
q.Set("status", p.Status)
|
|
}
|
|
if p.Visibility != "" {
|
|
q.Set("visibility", p.Visibility)
|
|
}
|
|
if p.Limit != nil {
|
|
q.Set("limit", strconv.Itoa(*p.Limit))
|
|
}
|
|
if p.Offset != nil {
|
|
q.Set("offset", strconv.Itoa(*p.Offset))
|
|
}
|
|
path := "/api/topics"
|
|
if qs := q.Encode(); qs != "" {
|
|
path += "?" + qs
|
|
}
|
|
raw, err := cli.Get(ctx, path)
|
|
if err != nil {
|
|
return errResult(err.Error()), nil
|
|
}
|
|
return okResult(raw), nil
|
|
}
|
|
|
|
type topicIDIn struct {
|
|
TopicID string `json:"topic_id"`
|
|
}
|
|
|
|
func toolTopicDetail(ctx context.Context, deps Deps, in json.RawMessage) (sdkplugin.ToolResult, error) {
|
|
var p topicIDIn
|
|
if err := json.Unmarshal(in, &p); err != nil {
|
|
return errResult("invalid input: " + err.Error()), nil
|
|
}
|
|
if p.TopicID == "" {
|
|
return errResult("topic_id required"), nil
|
|
}
|
|
cli, err := clientFor(deps, deps.AgentIDFromCtx(ctx))
|
|
if err != nil {
|
|
return errResult(err.Error()), nil
|
|
}
|
|
raw, err := cli.Get(ctx, "/api/topics/"+url.PathEscape(p.TopicID))
|
|
if err != nil {
|
|
return errResult(err.Error()), nil
|
|
}
|
|
return okResult(raw), nil
|
|
}
|
|
|
|
func toolListArguments(ctx context.Context, deps Deps, in json.RawMessage) (sdkplugin.ToolResult, error) {
|
|
var p topicIDIn
|
|
if err := json.Unmarshal(in, &p); err != nil {
|
|
return errResult("invalid input: " + err.Error()), nil
|
|
}
|
|
if p.TopicID == "" {
|
|
return errResult("topic_id required"), nil
|
|
}
|
|
cli, err := clientFor(deps, deps.AgentIDFromCtx(ctx))
|
|
if err != nil {
|
|
return errResult(err.Error()), nil
|
|
}
|
|
raw, err := cli.Get(ctx, "/api/topics/"+url.PathEscape(p.TopicID)+"/arguments")
|
|
if err != nil {
|
|
return errResult(err.Error()), nil
|
|
}
|
|
return okResult(raw), nil
|
|
}
|
|
|
|
type proposeTopicIn struct {
|
|
Title string `json:"title"`
|
|
Summary string `json:"summary"`
|
|
VerdictSchemaID string `json:"verdict_schema_id"`
|
|
Visibility string `json:"visibility,omitempty"`
|
|
SignupOpenAt string `json:"signup_open_at"`
|
|
SignupCloseAt string `json:"signup_close_at"`
|
|
DebateStartAt string `json:"debate_start_at"`
|
|
DebateEndAt string `json:"debate_end_at"`
|
|
}
|
|
|
|
func toolProposeTopic(ctx context.Context, deps Deps, in json.RawMessage) (sdkplugin.ToolResult, error) {
|
|
var p proposeTopicIn
|
|
if err := json.Unmarshal(in, &p); err != nil {
|
|
return errResult("invalid input: " + err.Error()), nil
|
|
}
|
|
for k, v := range map[string]string{
|
|
"title": p.Title,
|
|
"summary": p.Summary,
|
|
"verdict_schema_id": p.VerdictSchemaID,
|
|
"signup_open_at": p.SignupOpenAt,
|
|
"signup_close_at": p.SignupCloseAt,
|
|
"debate_start_at": p.DebateStartAt,
|
|
"debate_end_at": p.DebateEndAt,
|
|
} {
|
|
if v == "" {
|
|
return errResult(k + " required"), nil
|
|
}
|
|
}
|
|
cli, err := clientFor(deps, deps.AgentIDFromCtx(ctx))
|
|
if err != nil {
|
|
return errResult(err.Error()), nil
|
|
}
|
|
body := map[string]any{
|
|
"title": p.Title,
|
|
"summary": p.Summary,
|
|
"verdict_schema_id": p.VerdictSchemaID,
|
|
"signup_open_at": p.SignupOpenAt,
|
|
"signup_close_at": p.SignupCloseAt,
|
|
"debate_start_at": p.DebateStartAt,
|
|
"debate_end_at": p.DebateEndAt,
|
|
}
|
|
if p.Visibility != "" {
|
|
body["visibility"] = p.Visibility
|
|
}
|
|
raw, err := cli.Post(ctx, "/api/topics", body)
|
|
if err != nil {
|
|
return errResult(err.Error()), nil
|
|
}
|
|
return okResult(raw), nil
|
|
}
|
|
|
|
type signupIn struct {
|
|
TopicID string `json:"topic_id"`
|
|
WillingCamps []string `json:"willing_camps"`
|
|
}
|
|
|
|
func toolSignup(ctx context.Context, deps Deps, in json.RawMessage) (sdkplugin.ToolResult, error) {
|
|
var p signupIn
|
|
if err := json.Unmarshal(in, &p); err != nil {
|
|
return errResult("invalid input: " + err.Error()), nil
|
|
}
|
|
if p.TopicID == "" {
|
|
return errResult("topic_id required"), nil
|
|
}
|
|
if len(p.WillingCamps) == 0 {
|
|
return errResult("willing_camps must have ≥1 entry"), nil
|
|
}
|
|
agentID := deps.AgentIDFromCtx(ctx)
|
|
cli, err := clientFor(deps, agentID)
|
|
if err != nil {
|
|
return errResult(err.Error()), nil
|
|
}
|
|
|
|
// Fetch topic detail so we know the debate window for HF pre-check.
|
|
rawTopic, err := cli.Get(ctx, "/api/topics/"+url.PathEscape(p.TopicID))
|
|
if err != nil {
|
|
return errResult("topic lookup: " + err.Error()), nil
|
|
}
|
|
var td backend.TopicDetail
|
|
if err := json.Unmarshal(rawTopic, &td); err != nil {
|
|
return errResult("topic lookup parse: " + err.Error()), nil
|
|
}
|
|
if td.DebateStartAt == "" || td.DebateEndAt == "" {
|
|
return errResult("topic detail missing debate window timestamps"), nil
|
|
}
|
|
|
|
pre := hfprecheck.Check(agentID, td.DebateStartAt, td.DebateEndAt)
|
|
if !pre.OK {
|
|
return errResult("HF pre-check failed: " + pre.Reason), nil
|
|
}
|
|
|
|
body := map[string]any{
|
|
"willing_camps": p.WillingCamps,
|
|
"pre_validated": pre.Source == "hf",
|
|
}
|
|
raw, err := cli.Post(ctx, "/api/topics/"+url.PathEscape(p.TopicID)+"/signups", body)
|
|
if err != nil {
|
|
return errResult(err.Error()), nil
|
|
}
|
|
return okResult(raw), nil
|
|
}
|
|
|
|
type postArgumentIn struct {
|
|
TopicID string `json:"topic_id"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
func toolPostArgument(ctx context.Context, deps Deps, in json.RawMessage) (sdkplugin.ToolResult, error) {
|
|
var p postArgumentIn
|
|
if err := json.Unmarshal(in, &p); err != nil {
|
|
return errResult("invalid input: " + err.Error()), nil
|
|
}
|
|
if p.TopicID == "" {
|
|
return errResult("topic_id required"), nil
|
|
}
|
|
if p.Content == "" {
|
|
return errResult("content required"), nil
|
|
}
|
|
cli, err := clientFor(deps, deps.AgentIDFromCtx(ctx))
|
|
if err != nil {
|
|
return errResult(err.Error()), nil
|
|
}
|
|
raw, err := cli.Post(ctx, "/api/topics/"+url.PathEscape(p.TopicID)+"/arguments",
|
|
map[string]any{"content": p.Content})
|
|
if err != nil {
|
|
return errResult(err.Error()), nil
|
|
}
|
|
return okResult(raw), nil
|
|
}
|
|
|
|
type submitVerdictIn struct {
|
|
TopicID string `json:"topic_id"`
|
|
Verdict map[string]any `json:"verdict"`
|
|
Rationale string `json:"rationale"`
|
|
TokensInput *int `json:"tokens_input,omitempty"`
|
|
TokensOutput *int `json:"tokens_output,omitempty"`
|
|
}
|
|
|
|
func toolSubmitVerdict(ctx context.Context, deps Deps, in json.RawMessage) (sdkplugin.ToolResult, error) {
|
|
var p submitVerdictIn
|
|
if err := json.Unmarshal(in, &p); err != nil {
|
|
return errResult("invalid input: " + err.Error()), nil
|
|
}
|
|
if p.TopicID == "" {
|
|
return errResult("topic_id required"), nil
|
|
}
|
|
if p.Verdict == nil {
|
|
return errResult("verdict required"), nil
|
|
}
|
|
if p.Rationale == "" {
|
|
return errResult("rationale required"), nil
|
|
}
|
|
cli, err := clientFor(deps, deps.AgentIDFromCtx(ctx))
|
|
if err != nil {
|
|
return errResult(err.Error()), nil
|
|
}
|
|
body := map[string]any{
|
|
"verdict": p.Verdict,
|
|
"rationale": p.Rationale,
|
|
}
|
|
if p.TokensInput != nil {
|
|
body["tokens_input"] = *p.TokensInput
|
|
}
|
|
if p.TokensOutput != nil {
|
|
body["tokens_output"] = *p.TokensOutput
|
|
}
|
|
raw, err := cli.Post(ctx, "/api/topics/"+url.PathEscape(p.TopicID)+"/verdict", body)
|
|
if err != nil {
|
|
return errResult(err.Error()), nil
|
|
}
|
|
return okResult(raw), nil
|
|
}
|
|
|
|
func toolViewVerdict(ctx context.Context, deps Deps, in json.RawMessage) (sdkplugin.ToolResult, error) {
|
|
var p topicIDIn
|
|
if err := json.Unmarshal(in, &p); err != nil {
|
|
return errResult("invalid input: " + err.Error()), nil
|
|
}
|
|
if p.TopicID == "" {
|
|
return errResult("topic_id required"), nil
|
|
}
|
|
cli, err := clientFor(deps, deps.AgentIDFromCtx(ctx))
|
|
if err != nil {
|
|
return errResult(err.Error()), nil
|
|
}
|
|
raw, err := cli.Get(ctx, "/api/topics/"+url.PathEscape(p.TopicID)+"/verdict")
|
|
if err != nil {
|
|
return errResult(err.Error()), nil
|
|
}
|
|
return okResult(raw), nil
|
|
}
|