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

@@ -29,6 +29,7 @@ import (
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/identity"
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/inbound"
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/tokens"
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/tools"
)
// HostConfig is the plugin's own config at
@@ -51,6 +52,7 @@ type fabricPlugin struct {
byFabric config.ByFabricChannel
client *fabric.Client
tokens *tokens.Cache
tools *tools.Registry
// Goroutine handle for the inbound supervisor. Cancelled on
// plugin shutdown (we don't have an explicit shutdown signal in
@@ -66,12 +68,10 @@ type fabricPlugin struct {
}
func (p *fabricPlugin) Manifest() plugin.Manifest {
// Manifest channels are populated dynamically from channels/*.json
// at startup: the operator adds a channels/<name>.json + restarts
// the gateway, and the matching ChannelContract entry surfaces here.
// Both halves needed because Plexum's host registry reads the
// manifest's channel names too.
channels := p.dynamicChannelContracts()
// Tool surface = the agent-facing tool family (port of openclaw
// plugin's tools.ts, F-4). The "send" outbound is also there for
// the host's channel manager.
return plugin.Manifest{
Name: config.PluginName,
Version: "0.1.0",
@@ -79,21 +79,7 @@ func (p *fabricPlugin) Manifest() plugin.Manifest {
Executable: "plexum-fabric-channel-plugin",
Contracts: plugin.Contracts{
Channels: channels,
Tools: []plugin.ToolContract{
{
Name: "send",
Description: "Post a plain-text message to the bound Fabric channel as the agent user.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"channel_name": {"type": "string"},
"session_id": {"type": "string"},
"message": {"type": "string"}
},
"required": ["channel_name", "message"]
}`),
},
},
Tools: tools.New(tools.Deps{}).Contracts(),
},
}
}
@@ -169,6 +155,11 @@ func (p *fabricPlugin) Init(ctx context.Context, host plugin.HostAPI) error {
return p.client.AgentLogin(loginCtx, entry.FabricAPIKey)
})
// Agent-facing tool surface (port of openclaw's tools.ts).
p.tools = tools.New(tools.Deps{
Client: p.client, Tokens: p.tokens, Identities: p.identities,
})
host.Log("info", "fabric channel plugin initialized", map[string]any{
"center": p.cfg.CenterAPIBase,
"identity_path": idPath,
@@ -284,11 +275,22 @@ func (p *fabricPlugin) warmSessions(ctx context.Context) error {
return firstErr
}
// CallTool handles the "send" outbound tool.
// CallTool dispatches by tool name. "send" is the host-side channel
// outbound (driven by Plexum's channel manager); everything else
// delegates to the tools.Registry built at init time.
func (p *fabricPlugin) CallTool(ctx context.Context, name string, input json.RawMessage) (plugin.ToolResult, error) {
if name != "send" {
return plugin.ToolResult{}, fmt.Errorf("unknown tool: %s", name)
if name == "send" {
return p.handleSend(ctx, input)
}
if handler := p.tools.Handler(name); handler != nil {
return handler(ctx, input)
}
return plugin.ToolResult{}, fmt.Errorf("unknown tool: %s", name)
}
// handleSend implements the "send" outbound tool: posts the assistant
// reply via Fabric REST as the agent bound to the Plexum channel name.
func (p *fabricPlugin) handleSend(ctx context.Context, input json.RawMessage) (plugin.ToolResult, error) {
var args struct {
ChannelName string `json:"channel_name"`
SessionID string `json:"session_id"`
@@ -304,7 +306,6 @@ func (p *fabricPlugin) CallTool(ctx context.Context, name string, input json.Raw
"channel_name": args.ChannelName, "len": len(args.Message),
})
// Find the binding for this plexum channel name.
var binding *config.FabricBinding
for i := range p.bindings {
if p.bindings[i].PlexumChannelName == args.ChannelName {
@@ -316,19 +317,12 @@ func (p *fabricPlugin) CallTool(ctx context.Context, name string, input json.Raw
return errResult("unknown plexum channel: " + args.ChannelName), nil
}
// Resolve the bound agent's session (may need refresh — F-2 will
// add a proper TTL + background refresh; for F-1 we re-login lazily
// if the cache is empty).
sess, err := p.sessionFor(ctx, binding.AgentID)
if err != nil {
return errResult("session for agent " + binding.AgentID + ": " + err.Error()), nil
}
// Pick the guild endpoint + token for the target guild_node_id.
var (
endpoint string
token string
)
var endpoint, token string
for _, g := range sess.Guilds {
if g.NodeID == binding.FabricGuildNodeID {
endpoint = g.Endpoint