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:
@@ -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/identity"
|
||||||
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/inbound"
|
"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/tokens"
|
||||||
|
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostConfig is the plugin's own config at
|
// HostConfig is the plugin's own config at
|
||||||
@@ -51,6 +52,7 @@ type fabricPlugin struct {
|
|||||||
byFabric config.ByFabricChannel
|
byFabric config.ByFabricChannel
|
||||||
client *fabric.Client
|
client *fabric.Client
|
||||||
tokens *tokens.Cache
|
tokens *tokens.Cache
|
||||||
|
tools *tools.Registry
|
||||||
|
|
||||||
// Goroutine handle for the inbound supervisor. Cancelled on
|
// Goroutine handle for the inbound supervisor. Cancelled on
|
||||||
// plugin shutdown (we don't have an explicit shutdown signal in
|
// plugin shutdown (we don't have an explicit shutdown signal in
|
||||||
@@ -66,12 +68,10 @@ type fabricPlugin struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *fabricPlugin) Manifest() plugin.Manifest {
|
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()
|
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{
|
return plugin.Manifest{
|
||||||
Name: config.PluginName,
|
Name: config.PluginName,
|
||||||
Version: "0.1.0",
|
Version: "0.1.0",
|
||||||
@@ -79,21 +79,7 @@ func (p *fabricPlugin) Manifest() plugin.Manifest {
|
|||||||
Executable: "plexum-fabric-channel-plugin",
|
Executable: "plexum-fabric-channel-plugin",
|
||||||
Contracts: plugin.Contracts{
|
Contracts: plugin.Contracts{
|
||||||
Channels: channels,
|
Channels: channels,
|
||||||
Tools: []plugin.ToolContract{
|
Tools: tools.New(tools.Deps{}).Contracts(),
|
||||||
{
|
|
||||||
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"]
|
|
||||||
}`),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,6 +155,11 @@ func (p *fabricPlugin) Init(ctx context.Context, host plugin.HostAPI) error {
|
|||||||
return p.client.AgentLogin(loginCtx, entry.FabricAPIKey)
|
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{
|
host.Log("info", "fabric channel plugin initialized", map[string]any{
|
||||||
"center": p.cfg.CenterAPIBase,
|
"center": p.cfg.CenterAPIBase,
|
||||||
"identity_path": idPath,
|
"identity_path": idPath,
|
||||||
@@ -284,11 +275,22 @@ func (p *fabricPlugin) warmSessions(ctx context.Context) error {
|
|||||||
return firstErr
|
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) {
|
func (p *fabricPlugin) CallTool(ctx context.Context, name string, input json.RawMessage) (plugin.ToolResult, error) {
|
||||||
if name != "send" {
|
if name == "send" {
|
||||||
return plugin.ToolResult{}, fmt.Errorf("unknown tool: %s", name)
|
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 {
|
var args struct {
|
||||||
ChannelName string `json:"channel_name"`
|
ChannelName string `json:"channel_name"`
|
||||||
SessionID string `json:"session_id"`
|
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),
|
"channel_name": args.ChannelName, "len": len(args.Message),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Find the binding for this plexum channel name.
|
|
||||||
var binding *config.FabricBinding
|
var binding *config.FabricBinding
|
||||||
for i := range p.bindings {
|
for i := range p.bindings {
|
||||||
if p.bindings[i].PlexumChannelName == args.ChannelName {
|
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
|
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)
|
sess, err := p.sessionFor(ctx, binding.AgentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errResult("session for agent " + binding.AgentID + ": " + err.Error()), nil
|
return errResult("session for agent " + binding.AgentID + ": " + err.Error()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick the guild endpoint + token for the target guild_node_id.
|
var endpoint, token string
|
||||||
var (
|
|
||||||
endpoint string
|
|
||||||
token string
|
|
||||||
)
|
|
||||||
for _, g := range sess.Guilds {
|
for _, g := range sess.Guilds {
|
||||||
if g.NodeID == binding.FabricGuildNodeID {
|
if g.NodeID == binding.FabricGuildNodeID {
|
||||||
endpoint = g.Endpoint
|
endpoint = g.Endpoint
|
||||||
|
|||||||
@@ -175,6 +175,170 @@ func (c *Client) PostMessage(ctx context.Context, guildEndpoint, guildToken, cha
|
|||||||
return err
|
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.
|
// ChannelMembers lists members of a channel.
|
||||||
type ChannelMember struct {
|
type ChannelMember struct {
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
|
|||||||
@@ -32,10 +32,19 @@ import (
|
|||||||
// that arrived without a push event. Matches openclaw's 60s.
|
// that arrived without a push event. Matches openclaw's 60s.
|
||||||
const ChannelSyncInterval = 60 * time.Second
|
const ChannelSyncInterval = 60 * time.Second
|
||||||
|
|
||||||
// ReconnectBackoff is the wait between reconnect attempts when a
|
// ReconnectBackoffInitial / Max / Factor drive exponential backoff
|
||||||
// socket drops + Connect returns. Keep small for snappier recovery;
|
// between reconnect attempts. Starts at 1s, doubles up to 60s; resets
|
||||||
// exponential backoff is a future improvement.
|
// on successful connect (signalled by connectOnce returning after at
|
||||||
const ReconnectBackoff = 3 * time.Second
|
// least one event was received — proxied via wall-clock duration).
|
||||||
|
const (
|
||||||
|
ReconnectBackoffInitial = 1 * time.Second
|
||||||
|
ReconnectBackoffMax = 60 * time.Second
|
||||||
|
ReconnectBackoffFactor = 2.0
|
||||||
|
// ReconnectResetAfter: if the previous connection survived at least
|
||||||
|
// this long, reset the backoff to Initial (we made meaningful
|
||||||
|
// progress; transient drop, not a flapping-failure loop).
|
||||||
|
ReconnectResetAfter = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
// Notifier pushes one inbound message to the Plexum host. The plugin
|
// Notifier pushes one inbound message to the Plexum host. The plugin
|
||||||
// main wires this to HostAPI.EmitNotification.
|
// main wires this to HostAPI.EmitNotification.
|
||||||
@@ -110,27 +119,41 @@ func (s *Supervisor) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runAgentGuild keeps one socket.io connection alive for (agent, guild)
|
// runAgentGuild keeps one socket.io connection alive for (agent, guild)
|
||||||
// until ctx cancels. Reconnects with fresh auth on every drop.
|
// until ctx cancels. Reconnects with fresh auth on every drop using
|
||||||
|
// exponential backoff (resets if the previous session survived long
|
||||||
|
// enough to look healthy).
|
||||||
func (s *Supervisor) runAgentGuild(ctx context.Context, agentID string, guild fabric.GuildInfo) {
|
func (s *Supervisor) runAgentGuild(ctx context.Context, agentID string, guild fabric.GuildInfo) {
|
||||||
logger := s.Logger.With("agent", agentID, "guild", guild.NodeID)
|
logger := s.Logger.With("agent", agentID, "guild", guild.NodeID)
|
||||||
|
backoff := ReconnectBackoffInitial
|
||||||
for {
|
for {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
startedAt := time.Now()
|
||||||
err := s.connectOnce(ctx, agentID, guild, logger)
|
err := s.connectOnce(ctx, agentID, guild, logger)
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Reset backoff if the prior session was meaningfully long
|
||||||
|
// (proxy for "we connected + did work" — not a flap loop).
|
||||||
|
if time.Since(startedAt) > ReconnectResetAfter {
|
||||||
|
backoff = ReconnectBackoffInitial
|
||||||
|
}
|
||||||
errStr := "(nil)"
|
errStr := "(nil)"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errStr = err.Error()
|
errStr = err.Error()
|
||||||
}
|
}
|
||||||
logger.Warn("inbound: socket connection ended; reconnecting", "err", errStr)
|
logger.Warn("inbound: socket ended; reconnecting",
|
||||||
|
"err", errStr, "backoff", backoff.String())
|
||||||
select {
|
select {
|
||||||
case <-time.After(ReconnectBackoff):
|
case <-time.After(backoff):
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
backoff = time.Duration(float64(backoff) * ReconnectBackoffFactor)
|
||||||
|
if backoff > ReconnectBackoffMax {
|
||||||
|
backoff = ReconnectBackoffMax
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
139
internal/tools/contracts.go
Normal file
139
internal/tools/contracts.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
plugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// allContracts is the static manifest list of all agent-facing tools.
|
||||||
|
// install.sh reads this via a Go-build introspection step? No — we'd
|
||||||
|
// need a separate emit-manifest binary. For now, install.sh has the
|
||||||
|
// same JSON inline (keep in sync; trivially small). Future refinement
|
||||||
|
// could auto-emit.
|
||||||
|
var allContracts = []plugin.ToolContract{
|
||||||
|
{
|
||||||
|
Name: "send",
|
||||||
|
Description: "Outbound channel reply — host calls this after an inbound dispatch (agent-side calls usually prefer `fabric-send-message`).",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"channel_name": {"type": "string"},
|
||||||
|
"session_id": {"type": "string"},
|
||||||
|
"message": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["channel_name", "message"]
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "fabric-send-message",
|
||||||
|
Description: "Post a normal message to a Fabric channel as the bound agent. guild_node_id defaults to the agent's first guild when omitted.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"agent_id": {"type": "string"},
|
||||||
|
"guild_node_id": {"type": "string"},
|
||||||
|
"channel_id": {"type": "string"},
|
||||||
|
"content": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["agent_id", "channel_id", "content"]
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "fabric-send-sys-msg",
|
||||||
|
Description: "Post a system-kind message to a channel (not subject to turn engine / wakeup).",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"agent_id": {"type": "string"},
|
||||||
|
"guild_node_id": {"type": "string"},
|
||||||
|
"channel_id": {"type": "string"},
|
||||||
|
"content": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["agent_id", "guild_node_id", "channel_id", "content"]
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "fabric-channel-list",
|
||||||
|
Description: "List channels visible to the agent in a guild (public + member channels).",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"agent_id": {"type": "string"},
|
||||||
|
"guild_node_id": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["agent_id", "guild_node_id"]
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "fabric-guild-list",
|
||||||
|
Description: "List the guilds the agent belongs to (nodes + endpoints).",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"agent_id": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["agent_id"]
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "fabric-message-history",
|
||||||
|
Description: "Paginate channel messages by seq. seq_from/seq_to/limit are optional (server caps limit at 200).",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"agent_id": {"type": "string"},
|
||||||
|
"guild_node_id": {"type": "string"},
|
||||||
|
"channel_id": {"type": "string"},
|
||||||
|
"seq_from": {"type": "integer"},
|
||||||
|
"seq_to": {"type": "integer"},
|
||||||
|
"limit": {"type": "integer"}
|
||||||
|
},
|
||||||
|
"required": ["agent_id", "guild_node_id", "channel_id"]
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "fabric-channel-set-purpose",
|
||||||
|
Description: "Update the channel's free-form purpose text.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"agent_id": {"type": "string"},
|
||||||
|
"guild_node_id": {"type": "string"},
|
||||||
|
"channel_id": {"type": "string"},
|
||||||
|
"purpose": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["agent_id", "guild_node_id", "channel_id", "purpose"]
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "fabric-channel",
|
||||||
|
Description: "Fetch metadata + member list for a specific channel.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"agent_id": {"type": "string"},
|
||||||
|
"guild_node_id": {"type": "string"},
|
||||||
|
"channel_id": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["agent_id", "guild_node_id", "channel_id"]
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "fabric-canvas",
|
||||||
|
Description: "Read or write the channel's pinned canvas doc. op: get|share|update|remove. share/update need title+format+source.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"agent_id": {"type": "string"},
|
||||||
|
"guild_node_id": {"type": "string"},
|
||||||
|
"channel_id": {"type": "string"},
|
||||||
|
"op": {"type": "string", "enum": ["get","share","update","remove"]},
|
||||||
|
"title": {"type": "string"},
|
||||||
|
"format": {"type": "string", "enum": ["md","html","text"]},
|
||||||
|
"source": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["agent_id", "guild_node_id", "channel_id", "op"]
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,29 @@ for f in sorted(os.listdir('${CHANNELS_DIR}')):
|
|||||||
print(json.dumps(out))
|
print(json.dumps(out))
|
||||||
")
|
")
|
||||||
fi
|
fi
|
||||||
|
# Tools list: kept in sync with internal/tools/contracts.go. Each
|
||||||
|
# entry mirrors a ToolContract in allContracts.
|
||||||
|
TOOLS_JSON='[
|
||||||
|
{"name":"send","description":"Outbound channel reply — host calls this after an inbound dispatch.",
|
||||||
|
"inputSchema":{"type":"object","properties":{"channel_name":{"type":"string"},"session_id":{"type":"string"},"message":{"type":"string"}},"required":["channel_name","message"]}},
|
||||||
|
{"name":"fabric-send-message","description":"Post a normal message to a Fabric channel as the bound agent.",
|
||||||
|
"inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"channel_id":{"type":"string"},"content":{"type":"string"}},"required":["agent_id","channel_id","content"]}},
|
||||||
|
{"name":"fabric-send-sys-msg","description":"Post a system-kind message (not subject to turn engine / wakeup).",
|
||||||
|
"inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"channel_id":{"type":"string"},"content":{"type":"string"}},"required":["agent_id","guild_node_id","channel_id","content"]}},
|
||||||
|
{"name":"fabric-channel-list","description":"List channels visible to the agent in a guild.",
|
||||||
|
"inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"}},"required":["agent_id","guild_node_id"]}},
|
||||||
|
{"name":"fabric-guild-list","description":"List the guilds the agent belongs to.",
|
||||||
|
"inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"}},"required":["agent_id"]}},
|
||||||
|
{"name":"fabric-message-history","description":"Paginate channel messages by seq.",
|
||||||
|
"inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"channel_id":{"type":"string"},"seq_from":{"type":"integer"},"seq_to":{"type":"integer"},"limit":{"type":"integer"}},"required":["agent_id","guild_node_id","channel_id"]}},
|
||||||
|
{"name":"fabric-channel-set-purpose","description":"Update the channel'"'"'s free-form purpose text.",
|
||||||
|
"inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"channel_id":{"type":"string"},"purpose":{"type":"string"}},"required":["agent_id","guild_node_id","channel_id","purpose"]}},
|
||||||
|
{"name":"fabric-channel","description":"Fetch metadata + member list for a specific channel.",
|
||||||
|
"inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"channel_id":{"type":"string"}},"required":["agent_id","guild_node_id","channel_id"]}},
|
||||||
|
{"name":"fabric-canvas","description":"Read or write the channel canvas (op: get|share|update|remove).",
|
||||||
|
"inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"channel_id":{"type":"string"},"op":{"type":"string","enum":["get","share","update","remove"]},"title":{"type":"string"},"format":{"type":"string","enum":["md","html","text"]},"source":{"type":"string"}},"required":["agent_id","guild_node_id","channel_id","op"]}}
|
||||||
|
]'
|
||||||
|
|
||||||
cat > "${MANIFEST_PATH}" <<EOF
|
cat > "${MANIFEST_PATH}" <<EOF
|
||||||
{
|
{
|
||||||
"name": "plexum-fabric-channel",
|
"name": "plexum-fabric-channel",
|
||||||
@@ -77,21 +100,7 @@ print(json.dumps(out))
|
|||||||
"executable": "plexum-fabric-channel-plugin",
|
"executable": "plexum-fabric-channel-plugin",
|
||||||
"contracts": {
|
"contracts": {
|
||||||
"channels": ${CHANNELS_JSON},
|
"channels": ${CHANNELS_JSON},
|
||||||
"tools": [
|
"tools": ${TOOLS_JSON}
|
||||||
{
|
|
||||||
"name": "send",
|
|
||||||
"description": "Post a plain-text message to the bound Fabric channel as the agent user.",
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"channel_name": {"type": "string"},
|
|
||||||
"session_id": {"type": "string"},
|
|
||||||
"message": {"type": "string"}
|
|
||||||
},
|
|
||||||
"required": ["channel_name", "message"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
Reference in New Issue
Block a user