refactor(channels,router): move all policy to router; bridge is transport

Follow-up to b159722. That shrank the bridge's shouldEngage to a flood
gate + coarse sticky-subscribe signal. This completes the move —
policy lives exclusively in the router, the bridge is transport-only,
and the conversations map + ChannelSetup.conversations +
ChannelAdapter.updateConversations are all gone.

Key shifts:

1. Subscribe moves from bridge to router.

   Bridge used to call `thread.subscribe()` from its onNewMention /
   onDirectMessage handlers based on a coarse "any mention-sticky wiring
   exists on this channel" check. That forced the decision before the
   router could apply per-wiring engage logic, and it relied on the
   conversations map being current (staleness risk).

   ChannelAdapter gains `subscribe?(platformId, threadId)`. The Chat
   SDK bridge implements it via SqliteStateAdapter.subscribe(threadId)
   (idempotent — a repeat call on an already-subscribed thread is a
   no-op). The router's fan-out loop calls it once per message when
   the first mention-sticky wiring actually engages. Precise, not
   coarse.

2. Short-circuit the drop path with one combined query.

   New `getMessagingGroupWithAgentCount(channelType, platformId)` does
   the messaging_groups lookup AND counts wirings in a single SELECT,
   using the existing UNIQUE(channel_type, platform_id) index on
   messaging_groups and UNIQUE(messaging_group_id, agent_group_id) on
   messaging_group_agents for the JOIN. No new indexes needed.

   routeInbound now short-circuits:
     - No messaging_groups row AND not addressed (no mention/DM)
       → return silently. One DB read, nothing written. This is the
       Discord-bot-in-a-big-guild case; we no longer auto-create rows
       for every plain message in every channel the bot can see.
     - Messaging group exists but no wirings AND not addressed
       → return silently. One DB read.
     - Otherwise fall through to sender resolution + fan-out as before.

   Behavioral change: plain chatter on unwired channels no longer gets
   dropped_messages audit rows, which used to bloat the table. Audit
   still fires on addressed-to-bot drops where the admin cares
   ("someone @-mentioned us but nobody's wired").

3. Bridge is now purely transport.

   Deleted entirely: ConversationConfig, ChannelSetup.conversations,
   ChannelAdapter.updateConversations?, bridge's `conversations` map,
   buildConversationMap, shouldEngage, EngageSource, engageDecision,
   bridge.updateConversations method, src/index.ts
   buildConversationConfigs. Four handlers reduce to "resolve channel
   id, build InboundMessage with isMention, call onInbound". Net
   ~130 LOC deleted from the bridge.

   Collateral: the conversations-map staleness problem is gone. The
   upcoming channel-registration feature doesn't need any map-refresh
   plumbing — when an approval creates a new wiring, the next message
   hits the DB fresh and just works.

Bridge tests prune to the narrow platform-adjacent surface (openDM
delegation, subscribe presence). Host-core test that asserted the
old "auto-create on every unknown message" behavior updates to
reflect the new escalation-gated semantics: plain messages on
unknown channels don't auto-create, mentions do.

159 tests pass (was 172 — net -13, almost entirely from
bridge-engage-mode tests that covered logic now owned by the router
and exercised through host-core.test.ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-20 13:55:49 +03:00
parent b15972284b
commit a4061a0012
8 changed files with 173 additions and 309 deletions

View File

@@ -5,45 +5,8 @@
* Two patterns: native adapters (implement directly) or Chat SDK bridge (wrap a Chat SDK adapter).
*/
/** Configuration for a registered conversation (messaging group + agent wiring). */
export interface ConversationConfig {
platformId: string;
agentGroupId: string;
/**
* When does the agent engage on messages from this conversation?
*
* 'pattern' — regex test against message text; engagePattern='.'
* means "always" (match everything)
* 'mention' — fires only on @mention
* 'mention-sticky' — fires on @mention, then auto-subscribes to the thread
* and treats subsequent messages as engage-all.
* Threaded platforms only (Slack/Discord/Linear).
*/
engageMode: 'pattern' | 'mention' | 'mention-sticky';
/** Regex source when engageMode='pattern'. '.' is the "always" sentinel. */
engagePattern?: string | null;
/**
* What to do with messages this wiring doesn't engage on.
*
* 'drop' — discard silently
* 'accumulate' — still forward to the host so the router can store the
* message in this agent's session with trigger=0. It
* rides along as context when the agent next wakes, but
* doesn't wake it on its own.
*
* The bridge reads this to decide whether to forward a non-engaging
* message at all — if any wiring on a conversation has 'accumulate', the
* bridge forwards and lets the router apply the per-wiring decision.
*/
ignoredMessagePolicy?: 'drop' | 'accumulate';
sessionMode: 'shared' | 'per-thread' | 'agent-shared';
}
/** Passed to the adapter at setup time. */
export interface ChannelSetup {
/** Known conversations from central DB. */
conversations: ConversationConfig[];
/** Called when an inbound message arrives from the platform. */
onInbound(platformId: string, threadId: string | null, message: InboundMessage): void | Promise<void>;
@@ -125,7 +88,17 @@ export interface ChannelAdapter {
// Optional
setTyping?(platformId: string, threadId: string | null): Promise<void>;
syncConversations?(): Promise<ConversationInfo[]>;
updateConversations?(conversations: ConversationConfig[]): void;
/**
* Subscribe the bot to a thread so follow-up messages route via the
* platform's "subscribed message" path (onSubscribedMessage in Chat SDK).
* Called by the router when a mention-sticky wiring first engages in a
* thread. Idempotent: calling twice on the same thread is a no-op.
*
* Platforms without a subscription concept can omit this; the router
* treats absence as a no-op.
*/
subscribe?(platformId: string, threadId: string): Promise<void>;
/**
* Open (or fetch) a DM with this user, returning the platform_id of the