diff --git a/cmd/plexum-fabric-channel-plugin/main.go b/cmd/plexum-fabric-channel-plugin/main.go index 49e2f34..5112239 100644 --- a/cmd/plexum-fabric-channel-plugin/main.go +++ b/cmd/plexum-fabric-channel-plugin/main.go @@ -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/.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 diff --git a/internal/fabric/client.go b/internal/fabric/client.go index 208296a..c7b2a1b 100644 --- a/internal/fabric/client.go +++ b/internal/fabric/client.go @@ -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//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"` diff --git a/internal/inbound/inbound.go b/internal/inbound/inbound.go index f24ee5b..aa263d3 100644 --- a/internal/inbound/inbound.go +++ b/internal/inbound/inbound.go @@ -32,10 +32,19 @@ import ( // that arrived without a push event. Matches openclaw's 60s. const ChannelSyncInterval = 60 * time.Second -// ReconnectBackoff is the wait between reconnect attempts when a -// socket drops + Connect returns. Keep small for snappier recovery; -// exponential backoff is a future improvement. -const ReconnectBackoff = 3 * time.Second +// ReconnectBackoffInitial / Max / Factor drive exponential backoff +// between reconnect attempts. Starts at 1s, doubles up to 60s; resets +// on successful connect (signalled by connectOnce returning after at +// 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 // 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) -// 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) { logger := s.Logger.With("agent", agentID, "guild", guild.NodeID) + backoff := ReconnectBackoffInitial for { if err := ctx.Err(); err != nil { return } + startedAt := time.Now() err := s.connectOnce(ctx, agentID, guild, logger) if ctx.Err() != nil { 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)" if err != nil { errStr = err.Error() } - logger.Warn("inbound: socket connection ended; reconnecting", "err", errStr) + logger.Warn("inbound: socket ended; reconnecting", + "err", errStr, "backoff", backoff.String()) select { - case <-time.After(ReconnectBackoff): + case <-time.After(backoff): case <-ctx.Done(): return } + backoff = time.Duration(float64(backoff) * ReconnectBackoffFactor) + if backoff > ReconnectBackoffMax { + backoff = ReconnectBackoffMax + } } } diff --git a/internal/tools/contracts.go b/internal/tools/contracts.go new file mode 100644 index 0000000..8b47016 --- /dev/null +++ b/internal/tools/contracts.go @@ -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"] + }`), + }, +} diff --git a/internal/tools/tools.go b/internal/tools/tools.go new file mode 100644 index 0000000..6d38a20 --- /dev/null +++ b/internal/tools/tools.go @@ -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 + } + } +} diff --git a/scripts/install.sh b/scripts/install.sh index 367458d..f21f0e0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -69,6 +69,29 @@ for f in sorted(os.listdir('${CHANNELS_DIR}')): print(json.dumps(out)) ") 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}" <