Adds a host-driven sub-discussion mechanism for short-lived multi-agent
exchanges where one participant (recruitment interviewee, scoped Q&A
target) has no workflow capability of its own — no Meridian state, no
installed skills, no ego identity, possibly just a placeholder Fabric
account. The host stays procedurally in control; the guest just answers
natural-language questions.
The fundamental shift from `create-discussion-channel` is the guide
plumbing: instead of a single shared guide file posted as the channel's
first message (Discord-era model), each role gets its OWN system-prompt
guide, injected at turn time via `before_prompt_build`. The host can
carry the full procedure; the guest sees a tiny orientation tailored to
"answer my questions, don't try to enter workflows you can't enter".
Three new pieces:
1. SubDiscussionStore (src/sub-discussion-store.ts) — in-memory store
keyed by sub channelId, mirror-persisted to
~/.openclaw/fabric-sub-discussion.json so a gateway restart
mid-interview doesn't strand both parties with no guide. Carries
host agentId + userId, guest userIds, host guide text, guest guide
text, callback (parent) channelId / guildNodeId, createdAt.
2. create-sub-discussion tool (src/tools.ts):
- Creates a discuss-type Fabric channel with guests as members
(host is creator → auto-included).
- Persists a store entry indexed by the new channelId.
- Sleeps FABRIC_SUB_DISCUSSION_GREETING_DELAY_MS (default 500ms,
env-overridable) for the backend's channel.joined push to land on
guest sockets, then posts greetingMsg using the host's own Fabric
account. Turn rotation's activation rule then puts the first guest
on the spot with wakeup=true — no race where the host posts before
the guest's socket subs the channel room.
3. close-sub-discussion tool (src/tools.ts):
- Host-only (rejects non-host callers by agentId match against the
store).
- Posts callbackMsg back into the parent channel using the Guild's
x-fabric-system-key path so the callback lands as a guild/system-
authored message rather than the host's personal account.
- Default wakeupHost=true precisely wakes the host on the parent
channel so the next workflow step (e.g. recruitment onboard) fires
without waiting for unrelated traffic.
- Closes the sub channel and drops the store entry.
Plus a `before_prompt_build` hook (src/sub-discussion-hook.ts) the plugin
registers at startup:
ctx.channelId → store.find() → entry
identity.findByAgentId(ctx.agentId).fabricUserId
─ matches entry.hostUserId → appendSystemContext: entry.hostGuide
─ in entry.guestUserIds → appendSystemContext: entry.guestGuide
─ neither → no injection
Fail-closed on unknown agentId / channelId — we never inject the wrong
guide, only the right one or nothing. Provider-gated to messageProvider
in {empty,'fabric'} so non-fabric triggers (HF wake, exec-event) don't
unintentionally pick up an injection.
Wiring in index.ts threads the new store + config into both
registerFabricTools (so close-sub-discussion can read the system key
from channels.fabric.commandsSyncKey) and registerSubDiscussionHook (so
the prompt hook can resolve agentId → fabricUserId via identity).
Manifest update: openclaw.plugin.json lists `create-sub-discussion`
and `close-sub-discussion` in contracts.tools so the registry surfaces
them to the agent.
Backend prereq: Fabric.Backend.Guild commit 340eed8 restores the
x-fabric-system-key bypass on POST /channels/:id/messages with
wakeupUserId support — close-sub-discussion is a no-op without it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
77 lines
2.9 KiB
TypeScript
77 lines
2.9 KiB
TypeScript
import type { IdentityRegistry } from './identity.js';
|
|
import type { SubDiscussionStore } from './sub-discussion-store.js';
|
|
|
|
// Plugin-local before_prompt_build hook that injects per-(agent, channel)
|
|
// guides for sub-discussion channels created via the `create-sub-discussion`
|
|
// tool. Mirrors the pattern used by ClawPrompts' fabric-chat-injector
|
|
// (channelId-aware injection) but with content dynamically supplied at
|
|
// channel-creation time instead of read from static files via PrismFacet's
|
|
// router/rule registry.
|
|
//
|
|
// Match logic per turn:
|
|
// ctx.channelId → store.find() → sub-discussion entry
|
|
// ctx.agentId → identity.findByAgentId().fabricUserId
|
|
// ─ matches entry.hostUserId → inject hostGuide
|
|
// ─ matches entry.guestUserIds → inject guestGuide
|
|
// ─ neither → no injection
|
|
//
|
|
// Fail-closed on unknown agentId/channelId — we never inject "the wrong"
|
|
// guide, only the right one or nothing.
|
|
|
|
const _G = globalThis as Record<string, unknown>;
|
|
const DEDUP_KEY = '_fabricSubDiscussionHookDedup';
|
|
|
|
type PromptCtx = {
|
|
agentId?: string;
|
|
channelId?: string;
|
|
messageProvider?: string;
|
|
};
|
|
|
|
export function registerSubDiscussionHook(
|
|
api: {
|
|
on: (hook: string, handler: (...args: unknown[]) => unknown) => void;
|
|
logger: { info: (m: string) => void; warn: (m: string) => void };
|
|
},
|
|
store: SubDiscussionStore,
|
|
identity: IdentityRegistry,
|
|
): void {
|
|
if (!(_G[DEDUP_KEY] instanceof WeakSet)) _G[DEDUP_KEY] = new WeakSet<object>();
|
|
const dedup = _G[DEDUP_KEY] as WeakSet<object>;
|
|
|
|
api.on('before_prompt_build', async (...args: unknown[]) => {
|
|
const event = args[0];
|
|
const ctx = (args[1] ?? {}) as PromptCtx;
|
|
// The hook fires both for fabric-driven turns (channelId set) and
|
|
// for other triggers (HF wake, exec-event, etc.) — drop those.
|
|
if (typeof event === 'object' && event !== null) {
|
|
if (dedup.has(event)) return undefined;
|
|
dedup.add(event);
|
|
}
|
|
const agentId = (ctx.agentId ?? '').trim();
|
|
const channelId = (ctx.channelId ?? '').trim();
|
|
if (!agentId || !channelId) return undefined;
|
|
const provider = (ctx.messageProvider ?? '').toLowerCase();
|
|
if (provider && provider !== 'fabric') return undefined;
|
|
|
|
const entry = store.find(channelId);
|
|
if (!entry) return undefined;
|
|
|
|
const ident = identity.findByAgentId(agentId);
|
|
const myUserId = (ident?.fabricUserId ?? '').trim();
|
|
if (!myUserId) {
|
|
// identity registry caches fabricUserId after the first agentLogin
|
|
// in inbound.ts. If it's missing here, the agent likely hasn't
|
|
// completed login yet — skip rather than guess.
|
|
return undefined;
|
|
}
|
|
|
|
if (myUserId === entry.hostUserId) {
|
|
return { appendSystemContext: entry.hostGuide };
|
|
}
|
|
if (entry.guestUserIds.includes(myUserId)) {
|
|
return { appendSystemContext: entry.guestGuide };
|
|
}
|
|
return undefined;
|
|
});
|
|
}
|