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:
h z
2026-05-31 15:35:39 +01:00
parent 0efcdfd342
commit d6bea46d00
6 changed files with 825 additions and 54 deletions

View File

@@ -175,6 +175,170 @@ func (c *Client) PostMessage(ctx context.Context, guildEndpoint, guildToken, cha
return err
}
// PostSystemMessage posts a system-kind message. Guild routes
// kind=sys differently (not subject to turn engine, doesn't wake
// agents); useful for narrator-style narration from a plugin tool.
func (c *Client) PostSystemMessage(ctx context.Context, guildEndpoint, guildToken, channelID, content, authorUserID string) error {
_, err := c.do(ctx, http.MethodPost,
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/messages",
guildToken,
map[string]any{"content": content, "authorUserId": authorUserID, "kind": "sys"},
nil)
return err
}
// CreateChannelOpts is the payload to POST /api/channels.
type CreateChannelOpts struct {
GuildID string `json:"guildId"`
Name string `json:"name"`
XType string `json:"xType"`
IsPublic bool `json:"isPublic,omitempty"`
MemberUserIDs []string `json:"memberUserIds,omitempty"`
OnDuty string `json:"onDuty,omitempty"`
Listeners []string `json:"listeners,omitempty"`
Purpose string `json:"purpose,omitempty"`
}
// CreateChannel creates a new channel in a guild. Returns the new channel id.
func (c *Client) CreateChannel(ctx context.Context, guildEndpoint, guildToken string, opts CreateChannelOpts) (string, error) {
var out struct {
ID string `json:"id"`
}
if err := c.postJSON(ctx, guildEndpoint+"/api/channels", opts, guildToken, &out); err != nil {
return "", err
}
return out.ID, nil
}
// CloseChannel closes a channel (one-way for most x_types).
func (c *Client) CloseChannel(ctx context.Context, guildEndpoint, guildToken, channelID string) error {
_, err := c.do(ctx, http.MethodPost,
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/close",
guildToken, map[string]any{}, nil)
return err
}
// JoinChannel adds the calling user to a public channel.
func (c *Client) JoinChannel(ctx context.Context, guildEndpoint, guildToken, channelID string) error {
_, err := c.do(ctx, http.MethodPost,
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/join",
guildToken, map[string]any{}, nil)
return err
}
// LeaveChannel removes the calling user from a channel.
func (c *Client) LeaveChannel(ctx context.Context, guildEndpoint, guildToken, channelID string) error {
_, err := c.do(ctx, http.MethodPost,
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/leave",
guildToken, map[string]any{}, nil)
return err
}
// SetChannelPurpose PATCHes the channel's purpose (free-form text).
func (c *Client) SetChannelPurpose(ctx context.Context, guildEndpoint, guildToken, channelID, purpose string) error {
_, err := c.do(ctx, http.MethodPatch,
guildEndpoint+"/api/channels/"+url.PathEscape(channelID),
guildToken, map[string]string{"purpose": purpose}, nil)
return err
}
// ---- channel canvas ----
// CanvasFormat is "md" | "html" | "text".
type CanvasFormat string
// Canvas is the shape returned by GET /api/channels/<id>/canvas.
type Canvas struct {
ChannelID string `json:"channelId"`
SharerUserID string `json:"sharerUserId"`
Title string `json:"title"`
Format CanvasFormat `json:"format"`
Source string `json:"source"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
// GetCanvas returns the canvas or nil if no canvas is set.
func (c *Client) GetCanvas(ctx context.Context, guildEndpoint, guildToken, channelID string) (*Canvas, error) {
raw, err := c.do(ctx, http.MethodGet,
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/canvas",
guildToken, nil, nil)
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, nil
}
var out Canvas
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return &out, nil
}
// ShareCanvas replaces (or initializes) the channel canvas. Caller
// becomes the sharer.
func (c *Client) ShareCanvas(ctx context.Context, guildEndpoint, guildToken, channelID, title string, format CanvasFormat, source string) (*Canvas, error) {
body := map[string]any{"title": title, "format": format, "source": source}
raw, err := c.do(ctx, http.MethodPut,
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/canvas",
guildToken, body, nil)
if err != nil {
return nil, err
}
var out Canvas
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return &out, nil
}
// UpdateCanvas updates fields in place (original sharer only; else 403).
func (c *Client) UpdateCanvas(ctx context.Context, guildEndpoint, guildToken, channelID string, title, source string, format CanvasFormat) (*Canvas, error) {
body := map[string]any{}
if title != "" {
body["title"] = title
}
if source != "" {
body["source"] = source
}
if format != "" {
body["format"] = format
}
raw, err := c.do(ctx, http.MethodPatch,
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/canvas",
guildToken, body, nil)
if err != nil {
return nil, err
}
var out Canvas
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return &out, nil
}
// RemoveCanvas closes the canvas.
func (c *Client) RemoveCanvas(ctx context.Context, guildEndpoint, guildToken, channelID string) error {
_, err := c.do(ctx, http.MethodDelete,
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/canvas",
guildToken, nil, nil)
return err
}
// SyncCommands PUTs the agent's slash-command catalog onto the guild
// (idempotent full replace). Needs the guild's commands-sync key, which
// the operator sources from the guild config.
func (c *Client) SyncCommands(ctx context.Context, guildEndpoint, guildToken string, commands []any, syncKey string) error {
headers := map[string]string{}
if syncKey != "" {
headers["x-commands-sync-key"] = syncKey
}
_, err := c.do(ctx, http.MethodPut,
guildEndpoint+"/api/commands", guildToken,
map[string]any{"commands": commands}, headers)
return err
}
// ChannelMembers lists members of a channel.
type ChannelMember struct {
UserID string `json:"userId"`