Compare commits
2 Commits
152b465e64
...
fix/routin
| Author | SHA1 | Date | |
|---|---|---|---|
| ea713064e1 | |||
| 180b717eda |
@@ -20,6 +20,7 @@
|
||||
"fabric-canvas",
|
||||
"fabric-channel",
|
||||
"fabric-send-message",
|
||||
"fabric-send-sys-msg",
|
||||
"fabric-channel-list",
|
||||
"fabric-message-history",
|
||||
"fabric-guild-list",
|
||||
|
||||
@@ -51,6 +51,30 @@ type FabricMessage = {
|
||||
xType?: string;
|
||||
};
|
||||
|
||||
// Walk cfg.bindings for the entry that ties `agentId` to a fabric account.
|
||||
// Returns the binding's match.accountId (the slot label routing keys on);
|
||||
// returns undefined when the agent has no explicit fabric binding so the
|
||||
// caller can fall back to agentId without changing pre-existing semantics
|
||||
// for agents whose binding accountId == agent_id anyway.
|
||||
function findFabricBindingAccountId(cfg: unknown, agentId: string): string | undefined {
|
||||
const bindings = (cfg as { bindings?: Array<{
|
||||
agentId?: string;
|
||||
match?: { channel?: string; accountId?: string };
|
||||
}> })?.bindings;
|
||||
if (!Array.isArray(bindings)) return undefined;
|
||||
for (const b of bindings) {
|
||||
if (
|
||||
b?.agentId === agentId &&
|
||||
b?.match?.channel === 'fabric' &&
|
||||
typeof b?.match?.accountId === 'string' &&
|
||||
b.match.accountId.length > 0
|
||||
) {
|
||||
return b.match.accountId;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export class FabricInbound {
|
||||
private sockets: Socket[] = [];
|
||||
private seen = new Set<string>();
|
||||
@@ -541,10 +565,22 @@ export class FabricInbound {
|
||||
// (commands-handlers `isDirectMessage` checks ChatType==='direct')
|
||||
// misclassifies the turn.
|
||||
const { peerKind, chatType } = fabricPeerRoutingForXType(m.xType);
|
||||
// resolveAgentRoute needs the *binding* accountId (the channel-side
|
||||
// slot name) — not the openclaw agentId. For most agents the binding
|
||||
// is `{agentId: X, match: {channel: fabric, accountId: X}}` so the
|
||||
// two coincide; but for shared-placeholder cases (e.g. the recruitment
|
||||
// `interviewee` slot bound to multiple agents over its lifetime) the
|
||||
// binding accountId is the slot label ("interviewee", "Neon", …) not
|
||||
// the agent_id. Passing agentId there returned bindings=0 and silently
|
||||
// fell back to `main`, hijacking sub-discussion turns. Look up the
|
||||
// agent's fabric binding accountId here; fall back to agentId when no
|
||||
// explicit binding exists (preserves prior behavior for agents with
|
||||
// no fabric binding declared).
|
||||
const bindingAccountId = findFabricBindingAccountId(this.cfg, agentId) ?? agentId;
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg: this.cfg,
|
||||
channel: 'fabric',
|
||||
accountId: agentId,
|
||||
accountId: bindingAccountId,
|
||||
peer: { kind: peerKind, id: channelId },
|
||||
});
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
|
||||
83
src/tools.ts
83
src/tools.ts
@@ -555,6 +555,89 @@ export function registerFabricTools(
|
||||
},
|
||||
}));
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// fabric-send-sys-msg: post a system-authored message (author =
|
||||
// sentinel UUID 0000…, not the calling agent) using the Guild's
|
||||
// x-fabric-system-key path. Use for cross-agent broadcasts where you
|
||||
// don't want the message tied to one agent's identity — Dialectic
|
||||
// topic announcements / lifecycle events, host-system advisories,
|
||||
// etc. Caller doesn't need to be a member of the channel (the
|
||||
// backend isSystem branch skips assertParticipant), but must be a
|
||||
// member of the guild (their session resolves the guild endpoint).
|
||||
//
|
||||
// Shared secret: reads channels.fabric.commandsSyncKey (same value
|
||||
// as the guild's FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY env). Empty
|
||||
// config → tool returns ok:false with a clear error, no fall-through
|
||||
// to regular agent posting.
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
api.registerTool((ctx: Ctx) => ({
|
||||
name: 'fabric-send-sys-msg',
|
||||
description:
|
||||
'Send a SYSTEM-AUTHORED message into a Fabric channel (author = guild sentinel, not you). ' +
|
||||
'Use for cross-agent broadcasts that should not be attributed to a single agent — ' +
|
||||
'Dialectic announce-channel topic broadcasts, lifecycle events, system advisories. ' +
|
||||
'Optionally precise-wake one recipient via wakeupUserId; otherwise the message lands ' +
|
||||
'silently in history (no wake).',
|
||||
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).' },
|
||||
wakeupUserId: {
|
||||
type: 'string',
|
||||
description:
|
||||
"Optional: a single Fabric userId to wake with this message (everyone else in the " +
|
||||
'channel sees it but with wakeup=false). Omit for fully silent broadcast.',
|
||||
},
|
||||
},
|
||||
},
|
||||
execute: async (
|
||||
_id: string,
|
||||
p: { guildNodeId: string; channelId: string; content: string; wakeupUserId?: string },
|
||||
) => {
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId) return { ok: false, error: 'no agent context' };
|
||||
const systemKey = resolveCommandsSyncKey(cfg);
|
||||
if (!systemKey) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
'channels.fabric.commandsSyncKey is not configured — fabric-send-sys-msg needs it for ' +
|
||||
'the x-fabric-system-key header. Configure via openclaw config.',
|
||||
};
|
||||
}
|
||||
const { guild } = await ctxGuild(agentId, p.guildNodeId);
|
||||
const url = `${guild.endpoint}/api/channels/${encodeURIComponent(p.channelId)}/messages`;
|
||||
const wakeup = typeof p.wakeupUserId === 'string' && p.wakeupUserId.trim()
|
||||
? p.wakeupUserId.trim()
|
||||
: null;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-fabric-system-key': systemKey,
|
||||
},
|
||||
body: JSON.stringify({ content: p.content, wakeupUserId: wakeup }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
return { ok: false, error: `POST ${url} -> ${res.status} ${text}` };
|
||||
}
|
||||
const json = (await res.json().catch(() => null)) as
|
||||
| { messageId?: string; seq?: number; authorUserId?: string }
|
||||
| null;
|
||||
return {
|
||||
ok: true,
|
||||
messageId: json?.messageId,
|
||||
seq: json?.seq,
|
||||
authorUserId: json?.authorUserId,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// fabric-channel-list: enumerate channels the calling agent can see
|
||||
// in a given guild. Backend filters to public channels + channels the
|
||||
|
||||
Reference in New Issue
Block a user