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:
h z
2026-05-31 15:40:09 +01:00
parent d6bea46d00
commit ed3676ebc8
6 changed files with 571 additions and 3 deletions

View File

@@ -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"]
}`),
}
}

View File

@@ -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() {