feat(plugin): fabric-guild-list + fabric-channel-set-purpose tools + purpose on existing tools
Adds two agent-facing tools that close the discoverability loop:
- fabric-guild-list — enumerates guilds the agent belongs to with
name + purpose + status (no api calls beyond the existing agentLogin
response). Optional nameFilter/purposeFilter for narrowing.
- fabric-channel-set-purpose — PATCH /api/channels/:id { purpose }
so agents can backfill or update an existing channel's purpose.
Extends existing tools:
- fabric-channel-list now returns purpose on each row.
- create-{chat,work,report,discussion}-channel accept optional purpose.
FabricClient + FabricSession type changes carry the new field through.
Manifest contracts.tools updated (jiti loader needs both manifest entry
and onStartup activation to register).
Lets workflows that previously needed hardcoded channel ids instead say
'find a guild whose purpose mentions debate, then a channel of x_type
announce whose purpose covers public debate broadcasts.'
This commit is contained in:
258
dist/fabric/src/tools.js
vendored
258
dist/fabric/src/tools.js
vendored
@@ -25,7 +25,9 @@ export function registerFabricTools(api, client, identity) {
|
||||
// or via static config (channels.fabric.accounts.<agentId>).
|
||||
const makeCreate = (kind) => api.registerTool((ctx) => ({
|
||||
name: `create-${kind}-channel`,
|
||||
description: `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}).`,
|
||||
description: `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}). ` +
|
||||
'Optionally pass `purpose` to describe what this channel is for — ' +
|
||||
'agents browse channels by purpose via fabric-channel-list.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -37,6 +39,13 @@ export function registerFabricTools(api, client, identity) {
|
||||
memberUserIds: { type: 'array', items: { type: 'string' } },
|
||||
onDuty: { type: 'string', description: 'required for triage-like flows (unused for these kinds)' },
|
||||
listeners: { type: 'array', items: { type: 'string' } },
|
||||
purpose: {
|
||||
type: 'string',
|
||||
description: "Free-form description of what this channel is for. Optional but " +
|
||||
'strongly recommended so other agents can find this channel by ' +
|
||||
'intent (via fabric-channel-list). Can be edited later with ' +
|
||||
'fabric-channel-set-purpose.',
|
||||
},
|
||||
},
|
||||
},
|
||||
execute: async (p) => {
|
||||
@@ -50,6 +59,7 @@ export function registerFabricTools(api, client, identity) {
|
||||
xType: X_BY_KIND[kind],
|
||||
isPublic: p.isPublic ?? false,
|
||||
memberUserIds: p.memberUserIds ?? [],
|
||||
...(p.purpose !== undefined ? { purpose: p.purpose } : {}),
|
||||
});
|
||||
return { ok: true, channelId: ch.id };
|
||||
},
|
||||
@@ -202,4 +212,250 @@ export function registerFabricTools(api, client, identity) {
|
||||
}
|
||||
},
|
||||
}));
|
||||
// -----------------------------------------------------------------
|
||||
// fabric-send-message: post a message into a specific channel.
|
||||
//
|
||||
// Unlike a normal channel reply (which goes back to whatever channel
|
||||
// woke the agent), this lets the agent proactively initiate text into
|
||||
// any channel they are a member of — e.g. ARD broadcasting daily
|
||||
// workload to #agents-room, or triage agent following up on an
|
||||
// already-routed task by commenting in #updates.
|
||||
// -----------------------------------------------------------------
|
||||
api.registerTool((ctx) => ({
|
||||
name: 'fabric-send-message',
|
||||
description: 'Send a text message into a specific Fabric channel. Author is the calling agent. ' +
|
||||
'Requires guildNodeId + channelId + content. Returns {ok, messageId, seq}.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['guildNodeId', 'channelId', 'content'],
|
||||
properties: {
|
||||
guildNodeId: { type: 'string' },
|
||||
channelId: { type: 'string' },
|
||||
content: { type: 'string', description: 'Message body (markdown supported by the renderer).' },
|
||||
},
|
||||
},
|
||||
execute: async (p) => {
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId)
|
||||
return { ok: false, error: 'no agent context' };
|
||||
const { session, guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
||||
const res = (await client.postMessage(guild.endpoint, token, p.channelId, p.content, session.user.id));
|
||||
return { ok: true, messageId: res.messageId, seq: res.seq };
|
||||
},
|
||||
}));
|
||||
// -----------------------------------------------------------------
|
||||
// fabric-channel-list: enumerate channels the calling agent can see
|
||||
// in a given guild. Backend filters to public channels + channels the
|
||||
// agent is a member of. Returns id / name / xType per channel so the
|
||||
// agent can pick a channelId for fabric-send-message etc.
|
||||
// -----------------------------------------------------------------
|
||||
api.registerTool((ctx) => ({
|
||||
name: 'fabric-channel-list',
|
||||
description: 'List channels visible to the calling agent in a guild. Optional ' +
|
||||
'nameFilter does a case-insensitive substring match client-side. ' +
|
||||
'Use this to find a channelId before fabric-send-message / fabric-message-history.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['guildNodeId'],
|
||||
properties: {
|
||||
guildNodeId: { type: 'string' },
|
||||
nameFilter: { type: 'string', description: 'optional substring match on channel name (case-insensitive)' },
|
||||
xType: {
|
||||
type: 'string',
|
||||
enum: ['general', 'work', 'report', 'discuss', 'triage', 'custom', 'dm'],
|
||||
description: 'optional filter by x_type',
|
||||
},
|
||||
includeClosed: { type: 'boolean', description: 'default false — closed channels filtered out' },
|
||||
},
|
||||
},
|
||||
execute: async (p) => {
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId)
|
||||
return { ok: false, error: 'no agent context' };
|
||||
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
||||
const all = await client.listChannels(guild.endpoint, token, p.guildNodeId);
|
||||
const needle = (p.nameFilter ?? '').toLowerCase();
|
||||
const filtered = all.filter((c) => {
|
||||
if (!p.includeClosed && c.closed)
|
||||
return false;
|
||||
if (p.xType && c.xType !== p.xType)
|
||||
return false;
|
||||
if (needle && !c.name.toLowerCase().includes(needle))
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
count: filtered.length,
|
||||
channels: filtered.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
xType: c.xType,
|
||||
isPublic: c.isPublic,
|
||||
closed: c.closed,
|
||||
lastSeq: c.lastSeq,
|
||||
purpose: c.purpose ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
}));
|
||||
// -----------------------------------------------------------------
|
||||
// fabric-guild-list: enumerate guilds the calling agent belongs to.
|
||||
// Each row carries `purpose` — free-form description of what the
|
||||
// guild is for (admin-set). Use this as the first step when a
|
||||
// workflow says "find the right guild for X" — pick by purpose,
|
||||
// then fabric-channel-list to find the right channel inside it.
|
||||
// -----------------------------------------------------------------
|
||||
api.registerTool((ctx) => ({
|
||||
name: 'fabric-guild-list',
|
||||
description: 'List guilds the calling agent is a member of. Returns ' +
|
||||
'{nodeId, name, purpose, status} per row. `purpose` is a free-form ' +
|
||||
"description of what each guild is for. Use this BEFORE " +
|
||||
'fabric-channel-list when a workflow asks you to pick the ' +
|
||||
'right guild by intent (no guild ids hardcoded into workflows).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
nameFilter: {
|
||||
type: 'string',
|
||||
description: 'optional case-insensitive substring match on guild name',
|
||||
},
|
||||
purposeFilter: {
|
||||
type: 'string',
|
||||
description: 'optional case-insensitive substring match on guild purpose ' +
|
||||
'(e.g. "debate", "announcements")',
|
||||
},
|
||||
},
|
||||
},
|
||||
execute: async (p) => {
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId)
|
||||
return { ok: false, error: 'no agent context' };
|
||||
const entry = identity.findByAgentId(agentId);
|
||||
if (!entry)
|
||||
return { ok: false, error: `agent ${agentId} not registered` };
|
||||
const session = await client.agentLogin(entry.fabricApiKey);
|
||||
const nameNeedle = (p.nameFilter ?? '').toLowerCase();
|
||||
const purposeNeedle = (p.purposeFilter ?? '').toLowerCase();
|
||||
const guilds = session.guilds.filter((g) => {
|
||||
if (nameNeedle && !g.name.toLowerCase().includes(nameNeedle))
|
||||
return false;
|
||||
if (purposeNeedle) {
|
||||
const purp = (g.purpose ?? '').toLowerCase();
|
||||
if (!purp.includes(purposeNeedle))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
count: guilds.length,
|
||||
guilds: guilds.map((g) => ({
|
||||
nodeId: g.nodeId,
|
||||
name: g.name,
|
||||
status: g.status,
|
||||
purpose: g.purpose ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
}));
|
||||
// -----------------------------------------------------------------
|
||||
// fabric-channel-set-purpose: set/update a channel's free-form
|
||||
// purpose description. Caller must be a channel member (or the
|
||||
// channel must be public). Use this to backfill purpose on existing
|
||||
// channels, or to refine it after a channel's role evolves.
|
||||
// -----------------------------------------------------------------
|
||||
api.registerTool((ctx) => ({
|
||||
name: 'fabric-channel-set-purpose',
|
||||
description: "Set or update a channel's free-form purpose description. " +
|
||||
'Channel membership required (or the channel must be public). ' +
|
||||
'Pass empty string to clear. Use this to make a channel ' +
|
||||
'discoverable to other agents via fabric-channel-list.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['guildNodeId', 'channelId', 'purpose'],
|
||||
properties: {
|
||||
guildNodeId: { type: 'string' },
|
||||
channelId: { type: 'string' },
|
||||
purpose: {
|
||||
type: 'string',
|
||||
description: "What this channel is for. Pass '' (empty string) to clear.",
|
||||
},
|
||||
},
|
||||
},
|
||||
execute: async (p) => {
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId)
|
||||
return { ok: false, error: 'no agent context' };
|
||||
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
||||
const res = await client.setChannelPurpose(guild.endpoint, token, p.channelId, p.purpose);
|
||||
return { ok: true, channel: res };
|
||||
},
|
||||
}));
|
||||
// -----------------------------------------------------------------
|
||||
// fabric-message-history: read a channel's recent message history by
|
||||
// `seq`. Tail-by-default: when `seqFrom`/`seqTo` are omitted, returns
|
||||
// the last `limit` messages (limit defaults to 20, max 200).
|
||||
//
|
||||
// Use cases: catch-up on a channel that was muted while the agent was
|
||||
// gated; verify a previous message went through; lookup recent
|
||||
// duplicates before opening a new task in triage.
|
||||
// -----------------------------------------------------------------
|
||||
api.registerTool((ctx) => ({
|
||||
name: 'fabric-message-history',
|
||||
description: "Read a channel's recent message history. Omit seqFrom/seqTo to " +
|
||||
'tail (last `limit` messages, default 20, max 200). Backend ' +
|
||||
'requires the calling agent to be a channel participant.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['guildNodeId', 'channelId'],
|
||||
properties: {
|
||||
guildNodeId: { type: 'string' },
|
||||
channelId: { type: 'string' },
|
||||
seqFrom: { type: 'integer', minimum: 1, description: 'inclusive lower bound; default = tail' },
|
||||
seqTo: { type: 'integer', minimum: 1, description: 'inclusive upper bound; default = channel head' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 200, description: 'default 20' },
|
||||
},
|
||||
},
|
||||
execute: async (p) => {
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId)
|
||||
return { ok: false, error: 'no agent context' };
|
||||
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
||||
const limit = p.limit ?? 20;
|
||||
// Tail mode: discover channel head via channel listing, then ask
|
||||
// for [head-limit+1, head]. Avoids needing the agent to know seq.
|
||||
let seqFrom = p.seqFrom;
|
||||
let seqTo = p.seqTo;
|
||||
if (seqFrom === undefined && seqTo === undefined) {
|
||||
const channels = await client.listChannels(guild.endpoint, token, p.guildNodeId);
|
||||
const ch = channels.find((c) => c.id === p.channelId);
|
||||
const head = ch?.lastSeq ?? 0;
|
||||
seqFrom = Math.max(1, head - limit + 1);
|
||||
seqTo = head;
|
||||
}
|
||||
const res = await client.listMessages(guild.endpoint, token, p.channelId, {
|
||||
seqFrom,
|
||||
seqTo,
|
||||
limit,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
page: res.page,
|
||||
messages: res.items.map((m) => ({
|
||||
messageId: m.messageId,
|
||||
seq: m.seq,
|
||||
authorUserId: m.authorUserId,
|
||||
content: m.content,
|
||||
createdAt: m.createdAt,
|
||||
isDeleted: m.isDeleted,
|
||||
})),
|
||||
};
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user