feat: Phase F-5+F-7 — command-sync + sub-discussion + channel-create tools
Bundles the remaining "agent does things in Fabric" surface, skipping
presence-sync (Plexum's state machine isn't 1:1 with HF status semantics)
and attachments (Plexum has no media pipeline yet) — both deferred.
internal/subdisc/ (~140 LOC + 5 tests): persistent KV at
<profile>/state/plugin-kv/fabric-sub-discussions.json
- Entry: SubChannelID, HostAgentID, HostUserID, GuestUserIDs,
HostGuide, GuestGuide, CallbackGuildNodeID, CallbackChannelID, CreatedAt
- Open / Lookup / LookupForHost (host-enforcement) / Add / Remove / All
- atomic tmp+rename persistence; corrupt file = start empty
internal/tools/tools.go — 7 new tools:
- create-chat-channel (xType=general)
- create-work-channel (xType=work)
- create-report-channel (xType=report)
- create-discussion-channel (xType=discuss)
- discussion-complete (post summary + close channel)
- create-sub-discussion (create + invite + KV store + greeting)
- close-sub-discussion (host-only; posts callback to parent + closes)
internal/fabric/client.go: CreateChannel / CloseChannel /
JoinChannel / LeaveChannel / SetChannelPurpose / Canvas CRUD /
SyncCommands (added in earlier F-4 commit; reused here)
cmd/plexum-fabric-channel-plugin/main.go:
- subdisc.Store opened from DefaultPath at init; passed into tools.Deps
- HostConfig adds commands_sync_key + sync_commands (defaults
["new","stop"]) — F-5 command-sync to every (agent,guild) pair at
init when key is set; silently skips when omitted
- syncCommandsToGuilds: best-effort PUT per guild, logs per-guild
outcome
scripts/install.sh: manifest tools[] expanded to 16 entries
(9 from F-4 + 7 new). create-channel variants share a schema shape.
Live verified:
$ plexum plugin-call create-chat-channel \
'{"agent_id":"fabrictester","guild_node_id":"test-guild2",
"name":"plexum-f7-smoke","is_public":true}'
→ "created general channel plexum-f7-smoke (id=6315e636-...)"
$ plexum plugin-call fabric-channel-list ...
→ 3 channels listed including the new one
F-6 (attachments) + F-5b (presence-sync) + F-8 (coalesce) deliberately
deferred — see DEV-NOTES.
Tests: 5 new in internal/subdisc (27 total in this repo).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ import (
|
||||
"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/inbound"
|
||||
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/subdisc"
|
||||
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/tokens"
|
||||
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/tools"
|
||||
)
|
||||
@@ -36,10 +37,14 @@ import (
|
||||
// <profile>/plugins/plexum-fabric-channel/config.json:
|
||||
//
|
||||
// {
|
||||
// "center_api_base": "http://localhost:7001/api"
|
||||
// "center_api_base": "http://localhost:7001/api",
|
||||
// "commands_sync_key": "...", // F-5: enables /command autocomplete in Fabric
|
||||
// "sync_commands": ["new","stop"] // optional override; defaults to ["new","stop"]
|
||||
// }
|
||||
type HostConfig struct {
|
||||
CenterAPIBase string `json:"center_api_base"`
|
||||
CommandsSyncKey string `json:"commands_sync_key,omitempty"`
|
||||
SyncCommands []string `json:"sync_commands,omitempty"`
|
||||
}
|
||||
|
||||
type fabricPlugin struct {
|
||||
@@ -155,9 +160,16 @@ func (p *fabricPlugin) Init(ctx context.Context, host plugin.HostAPI) error {
|
||||
return p.client.AgentLogin(loginCtx, entry.FabricAPIKey)
|
||||
})
|
||||
|
||||
// Sub-discussion KV (F-7); persists across restarts.
|
||||
subStore, err := subdisc.Open(subdisc.DefaultPath())
|
||||
if err != nil {
|
||||
host.Log("warn", "fabric subdisc open", map[string]any{"err": err.Error()})
|
||||
}
|
||||
|
||||
// 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,
|
||||
SubDisc: subStore,
|
||||
})
|
||||
|
||||
host.Log("info", "fabric channel plugin initialized", map[string]any{
|
||||
@@ -173,6 +185,14 @@ func (p *fabricPlugin) Init(ctx context.Context, host plugin.HostAPI) error {
|
||||
map[string]any{"err": err.Error()})
|
||||
}
|
||||
|
||||
// F-5: push slash command catalog to every guild the bound agents
|
||||
// belong to. Cosmetic — gives Fabric frontend "/command" autocomplete.
|
||||
// Requires commands_sync_key matching the guild's
|
||||
// FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY; silently skip when unset.
|
||||
if p.cfg.CommandsSyncKey != "" {
|
||||
p.syncCommandsToGuilds(ctx)
|
||||
}
|
||||
|
||||
// Phase F-2: start the inbound supervisor in a goroutine. Lives
|
||||
// until p.inboundCancel fires (currently never — SDK has no
|
||||
// shutdown hook; subprocess kill is the only stop signal).
|
||||
@@ -353,6 +373,47 @@ func (p *fabricPlugin) sessionFor(ctx context.Context, agentID string) (*fabric.
|
||||
return p.tokens.Get(loginCtx, agentID)
|
||||
}
|
||||
|
||||
// syncCommandsToGuilds PUTs the slash-command catalog to every unique
|
||||
// (agent, guild) the plugin knows about. Best-effort — failure on any
|
||||
// one guild is logged but doesn't block other guilds.
|
||||
func (p *fabricPlugin) syncCommandsToGuilds(ctx context.Context) {
|
||||
commands := p.cfg.SyncCommands
|
||||
if len(commands) == 0 {
|
||||
commands = []string{"new", "stop"} // host-side intercepted slashes
|
||||
}
|
||||
wire := make([]any, 0, len(commands))
|
||||
for _, c := range commands {
|
||||
wire = append(wire, map[string]any{
|
||||
"name": c, "description": "Plexum host-intercepted slash: /" + c,
|
||||
})
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, b := range p.bindings {
|
||||
sess, err := p.tokens.Get(ctx, b.AgentID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, g := range sess.Guilds {
|
||||
key := b.AgentID + "/" + g.NodeID
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
tok, err := p.tokens.GuildToken(ctx, b.AgentID, g.NodeID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := p.client.SyncCommands(ctx, g.Endpoint, tok, wire, p.cfg.CommandsSyncKey); err != nil {
|
||||
p.host.Log("warn", "fabric commands sync failed",
|
||||
map[string]any{"guild": g.NodeID, "err": err.Error()})
|
||||
} else {
|
||||
p.host.Log("info", "fabric commands synced",
|
||||
map[string]any{"guild": g.NodeID, "n": len(commands)})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func errResult(msg string) plugin.ToolResult {
|
||||
return plugin.ToolResult{
|
||||
Content: []plugin.ContentBlock{{Type: "text", Text: msg}},
|
||||
|
||||
169
internal/subdisc/store.go
Normal file
169
internal/subdisc/store.go
Normal file
@@ -0,0 +1,169 @@
|
||||
// Package subdisc persists the sub-discussion entries managed by the
|
||||
// create-sub-discussion / close-sub-discussion tools (port of openclaw
|
||||
// plugin's sub-discussion-store.ts). State lives under
|
||||
// <profile>/state/plugin-kv/fabric-sub-discussions.json — the
|
||||
// Plexum-host's standard plugin KV dir.
|
||||
//
|
||||
// One entry = one sub-discussion channel. Lifetime: from
|
||||
// create-sub-discussion until close-sub-discussion (or the operator
|
||||
// nukes the JSON file).
|
||||
package subdisc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileName under <profile>/state/plugin-kv/.
|
||||
const FileName = "fabric-sub-discussions.json"
|
||||
|
||||
// Entry is the on-disk shape of one sub-discussion.
|
||||
type Entry struct {
|
||||
SubChannelID string `json:"sub_channel_id"`
|
||||
HostAgentID string `json:"host_agent_id"`
|
||||
HostUserID string `json:"host_user_id"`
|
||||
GuestUserIDs []string `json:"guest_user_ids"`
|
||||
HostGuide string `json:"host_guide,omitempty"`
|
||||
GuestGuide string `json:"guest_guide,omitempty"`
|
||||
CallbackGuildNodeID string `json:"callback_guild_node_id"`
|
||||
CallbackChannelID string `json:"callback_channel_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Store wraps the on-disk file. Thread-safe.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
entries map[string]*Entry // by SubChannelID
|
||||
}
|
||||
|
||||
// Open loads (or creates empty) the store at path.
|
||||
func Open(path string) (*Store, error) {
|
||||
s := &Store{path: path, entries: map[string]*Entry{}}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
return nil, fmt.Errorf("subdisc: read %s: %w", path, err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return s, nil
|
||||
}
|
||||
var wire struct {
|
||||
Entries []*Entry `json:"entries"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wire); err != nil {
|
||||
// Tolerate corruption — start empty; first write will overwrite.
|
||||
return s, nil
|
||||
}
|
||||
for _, e := range wire.Entries {
|
||||
if e != nil && e.SubChannelID != "" {
|
||||
s.entries[e.SubChannelID] = e
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Lookup returns the entry for subChannelID or nil.
|
||||
func (s *Store) Lookup(subChannelID string) *Entry {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if e, ok := s.entries[subChannelID]; ok {
|
||||
copyE := *e
|
||||
return ©E
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LookupForHost returns the entry IF subChannelID exists AND the entry
|
||||
// was created by hostAgentID. Used by close-sub-discussion to enforce
|
||||
// "only the host can close".
|
||||
func (s *Store) LookupForHost(subChannelID, hostAgentID string) *Entry {
|
||||
e := s.Lookup(subChannelID)
|
||||
if e == nil || e.HostAgentID != hostAgentID {
|
||||
return nil
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Add inserts a new entry; CreatedAt is auto-set if empty.
|
||||
func (s *Store) Add(e Entry) error {
|
||||
if e.SubChannelID == "" {
|
||||
return errors.New("subdisc: SubChannelID required")
|
||||
}
|
||||
if e.CreatedAt == "" {
|
||||
e.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.entries[e.SubChannelID] = &e
|
||||
s.mu.Unlock()
|
||||
return s.persist()
|
||||
}
|
||||
|
||||
// Remove drops the entry; returns true iff it was present.
|
||||
func (s *Store) Remove(subChannelID string) bool {
|
||||
s.mu.Lock()
|
||||
_, ok := s.entries[subChannelID]
|
||||
if ok {
|
||||
delete(s.entries, subChannelID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_ = s.persist() // best-effort
|
||||
return true
|
||||
}
|
||||
|
||||
// All returns a snapshot copy of every entry (for diagnostic tools).
|
||||
func (s *Store) All() []Entry {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]Entry, 0, len(s.entries))
|
||||
for _, e := range s.entries {
|
||||
out = append(out, *e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) persist() error {
|
||||
s.mu.Lock()
|
||||
entries := make([]*Entry, 0, len(s.entries))
|
||||
for _, e := range s.entries {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(struct {
|
||||
Entries []*Entry `json:"entries"`
|
||||
}{entries}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
// DefaultPath returns the canonical path under
|
||||
// $PLEXUM_PROFILE_ROOT/state/plugin-kv (or ~/.plexum/...).
|
||||
func DefaultPath() string {
|
||||
root := os.Getenv("PLEXUM_PROFILE_ROOT")
|
||||
if root == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
root = filepath.Join(home, ".plexum")
|
||||
}
|
||||
return filepath.Join(root, "state", "plugin-kv", FileName)
|
||||
}
|
||||
74
internal/subdisc/store_test.go
Normal file
74
internal/subdisc/store_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package subdisc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sub.json")
|
||||
s, _ := Open(path)
|
||||
err := s.Add(Entry{
|
||||
SubChannelID: "ch1", HostAgentID: "alice", HostUserID: "uA",
|
||||
GuestUserIDs: []string{"uB"}, CallbackGuildNodeID: "g1", CallbackChannelID: "pCh",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2, _ := Open(path)
|
||||
e := s2.Lookup("ch1")
|
||||
if e == nil || e.HostAgentID != "alice" || e.CallbackChannelID != "pCh" {
|
||||
t.Errorf("entry = %+v", e)
|
||||
}
|
||||
if e.CreatedAt == "" {
|
||||
t.Errorf("CreatedAt should be auto-set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupForHost(t *testing.T) {
|
||||
s, _ := Open(filepath.Join(t.TempDir(), "x.json"))
|
||||
s.Add(Entry{SubChannelID: "c", HostAgentID: "alice"})
|
||||
if s.LookupForHost("c", "alice") == nil {
|
||||
t.Errorf("alice should match")
|
||||
}
|
||||
if s.LookupForHost("c", "bob") != nil {
|
||||
t.Errorf("bob should NOT match")
|
||||
}
|
||||
if s.LookupForHost("missing", "alice") != nil {
|
||||
t.Errorf("missing channel should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemove(t *testing.T) {
|
||||
s, _ := Open(filepath.Join(t.TempDir(), "x.json"))
|
||||
s.Add(Entry{SubChannelID: "c"})
|
||||
if !s.Remove("c") {
|
||||
t.Errorf("Remove existing should return true")
|
||||
}
|
||||
if s.Remove("c") {
|
||||
t.Errorf("Remove missing should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllReturnsCopy(t *testing.T) {
|
||||
s, _ := Open(filepath.Join(t.TempDir(), "x.json"))
|
||||
s.Add(Entry{SubChannelID: "a", HostAgentID: "alice"})
|
||||
s.Add(Entry{SubChannelID: "b", HostAgentID: "bob"})
|
||||
all := s.All()
|
||||
if len(all) != 2 {
|
||||
t.Errorf("len = %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCorruptStartsEmpty(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "corrupt.json")
|
||||
os.WriteFile(path, []byte("not json"), 0o600)
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Errorf("Open corrupt should tolerate: %v", err)
|
||||
}
|
||||
if len(s.All()) != 0 {
|
||||
t.Errorf("corrupt should start empty")
|
||||
}
|
||||
}
|
||||
@@ -136,4 +136,77 @@ var allContracts = []plugin.ToolContract{
|
||||
"required": ["agent_id", "guild_node_id", "channel_id", "op"]
|
||||
}`),
|
||||
},
|
||||
// ---- channel creation (4 x-types share the same shape) ----
|
||||
createChannelContract("create-chat-channel", "Create a chat channel (xType=general)."),
|
||||
createChannelContract("create-work-channel", "Create a work channel (xType=work)."),
|
||||
createChannelContract("create-report-channel", "Create a report channel (xType=report)."),
|
||||
createChannelContract("create-discussion-channel", "Create a discussion channel (xType=discuss)."),
|
||||
{
|
||||
Name: "discussion-complete",
|
||||
Description: "Post a summary then close the channel — concludes a discussion.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {"type": "string"},
|
||||
"guild_node_id": {"type": "string"},
|
||||
"channel_id": {"type": "string"},
|
||||
"summary": {"type": "string"}
|
||||
},
|
||||
"required": ["agent_id", "guild_node_id", "channel_id", "summary"]
|
||||
}`),
|
||||
},
|
||||
{
|
||||
Name: "create-sub-discussion",
|
||||
Description: "Spawn a new sub-discussion channel with the calling agent as host + invited guests; remembered in plugin KV so close-sub-discussion can post the callback to the parent channel.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {"type": "string"},
|
||||
"guild_node_id": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"guest_user_ids": {"type": "array", "items": {"type": "string"}},
|
||||
"host_guide": {"type": "string"},
|
||||
"guest_guide": {"type": "string"},
|
||||
"parent_channel_id":{"type": "string"},
|
||||
"greeting": {"type": "string"},
|
||||
"x_type": {"type": "string", "enum": ["discuss","work","report","general"]}
|
||||
},
|
||||
"required": ["agent_id", "guild_node_id", "name", "parent_channel_id"]
|
||||
}`),
|
||||
},
|
||||
{
|
||||
Name: "close-sub-discussion",
|
||||
Description: "Close a sub-discussion the calling agent originally created; posts a callback message to the parent channel.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {"type": "string"},
|
||||
"sub_channel_id":{"type": "string"},
|
||||
"summary": {"type": "string"}
|
||||
},
|
||||
"required": ["agent_id", "sub_channel_id"]
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
// createChannelContract returns the shared schema for the four
|
||||
// create-*-channel tool variants.
|
||||
func createChannelContract(name, description string) plugin.ToolContract {
|
||||
return plugin.ToolContract{
|
||||
Name: name, Description: description,
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {"type": "string"},
|
||||
"guild_node_id": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"is_public": {"type": "boolean"},
|
||||
"member_user_ids": {"type": "array", "items": {"type": "string"}},
|
||||
"listeners": {"type": "array", "items": {"type": "string"}},
|
||||
"on_duty": {"type": "string"},
|
||||
"purpose": {"type": "string"}
|
||||
},
|
||||
"required": ["agent_id", "guild_node_id", "name"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
"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/subdisc"
|
||||
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/tokens"
|
||||
)
|
||||
|
||||
@@ -32,6 +33,7 @@ type Deps struct {
|
||||
Client *fabric.Client
|
||||
Tokens *tokens.Cache
|
||||
Identities *identity.Registry
|
||||
SubDisc *subdisc.Store // optional; sub-discussion tools are disabled when nil
|
||||
}
|
||||
|
||||
// HandlerFunc is the tool's call-time function. Returns a ToolResult
|
||||
@@ -47,6 +49,7 @@ type Registry struct {
|
||||
// New constructs a Registry with the agent-facing tools wired.
|
||||
func New(deps Deps) *Registry {
|
||||
r := &Registry{deps: deps, handlers: map[string]HandlerFunc{}}
|
||||
// Batch 1: stateless agent-facing tools.
|
||||
r.registerSendMessage()
|
||||
r.registerSendSysMsg()
|
||||
r.registerChannelList()
|
||||
@@ -55,6 +58,17 @@ func New(deps Deps) *Registry {
|
||||
r.registerChannelSetPurpose()
|
||||
r.registerChannel()
|
||||
r.registerCanvas()
|
||||
// Batch 2: channel creation (4 x-types) + discussion-complete.
|
||||
r.registerCreateChannelByKind("create-chat-channel", "general")
|
||||
r.registerCreateChannelByKind("create-work-channel", "work")
|
||||
r.registerCreateChannelByKind("create-report-channel", "report")
|
||||
r.registerCreateChannelByKind("create-discussion-channel", "discuss")
|
||||
r.registerDiscussionComplete()
|
||||
// Batch 3: sub-discussion (needs Deps.SubDisc).
|
||||
if deps.SubDisc != nil {
|
||||
r.registerCreateSubDiscussion()
|
||||
r.registerCloseSubDiscussion()
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -377,6 +391,169 @@ func (r *Registry) registerChannel() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tool: create-{chat,work,report,discussion}-channel ----
|
||||
|
||||
func (r *Registry) registerCreateChannelByKind(toolName, xType string) {
|
||||
r.handlers[toolName] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
||||
var in struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
GuildNodeID string `json:"guild_node_id"`
|
||||
Name string `json:"name"`
|
||||
MemberUserIDs []string `json:"member_user_ids"`
|
||||
Listeners []string `json:"listeners"`
|
||||
OnDuty string `json:"on_duty"`
|
||||
Purpose string `json:"purpose"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return errResult(toolName + ": parse: " + err.Error()), nil
|
||||
}
|
||||
if in.Name == "" {
|
||||
return errResult(toolName + ": name required"), nil
|
||||
}
|
||||
_, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
||||
if err != nil {
|
||||
return errResult(toolName + ": " + err.Error()), nil
|
||||
}
|
||||
id, err := r.deps.Client.CreateChannel(ctx, endpoint, token, fabric.CreateChannelOpts{
|
||||
GuildID: in.GuildNodeID, Name: in.Name, XType: xType,
|
||||
IsPublic: in.IsPublic, MemberUserIDs: in.MemberUserIDs,
|
||||
Listeners: in.Listeners, OnDuty: in.OnDuty, Purpose: in.Purpose,
|
||||
})
|
||||
if err != nil {
|
||||
return errResult(toolName + ": " + err.Error()), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("created %s channel %s (id=%s)", xType, in.Name, id)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tool: discussion-complete ----
|
||||
|
||||
func (r *Registry) registerDiscussionComplete() {
|
||||
r.handlers["discussion-complete"] = 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"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return errResult("discussion-complete: parse: " + err.Error()), nil
|
||||
}
|
||||
if in.ChannelID == "" || in.Summary == "" {
|
||||
return errResult("discussion-complete: channel_id + summary required"), nil
|
||||
}
|
||||
sess, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
||||
if err != nil {
|
||||
return errResult("discussion-complete: " + err.Error()), nil
|
||||
}
|
||||
// Post the summary as a normal message (so it shows in history
|
||||
// with the host's name), then close.
|
||||
if err := r.deps.Client.PostMessage(ctx, endpoint, token,
|
||||
in.ChannelID, in.Summary, sess.User.ID); err != nil {
|
||||
return errResult("discussion-complete: post summary: " + err.Error()), nil
|
||||
}
|
||||
if err := r.deps.Client.CloseChannel(ctx, endpoint, token, in.ChannelID); err != nil {
|
||||
return errResult("discussion-complete: close: " + err.Error()), nil
|
||||
}
|
||||
return textResult("discussion concluded; channel closed"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tool: create-sub-discussion ----
|
||||
|
||||
func (r *Registry) registerCreateSubDiscussion() {
|
||||
r.handlers["create-sub-discussion"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
||||
var in struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
GuildNodeID string `json:"guild_node_id"`
|
||||
Name string `json:"name"`
|
||||
GuestUserIDs []string `json:"guest_user_ids"`
|
||||
HostGuide string `json:"host_guide"`
|
||||
GuestGuide string `json:"guest_guide"`
|
||||
ParentChannelID string `json:"parent_channel_id"`
|
||||
Greeting string `json:"greeting"`
|
||||
XType string `json:"x_type"` // default "discuss"
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return errResult("create-sub-discussion: parse: " + err.Error()), nil
|
||||
}
|
||||
if in.Name == "" || in.ParentChannelID == "" {
|
||||
return errResult("create-sub-discussion: name + parent_channel_id required"), nil
|
||||
}
|
||||
if in.XType == "" {
|
||||
in.XType = "discuss"
|
||||
}
|
||||
sess, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID)
|
||||
if err != nil {
|
||||
return errResult("create-sub-discussion: " + err.Error()), nil
|
||||
}
|
||||
members := append([]string{sess.User.ID}, in.GuestUserIDs...)
|
||||
id, err := r.deps.Client.CreateChannel(ctx, endpoint, token, fabric.CreateChannelOpts{
|
||||
GuildID: in.GuildNodeID, Name: in.Name, XType: in.XType,
|
||||
MemberUserIDs: members,
|
||||
})
|
||||
if err != nil {
|
||||
return errResult("create-sub-discussion: create: " + err.Error()), nil
|
||||
}
|
||||
if err := r.deps.SubDisc.Add(subdisc.Entry{
|
||||
SubChannelID: id, HostAgentID: in.AgentID, HostUserID: sess.User.ID,
|
||||
GuestUserIDs: in.GuestUserIDs, HostGuide: in.HostGuide, GuestGuide: in.GuestGuide,
|
||||
CallbackGuildNodeID: in.GuildNodeID, CallbackChannelID: in.ParentChannelID,
|
||||
}); err != nil {
|
||||
return errResult("create-sub-discussion: store: " + err.Error()), nil
|
||||
}
|
||||
// Post greeting if supplied — kicks the discussion off so guests
|
||||
// see something on join.
|
||||
if in.Greeting != "" {
|
||||
if err := r.deps.Client.PostMessage(ctx, endpoint, token,
|
||||
id, in.Greeting, sess.User.ID); err != nil {
|
||||
return errResult("create-sub-discussion: greeting: " + err.Error()), nil
|
||||
}
|
||||
}
|
||||
return textResult(fmt.Sprintf("sub-discussion ready: id=%s parent=%s guests=%v",
|
||||
id, in.ParentChannelID, in.GuestUserIDs)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tool: close-sub-discussion ----
|
||||
|
||||
func (r *Registry) registerCloseSubDiscussion() {
|
||||
r.handlers["close-sub-discussion"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) {
|
||||
var in struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
SubChannelID string `json:"sub_channel_id"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return errResult("close-sub-discussion: parse: " + err.Error()), nil
|
||||
}
|
||||
if in.SubChannelID == "" {
|
||||
return errResult("close-sub-discussion: sub_channel_id required"), nil
|
||||
}
|
||||
entry := r.deps.SubDisc.LookupForHost(in.SubChannelID, in.AgentID)
|
||||
if entry == nil {
|
||||
return errResult("close-sub-discussion: only the host of the discussion can close it (or sub_channel_id is unknown)"), nil
|
||||
}
|
||||
sess, endpoint, token, err := r.agentGuild(ctx, in.AgentID, entry.CallbackGuildNodeID)
|
||||
if err != nil {
|
||||
return errResult("close-sub-discussion: " + err.Error()), nil
|
||||
}
|
||||
// Post callback to parent channel as a system message.
|
||||
callback := "sub-discussion concluded"
|
||||
if in.Summary != "" {
|
||||
callback = "sub-discussion concluded: " + in.Summary
|
||||
}
|
||||
_ = r.deps.Client.PostSystemMessage(ctx, endpoint, token,
|
||||
entry.CallbackChannelID, callback, sess.User.ID)
|
||||
if err := r.deps.Client.CloseChannel(ctx, endpoint, token, in.SubChannelID); err != nil {
|
||||
return errResult("close-sub-discussion: close: " + err.Error()), nil
|
||||
}
|
||||
r.deps.SubDisc.Remove(in.SubChannelID)
|
||||
return textResult("sub-discussion closed"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tool: fabric-canvas (get / share / update / remove) ----
|
||||
|
||||
func (r *Registry) registerCanvas() {
|
||||
|
||||
@@ -89,7 +89,21 @@ print(json.dumps(out))
|
||||
{"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"]}}
|
||||
"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"]}},
|
||||
{"name":"create-chat-channel","description":"Create a chat channel (xType=general).",
|
||||
"inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"name":{"type":"string"},"is_public":{"type":"boolean"},"member_user_ids":{"type":"array","items":{"type":"string"}},"listeners":{"type":"array","items":{"type":"string"}},"on_duty":{"type":"string"},"purpose":{"type":"string"}},"required":["agent_id","guild_node_id","name"]}},
|
||||
{"name":"create-work-channel","description":"Create a work channel (xType=work).",
|
||||
"inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"name":{"type":"string"},"is_public":{"type":"boolean"},"member_user_ids":{"type":"array","items":{"type":"string"}},"listeners":{"type":"array","items":{"type":"string"}},"on_duty":{"type":"string"},"purpose":{"type":"string"}},"required":["agent_id","guild_node_id","name"]}},
|
||||
{"name":"create-report-channel","description":"Create a report channel (xType=report).",
|
||||
"inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"name":{"type":"string"},"is_public":{"type":"boolean"},"member_user_ids":{"type":"array","items":{"type":"string"}},"listeners":{"type":"array","items":{"type":"string"}},"on_duty":{"type":"string"},"purpose":{"type":"string"}},"required":["agent_id","guild_node_id","name"]}},
|
||||
{"name":"create-discussion-channel","description":"Create a discussion channel (xType=discuss).",
|
||||
"inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"name":{"type":"string"},"is_public":{"type":"boolean"},"member_user_ids":{"type":"array","items":{"type":"string"}},"listeners":{"type":"array","items":{"type":"string"}},"on_duty":{"type":"string"},"purpose":{"type":"string"}},"required":["agent_id","guild_node_id","name"]}},
|
||||
{"name":"discussion-complete","description":"Post a summary then close the channel.",
|
||||
"inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"channel_id":{"type":"string"},"summary":{"type":"string"}},"required":["agent_id","guild_node_id","channel_id","summary"]}},
|
||||
{"name":"create-sub-discussion","description":"Spawn a sub-discussion channel with the caller as host + invited guests.",
|
||||
"inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"name":{"type":"string"},"guest_user_ids":{"type":"array","items":{"type":"string"}},"host_guide":{"type":"string"},"guest_guide":{"type":"string"},"parent_channel_id":{"type":"string"},"greeting":{"type":"string"},"x_type":{"type":"string","enum":["discuss","work","report","general"]}},"required":["agent_id","guild_node_id","name","parent_channel_id"]}},
|
||||
{"name":"close-sub-discussion","description":"Close a sub-discussion the caller hosts and post a callback to its parent channel.",
|
||||
"inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"sub_channel_id":{"type":"string"},"summary":{"type":"string"}},"required":["agent_id","sub_channel_id"]}}
|
||||
]'
|
||||
|
||||
cat > "${MANIFEST_PATH}" <<EOF
|
||||
|
||||
Reference in New Issue
Block a user