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:
h z
2026-05-23 19:22:10 +01:00
parent 6fe06f55dd
commit 5ff464a055
8 changed files with 746 additions and 6 deletions

32
dist/fabric/index.js vendored
View File

@@ -12,10 +12,16 @@ import { registerFabricTools } from './src/tools.js';
import { FabricClient } from './src/fabric-client.js';
import { IdentityRegistry } from './src/identity.js';
import { syncFabricCommands } from './src/command-sync.js';
import { PresenceSync } from './src/presence-sync.js';
import path from 'node:path';
import os from 'node:os';
let runtimeRef = null;
let inbound = null;
let presence = null;
// Periodic re-harvest of presence accounts so newly-connected agents
// (registered through tool-based identity flow AFTER initial start)
// get picked up. Cleared on gateway_stop.
let presenceRefreshTimer = null;
export { fabricChannelPlugin } from './src/channel.js';
export default defineChannelPluginEntry({
id: 'fabric',
@@ -57,7 +63,25 @@ export default defineChannelPluginEntry({
return;
}
inbound = new FabricInbound(runtimeRef, api.config, client, identity, api.logger, accounts);
void inbound.start();
// start() resolves once all accounts have attempted login; per-
// agent failures are logged but don't reject. Once it resolves we
// can harvest the presence accounts (those that DID log in have
// their fabricUserId + first guild endpoint populated).
void inbound.start().then(() => {
if (!inbound)
return;
presence = new PresenceSync(api.logger);
presence.setAccounts(inbound.getPresenceAccounts());
presence.start();
api.logger.info(`fabric: presence-sync started for ${inbound.getPresenceAccounts().length} account(s)`);
// Re-harvest every 5 min: catches agents added via tool-based
// identity provisioning after gateway_start (recruitment flow).
// setAccounts is idempotent — duplicates collapse on agentId.
presenceRefreshTimer = setInterval(() => {
if (inbound && presence)
presence.setAccounts(inbound.getPresenceAccounts());
}, 5 * 60_000);
});
api.logger.info(`fabric: inbound started for ${accounts.length} account(s)`);
void syncFabricCommands(client, cfg, accounts, api.logger);
});
@@ -67,6 +91,12 @@ export default defineChannelPluginEntry({
// BEFORE deliver()). gateway_stop only flushes any leftover buffer.
api.on('gateway_stop', () => {
void flushAllFabric();
if (presenceRefreshTimer) {
clearInterval(presenceRefreshTimer);
presenceRefreshTimer = null;
}
presence?.stop();
presence = null;
inbound?.stop();
inbound = null;
});

View File

@@ -63,6 +63,11 @@ export class FabricClient {
createChannel(guildEndpoint, guildToken, body) {
return this.post(`${guildEndpoint}/api/channels`, body, guildToken);
}
// PATCH /api/channels/:id — backend currently only patches `purpose`.
// Caller must be a member of the channel (or any user if public).
setChannelPurpose(guildEndpoint, guildToken, channelId, purpose) {
return this.req('PATCH', `${guildEndpoint}/api/channels/${channelId}`, guildToken, { purpose });
}
closeChannel(guildEndpoint, guildToken, channelId) {
return this.post(`${guildEndpoint}/api/channels/${channelId}/close`, {}, guildToken);
}
@@ -105,4 +110,31 @@ export class FabricClient {
removeCanvas(endpoint, token, channelId) {
return this.req('DELETE', this.canvasUrl(endpoint, channelId), token);
}
// ---- channel discovery + message read (used by the agent-facing
// fabric-channel-list / fabric-message-history tools) ----
/**
* List channels in a guild visible to the calling user. Backend
* filters to public + channels the user is a member of.
*/
listChannels(guildEndpoint, guildToken, guildNodeId) {
return this.req('GET', `${guildEndpoint}/api/channels?guildId=${encodeURIComponent(guildNodeId)}`, guildToken);
}
/**
* Page through a channel's message history by `seq`.
*
* Backend defaults: 50 / call, max 200. The `seq` field starts at 1
* per channel; pass `seqFrom=channel.lastSeq - N + 1` to get the
* tail. Page metadata in the response describes what to ask next.
*/
listMessages(guildEndpoint, guildToken, channelId, opts = {}) {
const qs = new URLSearchParams();
if (opts.seqFrom !== undefined)
qs.set('seq_from', String(opts.seqFrom));
if (opts.seqTo !== undefined)
qs.set('seq_to', String(opts.seqTo));
if (opts.limit !== undefined)
qs.set('limit', String(opts.limit));
const url = `${guildEndpoint}/api/channels/${channelId}/messages` + (qs.toString() ? `?${qs}` : '');
return this.req('GET', url, guildToken);
}
}

View File

@@ -28,6 +28,125 @@ export class FabricInbound {
// Re-login per agent on a short TTL to keep a fresh token.
tokenCache = new Map();
static TOKEN_TTL_MS = 8 * 60 * 1000;
// Per-channel serial work queue. Every inbound socket message for a
// channel awaits the previous task for that same channel, so model
// turns never interleave. Map key = channelId; value is the tail of
// the chain (an in-flight promise the next task awaits).
//
// Why per-channel and not per-agent: a single agent may sit in
// several triage / general channels; we want each channel to flow at
// its own speed but the SAME channel's traffic to be strictly serial.
// For dm and discuss the queue also serialises but those traditionally
// had at-most-one-in-flight anyway via the turn engine.
channelChains = new Map();
// Agent.status snapshot cache (5s TTL) — keeps the HF /calendar/
// agent/status round-trip off the hot path for back-to-back triage
// messages. Short TTL because status flips are rare-but-meaningful.
agentStatusCache = new Map();
static AGENT_STATUS_TTL_MS = 5_000;
// Triage messages that arrived while the on-duty agent wasn't on_call
// — sit here until either (a) the agent becomes on_call and the next
// triage arrival drains them, or (b) the gateway restarts (lost; ok
// because the underlying Fabric messages are persisted and re-fetched
// on agent reconnect's history sweep).
pendingTriageGated = [];
// Schedule `task` to run after every previous task on the same
// channel has completed. Returns the promise so callers can await
// their own result if they need to; the chain itself is fire-and-
// forget from the socket.on handler.
enqueueChannelTask(channelId, task) {
const prev = this.channelChains.get(channelId) ?? Promise.resolve();
const next = prev.then(task).catch((err) => {
this.log.warn(`fabric: per-channel task failed channel=${channelId}: ${String(err)}`);
});
this.channelChains.set(channelId, next);
// Best-effort cleanup so the Map doesn't grow without bound for
// long-running gateways: drop the entry when the chain settles, but
// only if it's still the latest reference (newer enqueue may have
// overwritten it in the meantime).
void next.finally(() => {
if (this.channelChains.get(channelId) === next) {
this.channelChains.delete(channelId);
}
});
return next;
}
// Hit HF backend to check whether `agentId` is currently on_call.
// Cached for 5s. Failures (network, 404, etc.) are treated as "not
// on_call" — triage stays gated rather than risking a confused wake.
async checkAgentOnCall(agentId) {
const cached = this.agentStatusCache.get(agentId);
if (cached && Date.now() - cached.at < FabricInbound.AGENT_STATUS_TTL_MS) {
return cached.onCall;
}
const base = (process.env.HF_API_BASE_URL ?? '').trim() || 'https://monitor.hangman-lab.top';
// CLAW_IDENTIFIER resolution priority:
// 1. HF_CLAW_IDENTIFIER env (operator override)
// 2. openclaw config `plugins.harbor-forge.identifier` (what the HF
// plugin itself uses — keeps the two in sync without an extra
// env per service unit)
// 3. os.hostname() last-resort fallback (often wrong: e.g. sim
// container hostname is `server.t2` but HF agent row has
// `claw_identifier=sim-t2`; matching is mandatory for the HF
// backend's _require_agent() check)
let claw = (process.env.HF_CLAW_IDENTIFIER ?? '').trim();
if (!claw) {
try {
// openclaw config shape (verified in sim):
// { plugins: { entries: { 'harbor-forge': { config: { identifier } } } } }
const cfg = this.cfg;
const fromCfg = cfg?.plugins?.entries?.['harbor-forge']?.config?.identifier;
if (fromCfg && typeof fromCfg === 'string' && fromCfg.trim()) {
claw = fromCfg.trim();
}
}
catch {
/* fall through to hostname */
}
}
if (!claw) {
claw = (await import('os')).hostname();
}
let onCall = false;
try {
const url = `${base.replace(/\/$/, '')}/calendar/agent/status?agent_id=${encodeURIComponent(agentId)}`;
const res = await fetch(url, {
headers: { 'X-Agent-ID': agentId, 'X-Claw-Identifier': claw },
});
if (res.ok) {
const data = (await res.json());
onCall = (data.status ?? '').toLowerCase() === 'on_call';
}
}
catch (err) {
this.log.warn(`fabric: HF status check failed agent=${agentId}: ${String(err)}`);
}
this.agentStatusCache.set(agentId, { onCall, at: Date.now() });
return onCall;
}
// FIFO drain of all triage-gated messages for `agentId` (called when
// we just learned they're on_call). Each drained message is dispatched
// through its own channel chain so per-channel serial order is kept.
async drainGatedFor(agentId) {
const keep = [];
const drain = [];
for (const item of this.pendingTriageGated) {
if (item.agentId === agentId)
drain.push(item);
else
keep.push(item);
}
if (drain.length === 0)
return;
this.pendingTriageGated = keep;
for (const item of drain) {
this.log.info(`fabric: triage drain agent=${item.agentId} channel=${item.channelId} msg=${item.m.messageId}`);
// Re-enqueue via the per-channel chain so ordering is preserved.
this.enqueueChannelTask(item.channelId, async () => {
await this.dispatch(item.agentId, item.g, item.channelId, item.m, item.session);
});
}
}
// Return a fresh guild access token for the agent, re-authenticating with
// the agent's Fabric API key when the cached session is stale. Falls back
// to the connect-time session token if re-login fails.
@@ -90,8 +209,48 @@ export class FabricInbound {
s.disconnect();
this.sockets = [];
}
/**
* Per-account metadata harvested during `start()` — used by
* PresenceSync to know where to push each agent's HF status.
*
* `fabricUserId` is filled from `session.user.id` after agent-login.
* `guildBaseUrl` is the FIRST guild the agent is connected to (multi-
* guild presence push is a future concern; for sim/prod-v1 each agent
* is in one guild).
*
* Returns ONLY agents that successfully connected — failed-login
* agents have no fabricUserId yet and are excluded.
*/
getPresenceAccounts() {
const out = [];
for (const entry of this.identity.list()) {
if (!entry.fabricUserId)
continue;
const presenceGuildUrl = this.firstGuildEndpointByAgent.get(entry.agentId);
if (!presenceGuildUrl)
continue;
out.push({
agentId: entry.agentId,
fabricUserId: entry.fabricUserId,
guildBaseUrl: presenceGuildUrl,
fabricApiKey: entry.fabricApiKey,
});
}
return out;
}
// Filled by connectAgent for each (agent, guild). Tracks ONLY the first
// guild per agent (used as the presence-push target).
firstGuildEndpointByAgent = new Map();
async connectAgent(agentId, session) {
const selfUserId = session.user.id;
// First-guild capture for presence-sync push target. session.guilds is
// already in priority order from Center; we take the first one with a
// valid endpoint and stop. Multi-guild presence is a future concern.
if (!this.firstGuildEndpointByAgent.has(agentId)) {
const firstGuild = session.guilds.find((g) => typeof g.endpoint === 'string' && g.endpoint.length > 0);
if (firstGuild)
this.firstGuildEndpointByAgent.set(agentId, firstGuild.endpoint);
}
for (const g of session.guilds) {
const tok = session.guildAccessTokens.find((t) => t.guildNodeId === g.nodeId)?.token;
if (!tok)
@@ -190,7 +349,31 @@ export class FabricInbound {
this.seen.add(key);
if (this.seen.size > 5000)
this.seen.clear();
void this.dispatch(agentId, g, channelId, m, session);
// Per-channel serial queue. Prevents concurrent model turns for
// the same channel — important for triage where a second wake
// arriving mid-reply would interleave with the in-flight one.
this.enqueueChannelTask(channelId, async () => {
// Triage on_call gate: if the on-duty agent isn't currently
// on_call per HF, don't dispatch yet — just sit on the
// per-channel queue. Subsequent triage messages will recheck;
// when the agent becomes on_call, the next arrival drains.
//
// Also handles: triage + wake=true must verify status before
// committing to a model turn. Non-triage and triage observer
// (wake=false) skip the gate.
if (m.xType === 'triage' && m.wakeup === true) {
const onCall = await this.checkAgentOnCall(agentId);
if (!onCall) {
this.log.info(`fabric: triage wake gated (agent=${agentId} not on_call) — re-queue msg=${m.messageId}`);
this.pendingTriageGated.push({ agentId, g, channelId, m, session });
return;
}
// Drain any previously-gated messages (FIFO) before this one,
// now that we know the agent is on_call.
await this.drainGatedFor(agentId);
}
await this.dispatch(agentId, g, channelId, m, session);
});
});
socket.connect();
this.sockets.push(socket);
@@ -281,6 +464,17 @@ export class FabricInbound {
// any message that isn't the agent's own (already filtered above) is
// always delivered to the model.
if (m.xType !== 'dm' && m.wakeup !== true) {
// Triage exception: non-wake messages (admin observer) MUST NOT
// enter the agent's session at all. The next time the agent
// wakes for a triage message, their context should contain only
// their own past wakeups + their own outgoing messages — never
// the observer-only chatter from other agents. For non-triage
// channels keep the legacy "record-as-history" so a later wake
// sees the full channel conversation.
if (m.xType === 'triage') {
this.log.info(`fabric: triage observer skip agent=${agentId} channel=${channelId} msg=${m.messageId}`);
return;
}
const ctxPayload = core.channel.reply.finalizeInboundContext(baseCtx);
await core.channel.session.recordInboundSession({
storePath,

84
dist/fabric/src/presence-sync.js vendored Normal file
View File

@@ -0,0 +1,84 @@
/**
* presence-sync — read each connected agent's HF status (via the
* cross-plugin `globalThis.__hfAgentStatus.get(agentId)` exposed by
* HarborForge.OpenclawPlugin) and push diffs to Fabric.Backend.Guild
* `PUT /agents/:userId/presence` so the backend can apply busy-discard
* on `announce`-type channel deliveries.
*
* Push model: we only PUT when an agent's status actually changes
* (since the last push). The HF-side accessor has its own TTL cache
* to absorb the every-30s polling.
*
* If HF plugin isn't loaded (`__hfAgentStatus` undefined), the loop
* is a no-op — Fabric backend defaults presence to 'unknown' which is
* treated as not-busy. Announce-channel delivery still works; busy
* filtering simply doesn't kick in.
*/
export class PresenceSync {
logger;
timer = null;
lastStatus = new Map(); // by agentId
accounts = new Map();
constructor(logger) {
this.logger = logger;
}
setAccounts(accounts) {
this.accounts.clear();
for (const a of accounts)
this.accounts.set(a.agentId, a);
}
start(intervalMs = 30_000) {
if (this.timer)
return;
this.timer = setInterval(() => {
this.tick().catch((err) => this.logger.warn(`fabric: presence-sync error: ${String(err)}`));
}, intervalMs);
// run once immediately so initial state lands fast
void this.tick();
}
stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
async tick() {
const bridge = globalThis['__hfAgentStatus'];
if (!bridge || typeof bridge.get !== 'function')
return; // HF plugin not loaded — skip
for (const [agentId, acct] of this.accounts) {
let status;
try {
status = await bridge.get(agentId);
}
catch {
continue;
}
if (!status)
continue;
if (this.lastStatus.get(agentId) === status)
continue; // no change → no PUT
try {
const url = `${acct.guildBaseUrl.replace(/\/$/, '')}/agents/${encodeURIComponent(acct.fabricUserId)}/presence`;
const res = await fetch(url, {
method: 'PUT',
headers: {
'content-type': 'application/json',
'x-api-key': acct.fabricApiKey,
},
body: JSON.stringify({ status, source: 'hf-plugin' }),
});
if (res.ok) {
this.lastStatus.set(agentId, status);
this.logger.info(`fabric: presence-sync ${agentId}${status}`);
}
else {
this.logger.warn(`fabric: presence-sync PUT ${agentId} failed: ${res.status}`);
}
}
catch (err) {
this.logger.warn(`fabric: presence-sync PUT ${agentId} threw: ${String(err)}`);
}
}
}
}

View File

@@ -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,
})),
};
},
}));
}

View File

@@ -19,7 +19,9 @@
"fabric-channel",
"fabric-send-message",
"fabric-channel-list",
"fabric-message-history"
"fabric-message-history",
"fabric-guild-list",
"fabric-channel-set-purpose"
]
},
"configSchema": {

View File

@@ -6,7 +6,15 @@ export type FabricSession = {
accessToken: string;
refreshToken: string;
user: { id: string; email: string; name: string };
guilds: Array<{ nodeId: string; name: string; endpoint: string; status: string }>;
guilds: Array<{
nodeId: string;
name: string;
endpoint: string;
status: string;
// free-form description of this guild's role; admin-set on Center.
// null when the admin hasn't filled it in yet.
purpose?: string | null;
}>;
guildAccessTokens: Array<{ guildNodeId: string; token: string }>;
};
@@ -100,11 +108,30 @@ export class FabricClient {
memberUserIds?: string[];
onDuty?: string;
listeners?: string[];
// free-form purpose; optional. Existing agents can also set/update
// it later via setChannelPurpose().
purpose?: string;
},
): Promise<{ id: string }> {
return this.post(`${guildEndpoint}/api/channels`, body, guildToken);
}
// PATCH /api/channels/:id — backend currently only patches `purpose`.
// Caller must be a member of the channel (or any user if public).
setChannelPurpose(
guildEndpoint: string,
guildToken: string,
channelId: string,
purpose: string,
): Promise<{ id: string; name: string; xType: string; purpose: string | null }> {
return this.req(
'PATCH',
`${guildEndpoint}/api/channels/${channelId}`,
guildToken,
{ purpose },
);
}
closeChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise<unknown> {
return this.post(`${guildEndpoint}/api/channels/${channelId}/close`, {}, guildToken);
}
@@ -212,6 +239,7 @@ export class FabricClient {
closed: boolean;
lastSeq: number;
createdAt: string;
purpose?: string | null;
}>> {
return this.req(
'GET',

View File

@@ -46,7 +46,10 @@ export function registerFabricTools(
const makeCreate = (kind: 'chat' | 'work' | 'report' | 'discussion') =>
api.registerTool((ctx: 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,
@@ -58,6 +61,14 @@ export function registerFabricTools(
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: {
@@ -65,6 +76,7 @@ export function registerFabricTools(
name: string;
isPublic?: boolean;
memberUserIds?: string[];
purpose?: string;
}) => {
const agentId = ctx.agentId;
if (!agentId) return { ok: false, error: 'no agent context' };
@@ -75,6 +87,7 @@ export function registerFabricTools(
xType: X_BY_KIND[kind],
isPublic: p.isPublic ?? false,
memberUserIds: p.memberUserIds ?? [],
...(p.purpose !== undefined ? { purpose: p.purpose } : {}),
});
return { ok: true, channelId: ch.id };
},
@@ -337,11 +350,112 @@ export function registerFabricTools(
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: 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: { nameFilter?: string; purposeFilter?: string }) => {
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: 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: { guildNodeId: string; channelId: string; purpose: string }) => {
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