feat: Phase F-3 + F-4 — exp backoff + agent tool surface (batch 1)
F-3 refinements:
- internal/inbound: replace fixed 3s reconnect wait with exponential
backoff (1s → 60s, ×2, reset when prior session lasted >30s); proxy
for "healthy" vs "flapping" and avoids hot reconnect loops when the
server is sick
F-4 agent tool surface (port of openclaw plugin's tools.ts):
- internal/tools/tools.go (~370 LOC): Registry binds Deps {Client,
Tokens, Identities} and exposes 8 agent-facing tools:
fabric-send-message post a normal message to any channel
fabric-send-sys-msg post a kind=sys message (bypasses turn engine)
fabric-channel-list list channels visible in a guild
fabric-guild-list list guilds the agent is in
fabric-message-history paginate channel messages by seq
fabric-channel-set-purpose PATCH the channel's purpose
fabric-channel fetch metadata + members for one channel
fabric-canvas get/share/update/remove channel canvas
- internal/tools/contracts.go: static ToolContract list — kept in sync
with install.sh's manifest emitter
- Every agent-scoped tool requires agent_id in input args (Plexum SDK
doesn't propagate calling agent id through CallTool today)
- guild_node_id defaults to agent's first guild for fabric-send-message
internal/fabric/client.go: new REST methods needed by tools —
PostSystemMessage, CreateChannel, CloseChannel, JoinChannel,
LeaveChannel, SetChannelPurpose, GetCanvas, ShareCanvas, UpdateCanvas,
RemoveCanvas, SyncCommands.
cmd/plexum-fabric-channel-plugin/main.go:
- Manifest declares the tool surface via tools.New(...).Contracts()
- CallTool dispatches "send" to handleSend (outbound for channel
manager), everything else to tools.Registry.Handler(name)
scripts/install.sh:
- Manifest tools[] now lists all 9 tools with schemas — matches what
internal/tools/contracts.go advertises
Live verified against running Fabric stack:
$ plexum plugin-call fabric-guild-list '{"agent_id":"fabrictester"}'
→ "guilds for agent fabrictester (1): test-guild2 @ http://localhost:7003"
$ plexum plugin-call fabric-channel-list '{...,"guild_node_id":"test-guild2"}'
→ 2 channels listed
$ plexum plugin-call fabric-message-history '{...,"limit":5}'
→ 5 messages with timestamps + authors
F-5+ deferred:
- create-{chat,work,report,discussion}-channel (batch 2)
- sub-discussion family (state store + 3 tools)
- presence-sync + command-sync
- attachments
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
442
internal/tools/tools.go
Normal file
442
internal/tools/tools.go
Normal file
@@ -0,0 +1,442 @@
|
||||
// Package tools implements the Fabric-specific MCP tools the plugin
|
||||
// exposes to Plexum agents (port of openclaw plugin's tools.ts, F-4 +
|
||||
// F-5 + F-7 split). Each tool here is bound to a Plexum context that
|
||||
// gives it access to:
|
||||
//
|
||||
// - fabric.Client for REST calls
|
||||
// - tokens.Cache for per-agent session reuse + auto-refresh
|
||||
// - identity.Registry to look up agent → fabric user_id
|
||||
// - the plugin's logger
|
||||
//
|
||||
// Per-tool agent identity: Plexum SDK doesn't propagate the calling
|
||||
// agent id through CallTool today, so every agent-scoped tool here
|
||||
// requires `agent_id` in input args. The agent learns its own id from
|
||||
// the host (env var or system prompt — operator concern).
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
plugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin"
|
||||
|
||||
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/fabric"
|
||||
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/identity"
|
||||
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/tokens"
|
||||
)
|
||||
|
||||
// Deps bundles what every tool needs.
|
||||
type Deps struct {
|
||||
Client *fabric.Client
|
||||
Tokens *tokens.Cache
|
||||
Identities *identity.Registry
|
||||
}
|
||||
|
||||
// HandlerFunc is the tool's call-time function. Returns a ToolResult
|
||||
// (text content) or an in-band error result (IsError=true).
|
||||
type HandlerFunc func(ctx context.Context, input json.RawMessage) (plugin.ToolResult, error)
|
||||
|
||||
// Registry maps tool name → HandlerFunc. Build once at plugin init.
|
||||
type Registry struct {
|
||||
deps Deps
|
||||
handlers map[string]HandlerFunc
|
||||
}
|
||||
|
||||
// New constructs a Registry with the agent-facing tools wired.
|
||||
func New(deps Deps) *Registry {
|
||||
r := &Registry{deps: deps, handlers: map[string]HandlerFunc{}}
|
||||
r.registerSendMessage()
|
||||
r.registerSendSysMsg()
|
||||
r.registerChannelList()
|
||||
r.registerGuildList()
|
||||
r.registerMessageHistory()
|
||||
r.registerChannelSetPurpose()
|
||||
r.registerChannel()
|
||||
r.registerCanvas()
|
||||
return r
|
||||
}
|
||||
|
||||
// Names returns the registered tool names (for manifest generation).
|
||||
func (r *Registry) Names() []string {
|
||||
out := make([]string, 0, len(r.handlers))
|
||||
for k := range r.handlers {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Handler returns the function for `name`, or nil if not registered.
|
||||
func (r *Registry) Handler(name string) HandlerFunc {
|
||||
return r.handlers[name]
|
||||
}
|
||||
|
||||
// Contracts returns the MCP ToolContracts for all registered tools.
|
||||
// Used by install.sh / manifest generation; also useful for runtime
|
||||
// introspection.
|
||||
func (r *Registry) Contracts() []plugin.ToolContract {
|
||||
return allContracts
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func textResult(text string) plugin.ToolResult {
|
||||
return plugin.NewTextResult(text)
|
||||
}
|
||||
|
||||
func errResult(msg string) plugin.ToolResult {
|
||||
return plugin.ToolResult{
|
||||
Content: []plugin.ContentBlock{{Type: "text", Text: msg}},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
func jsonResult(label string, body any) plugin.ToolResult {
|
||||
raw, err := json.MarshalIndent(body, "", " ")
|
||||
if err != nil {
|
||||
return errResult(label + ": marshal: " + err.Error())
|
||||
}
|
||||
return textResult(label + ":\n" + string(raw))
|
||||
}
|
||||
|
||||
// agentGuild resolves the agent's session + the specified guild's
|
||||
// endpoint + token. Common preamble for every tool that hits a guild.
|
||||
func (r *Registry) agentGuild(ctx context.Context, agentID, guildNodeID string) (sess *fabric.Session, endpoint, token string, err error) {
|
||||
if agentID == "" {
|
||||
err = fmt.Errorf("agent_id is required")
|
||||
return
|
||||
}
|
||||
sess, err = r.deps.Tokens.Get(ctx, agentID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, g := range sess.Guilds {
|
||||
if g.NodeID == guildNodeID {
|
||||
endpoint = g.Endpoint
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, t := range sess.GuildAccessTokens {
|
||||
if t.GuildNodeID == guildNodeID {
|
||||
token = t.Token
|
||||
break
|
||||
}
|
||||
}
|
||||
if endpoint == "" || token == "" {
|
||||
err = fmt.Errorf("agent %s has no access to guild %s", agentID, guildNodeID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// pickFirstGuild returns the first guild from the session when caller
|
||||
// omits guild_node_id. Most agents are in exactly one guild, so this is
|
||||
// a no-arg sane default.
|
||||
func pickFirstGuild(sess *fabric.Session) (string, error) {
|
||||
for _, g := range sess.Guilds {
|
||||
if g.NodeID != "" {
|
||||
return g.NodeID, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("agent has no guilds")
|
||||
}
|
||||
|
||||
// ---- tool: fabric-send-message ----
|
||||
|
||||
func (r *Registry) registerSendMessage() {
|
||||
r.handlers["fabric-send-message"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
||||
var in struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
GuildNodeID string `json:"guild_node_id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return errResult("fabric-send-message: parse: " + err.Error()), nil
|
||||
}
|
||||
if in.ChannelID == "" || in.Content == "" {
|
||||
return errResult("fabric-send-message: channel_id and content required"), nil
|
||||
}
|
||||
sess, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
||||
if err != nil {
|
||||
if in.GuildNodeID == "" && in.AgentID != "" {
|
||||
// Try first-guild fallback.
|
||||
if s2, err2 := r.deps.Tokens.Get(ctx, in.AgentID); err2 == nil {
|
||||
if gn, err3 := pickFirstGuild(s2); err3 == nil {
|
||||
in.GuildNodeID = gn
|
||||
sess, endpoint, token, err = r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return errResult("fabric-send-message: " + err.Error()), nil
|
||||
}
|
||||
}
|
||||
if err := r.deps.Client.PostMessage(ctx, endpoint, token, in.ChannelID, in.Content, sess.User.ID); err != nil {
|
||||
return errResult("fabric-send-message: post: " + err.Error()), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("sent: guild=%s channel=%s len=%d",
|
||||
in.GuildNodeID, in.ChannelID, len(in.Content))), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tool: fabric-send-sys-msg ----
|
||||
|
||||
func (r *Registry) registerSendSysMsg() {
|
||||
r.handlers["fabric-send-sys-msg"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
||||
var in struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
GuildNodeID string `json:"guild_node_id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return errResult("fabric-send-sys-msg: parse: " + err.Error()), nil
|
||||
}
|
||||
if in.ChannelID == "" || in.Content == "" {
|
||||
return errResult("fabric-send-sys-msg: channel_id and content required"), nil
|
||||
}
|
||||
sess, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
||||
if err != nil {
|
||||
return errResult("fabric-send-sys-msg: " + err.Error()), nil
|
||||
}
|
||||
if err := r.deps.Client.PostSystemMessage(ctx, endpoint, token, in.ChannelID, in.Content, sess.User.ID); err != nil {
|
||||
return errResult("fabric-send-sys-msg: post: " + err.Error()), nil
|
||||
}
|
||||
return textResult("system message sent"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tool: fabric-channel-list ----
|
||||
|
||||
func (r *Registry) registerChannelList() {
|
||||
r.handlers["fabric-channel-list"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
||||
var in struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
GuildNodeID string `json:"guild_node_id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return errResult("fabric-channel-list: parse: " + err.Error()), nil
|
||||
}
|
||||
_, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
||||
if err != nil {
|
||||
return errResult("fabric-channel-list: " + err.Error()), nil
|
||||
}
|
||||
channels, err := r.deps.Client.ListChannels(ctx, endpoint, token, in.GuildNodeID)
|
||||
if err != nil {
|
||||
return errResult("fabric-channel-list: " + err.Error()), nil
|
||||
}
|
||||
// Compact rendering — table-ish.
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "channels in guild %s (%d):\n", in.GuildNodeID, len(channels))
|
||||
for _, c := range channels {
|
||||
purpose := ""
|
||||
if c.Purpose != nil {
|
||||
purpose = " — " + *c.Purpose
|
||||
}
|
||||
fmt.Fprintf(&b, " %s [%s] %s%s\n", c.ID, c.XType, c.Name, purpose)
|
||||
}
|
||||
return textResult(b.String()), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tool: fabric-guild-list ----
|
||||
|
||||
func (r *Registry) registerGuildList() {
|
||||
r.handlers["fabric-guild-list"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
||||
var in struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return errResult("fabric-guild-list: parse: " + err.Error()), nil
|
||||
}
|
||||
if in.AgentID == "" {
|
||||
return errResult("fabric-guild-list: agent_id required"), nil
|
||||
}
|
||||
sess, err := r.deps.Tokens.Get(ctx, in.AgentID)
|
||||
if err != nil {
|
||||
return errResult("fabric-guild-list: " + err.Error()), nil
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "guilds for agent %s (%d):\n", in.AgentID, len(sess.Guilds))
|
||||
for _, g := range sess.Guilds {
|
||||
purpose := ""
|
||||
if g.Purpose != nil {
|
||||
purpose = " — " + *g.Purpose
|
||||
}
|
||||
fmt.Fprintf(&b, " %s @ %s (%s)%s\n", g.NodeID, g.Endpoint, g.Name, purpose)
|
||||
}
|
||||
return textResult(b.String()), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tool: fabric-message-history ----
|
||||
|
||||
func (r *Registry) registerMessageHistory() {
|
||||
r.handlers["fabric-message-history"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
||||
var in struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
GuildNodeID string `json:"guild_node_id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
SeqFrom int `json:"seq_from"`
|
||||
SeqTo int `json:"seq_to"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return errResult("fabric-message-history: parse: " + err.Error()), nil
|
||||
}
|
||||
if in.ChannelID == "" {
|
||||
return errResult("fabric-message-history: channel_id required"), nil
|
||||
}
|
||||
_, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
||||
if err != nil {
|
||||
return errResult("fabric-message-history: " + err.Error()), nil
|
||||
}
|
||||
page, err := r.deps.Client.ListMessages(ctx, endpoint, token, in.ChannelID,
|
||||
fabric.ListMessagesOpts{SeqFrom: in.SeqFrom, SeqTo: in.SeqTo, Limit: in.Limit})
|
||||
if err != nil {
|
||||
return errResult("fabric-message-history: " + err.Error()), nil
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "history channel=%s seq=%d-%d returned=%d hasMore=%v\n",
|
||||
in.ChannelID, page.Page.SeqFrom, page.Page.SeqTo, page.Page.Returned, page.Page.HasMore)
|
||||
for _, m := range page.Items {
|
||||
marker := ""
|
||||
if m.IsDeleted {
|
||||
marker = " (deleted)"
|
||||
} else if m.EditedAt != nil {
|
||||
marker = " (edited)"
|
||||
}
|
||||
fmt.Fprintf(&b, " seq=%d %s by %s%s: %s\n",
|
||||
m.Seq, m.CreatedAt, m.AuthorUserID, marker, m.Content)
|
||||
}
|
||||
return textResult(b.String()), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tool: fabric-channel-set-purpose ----
|
||||
|
||||
func (r *Registry) registerChannelSetPurpose() {
|
||||
r.handlers["fabric-channel-set-purpose"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
||||
var in struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
GuildNodeID string `json:"guild_node_id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return errResult("fabric-channel-set-purpose: parse: " + err.Error()), nil
|
||||
}
|
||||
if in.ChannelID == "" {
|
||||
return errResult("fabric-channel-set-purpose: channel_id required"), nil
|
||||
}
|
||||
_, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
||||
if err != nil {
|
||||
return errResult("fabric-channel-set-purpose: " + err.Error()), nil
|
||||
}
|
||||
if err := r.deps.Client.SetChannelPurpose(ctx, endpoint, token, in.ChannelID, in.Purpose); err != nil {
|
||||
return errResult("fabric-channel-set-purpose: " + err.Error()), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("purpose updated for %s", in.ChannelID)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tool: fabric-channel (meta about one channel) ----
|
||||
|
||||
func (r *Registry) registerChannel() {
|
||||
r.handlers["fabric-channel"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
||||
var in struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
GuildNodeID string `json:"guild_node_id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return errResult("fabric-channel: parse: " + err.Error()), nil
|
||||
}
|
||||
if in.ChannelID == "" {
|
||||
return errResult("fabric-channel: channel_id required"), nil
|
||||
}
|
||||
_, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
||||
if err != nil {
|
||||
return errResult("fabric-channel: " + err.Error()), nil
|
||||
}
|
||||
// Find by scanning channel list — guild has no GET-one endpoint.
|
||||
channels, err := r.deps.Client.ListChannels(ctx, endpoint, token, in.GuildNodeID)
|
||||
if err != nil {
|
||||
return errResult("fabric-channel: " + err.Error()), nil
|
||||
}
|
||||
for _, c := range channels {
|
||||
if c.ID == in.ChannelID {
|
||||
members, _ := r.deps.Client.ChannelMembers(ctx, endpoint, token, in.ChannelID)
|
||||
return jsonResult("channel", map[string]any{
|
||||
"channel": c, "members": members,
|
||||
}), nil
|
||||
}
|
||||
}
|
||||
return errResult("fabric-channel: channel " + in.ChannelID + " not found in guild"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tool: fabric-canvas (get / share / update / remove) ----
|
||||
|
||||
func (r *Registry) registerCanvas() {
|
||||
r.handlers["fabric-canvas"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
||||
var in struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
GuildNodeID string `json:"guild_node_id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Op string `json:"op"` // "get" | "share" | "update" | "remove"
|
||||
Title string `json:"title"`
|
||||
Format string `json:"format"` // "md" | "html" | "text"
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return errResult("fabric-canvas: parse: " + err.Error()), nil
|
||||
}
|
||||
if in.ChannelID == "" {
|
||||
return errResult("fabric-canvas: channel_id required"), nil
|
||||
}
|
||||
if in.Op == "" {
|
||||
in.Op = "get"
|
||||
}
|
||||
_, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
||||
if err != nil {
|
||||
return errResult("fabric-canvas: " + err.Error()), nil
|
||||
}
|
||||
switch in.Op {
|
||||
case "get":
|
||||
canvas, err := r.deps.Client.GetCanvas(ctx, endpoint, token, in.ChannelID)
|
||||
if err != nil {
|
||||
return errResult("fabric-canvas: " + err.Error()), nil
|
||||
}
|
||||
if canvas == nil {
|
||||
return textResult("(no canvas set on this channel)"), nil
|
||||
}
|
||||
return jsonResult("canvas", canvas), nil
|
||||
case "share":
|
||||
if in.Title == "" || in.Source == "" || in.Format == "" {
|
||||
return errResult("fabric-canvas share: title + format + source required"), nil
|
||||
}
|
||||
canvas, err := r.deps.Client.ShareCanvas(ctx, endpoint, token, in.ChannelID,
|
||||
in.Title, fabric.CanvasFormat(in.Format), in.Source)
|
||||
if err != nil {
|
||||
return errResult("fabric-canvas share: " + err.Error()), nil
|
||||
}
|
||||
return jsonResult("canvas shared", canvas), nil
|
||||
case "update":
|
||||
canvas, err := r.deps.Client.UpdateCanvas(ctx, endpoint, token, in.ChannelID,
|
||||
in.Title, in.Source, fabric.CanvasFormat(in.Format))
|
||||
if err != nil {
|
||||
return errResult("fabric-canvas update: " + err.Error()), nil
|
||||
}
|
||||
return jsonResult("canvas updated", canvas), nil
|
||||
case "remove":
|
||||
if err := r.deps.Client.RemoveCanvas(ctx, endpoint, token, in.ChannelID); err != nil {
|
||||
return errResult("fabric-canvas remove: " + err.Error()), nil
|
||||
}
|
||||
return textResult("canvas removed"), nil
|
||||
default:
|
||||
return errResult("fabric-canvas: unknown op " + in.Op + " (want get|share|update|remove)"), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user