feat(routing): engage modes + sender scope + accumulate/drop + per-agent fan-out
Replaces the opaque trigger_rules JSON + response_scope enum on
messaging_group_agents with four explicit orthogonal columns:
engage_mode 'pattern' | 'mention' | 'mention-sticky'
engage_pattern regex source; required when mode='pattern';
'.' is the "always" sentinel
sender_scope 'all' | 'known'
ignored_message_policy 'drop' | 'accumulate'
Inbound routing becomes a fan-out — every wired agent is evaluated
independently. A match gets its own session + container wake. A miss
with accumulate keeps the message as context-only (trigger=0) in that
agent's session, so when the agent does eventually engage it sees the
prior chatter.
## Schema
- Migration 010 (`engage-modes`): adds the 4 new columns, backfills
from trigger_rules.pattern + requiresTrigger + response_scope, drops
the legacy columns.
- messages_in gains `trigger INTEGER NOT NULL DEFAULT 1` (session DB
schema + `migrateMessagesInTable` forward-compat).
- countDueMessages gates waking on `trigger = 1`.
## Routing
- `pickAgent` (returns one) → loop over all wired agents. Per agent:
evaluate engage_mode; run access gate + sender-scope gate; on full
match → resolveSession + writeSessionMessage(trigger=1) + wake. On
miss with accumulate → writeSessionMessage(trigger=0), no wake. On
miss with drop → skip.
- New `findSessionForAgent(agentGroupId, mgId, threadId)` scopes
session lookup by agent so fan-out doesn't cross sessions.
- `messageIdForAgent` namespaces inbound message ids by agent_group_id
so PRIMARY KEY doesn't collide across per-agent session DBs.
## Adapter layer
- `ConversationConfig` replaces `triggerPattern` + `requiresTrigger`
with `engageMode` + `engagePattern`.
- Chat SDK bridge stores `Map<platformId, ConversationConfig[]>` (multi-
agent per conversation) and applies union gating pre-onInbound:
* onSubscribedMessage: engage if any wiring keeps firing in
subscribed state (mention-sticky or pattern)
* onNewMention: engage on mention; only subscribes the thread if
at least one wiring is `mention-sticky`
* onDirectMessage: engage per mode; sticky follows same rule
- Bridge no longer unconditionally calls `thread.subscribe()`.
## Sender scope
- Permissions module registers a second hook `setSenderScopeGate` that
runs per-wiring after the existing access gate. `sender_scope='known'`
requires canAccessAgentGroup(); `'all'` is a no-op. Not installed →
no-op everywhere (default allow).
## Container side
- Host passes `NANOCLAW_MAX_MESSAGES_PER_PROMPT` (reuses existing
MAX_MESSAGES_PER_PROMPT config; was dead code from v1).
- `getPendingMessages` queries `ORDER BY seq DESC LIMIT N`, reverses to
chronological order for the prompt — accumulated context rides along
with trigger rows up to the cap.
- `MessageInRow` gains `trigger: number` so the container can tell them
apart in downstream code (container still processes both; only the
host uses `trigger=0` for don't-wake).
## Defaults (per ACTION-ITEMS item 1 decision)
- DM (is_group=0): `engage_mode='pattern'`, `engage_pattern='.'` (always)
- Threaded group: `engage_mode='mention-sticky'` (seed-discord)
- Non-threaded group / CLI: pattern '.' in bootstrap scripts
## Tests
- src/host-core.test.ts: 3 new cases — fan-out (2 agents, 2 sessions,
2 wakes), accumulate (trigger=0 + no wake), drop (no session created).
- Existing 10 host-core tests still pass.
- Migration 010 runs on an empty DB in 0-row path — verified.
Closes: ACTION-ITEMS items 1, 4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,16 +18,33 @@ export interface MessageInRow {
|
||||
process_after: string | null;
|
||||
recurrence: string | null;
|
||||
tries: number;
|
||||
/** 1 = wake-eligible (default); 0 = accumulated context only */
|
||||
trigger: number;
|
||||
platform_id: string | null;
|
||||
channel_type: string | null;
|
||||
thread_id: string | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// Cap on how many messages reach the agent in one prompt, including any
|
||||
// accumulated-but-not-triggered context. Host controls the cap via the
|
||||
// NANOCLAW_MAX_MESSAGES_PER_PROMPT env var; default mirrors the host's
|
||||
// config.ts default of 10.
|
||||
const MAX_MESSAGES_PER_PROMPT = Math.max(
|
||||
1,
|
||||
parseInt(process.env.NANOCLAW_MAX_MESSAGES_PER_PROMPT || '10', 10) || 10,
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch pending messages that are due for processing.
|
||||
* Reads from inbound.db (read-only), filters against processing_ack in outbound.db
|
||||
* to skip messages already picked up by this or a previous container run.
|
||||
*
|
||||
* Returns the most recent `MAX_MESSAGES_PER_PROMPT` pending rows in
|
||||
* chronological order, regardless of their `trigger` flag: accumulated
|
||||
* context (trigger=0) rides along with the wake-eligible rows so the agent
|
||||
* sees the prior context it missed. Host's countDueMessages gates waking on
|
||||
* trigger=1 separately (see src/db/session-db.ts).
|
||||
*/
|
||||
export function getPendingMessages(): MessageInRow[] {
|
||||
const inbound = getInboundDb();
|
||||
@@ -38,9 +55,10 @@ export function getPendingMessages(): MessageInRow[] {
|
||||
`SELECT * FROM messages_in
|
||||
WHERE status = 'pending'
|
||||
AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))
|
||||
ORDER BY timestamp ASC`,
|
||||
ORDER BY seq DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all() as MessageInRow[];
|
||||
.all(MAX_MESSAGES_PER_PROMPT) as MessageInRow[];
|
||||
|
||||
if (pending.length === 0) return [];
|
||||
|
||||
@@ -51,7 +69,9 @@ export function getPendingMessages(): MessageInRow[] {
|
||||
),
|
||||
);
|
||||
|
||||
return pending.filter((m) => !ackedIds.has(m.id));
|
||||
// Reverse: we fetched DESC to take the most recent N, but the agent
|
||||
// should see them in chronological order (oldest first).
|
||||
return pending.filter((m) => !ackedIds.has(m.id)).reverse();
|
||||
}
|
||||
|
||||
/** Mark messages as processing — writes to processing_ack in outbound.db. */
|
||||
|
||||
@@ -195,8 +195,13 @@ async function main(): Promise<void> {
|
||||
id: generateId('mga'),
|
||||
messaging_group_id: mg.id,
|
||||
agent_group_id: ag.id,
|
||||
trigger_rules: null,
|
||||
response_scope: 'all',
|
||||
// DM (is_group=0) defaults to "respond to everything" via the '.' pattern.
|
||||
// Group chats default to mention-only; admins can upgrade to
|
||||
// mention-sticky via /manage-channels once the agent is in use.
|
||||
engage_mode: mg.is_group === 0 ? 'pattern' : 'mention',
|
||||
engage_pattern: mg.is_group === 0 ? '.' : null,
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: now,
|
||||
@@ -248,8 +253,11 @@ async function main(): Promise<void> {
|
||||
id: generateId('mga'),
|
||||
messaging_group_id: cliMg.id,
|
||||
agent_group_id: ag.id,
|
||||
trigger_rules: null,
|
||||
response_scope: 'all',
|
||||
// CLI is a local single-user DM — always respond.
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: now,
|
||||
|
||||
@@ -58,8 +58,12 @@ try {
|
||||
id: 'mga-discord',
|
||||
messaging_group_id: MESSAGING_GROUP_ID,
|
||||
agent_group_id: AGENT_GROUP_ID,
|
||||
trigger_rules: null,
|
||||
response_scope: 'all',
|
||||
// Discord group channel → mention-sticky default. Mention once, stay
|
||||
// subscribed to the thread. Admins can tune via /manage-channels.
|
||||
engage_mode: 'mention-sticky',
|
||||
engage_pattern: null,
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
|
||||
@@ -53,8 +53,10 @@ createMessagingGroupAgent({
|
||||
id: 'mga-chan',
|
||||
messaging_group_id: 'mg-chan',
|
||||
agent_group_id: 'ag-chan',
|
||||
trigger_rules: null,
|
||||
response_scope: 'all',
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
@@ -105,7 +107,15 @@ registerChannelAdapter('mock', { factory: () => mockAdapter });
|
||||
|
||||
// Init channel adapters — this calls setup() with conversation configs from central DB
|
||||
await initChannelAdapters((adapter) => ({
|
||||
conversations: [{ platformId: 'mock-channel-1', agentGroupId: 'ag-chan', requiresTrigger: false, sessionMode: 'shared' }],
|
||||
conversations: [
|
||||
{
|
||||
platformId: 'mock-channel-1',
|
||||
agentGroupId: 'ag-chan',
|
||||
engageMode: 'pattern',
|
||||
engagePattern: '.',
|
||||
sessionMode: 'shared',
|
||||
},
|
||||
],
|
||||
onInbound(platformId, threadId, message) {
|
||||
routeInbound({
|
||||
channelType: adapter.channelType,
|
||||
|
||||
@@ -55,8 +55,10 @@ createMessagingGroupAgent({
|
||||
id: 'mga-e2e',
|
||||
messaging_group_id: 'mg-e2e',
|
||||
agent_group_id: 'ag-e2e',
|
||||
trigger_rules: null,
|
||||
response_scope: 'all',
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
|
||||
@@ -9,8 +9,19 @@
|
||||
export interface ConversationConfig {
|
||||
platformId: string;
|
||||
agentGroupId: string;
|
||||
triggerPattern?: string; // regex string (for native channels)
|
||||
requiresTrigger: boolean;
|
||||
/**
|
||||
* 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;
|
||||
sessionMode: 'shared' | 'per-thread' | 'agent-shared';
|
||||
}
|
||||
|
||||
|
||||
@@ -148,8 +148,10 @@ describe('channel + router integration', () => {
|
||||
id: 'mga-1',
|
||||
messaging_group_id: 'mg-1',
|
||||
agent_group_id: 'ag-1',
|
||||
trigger_rules: null,
|
||||
response_scope: 'all',
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: now(),
|
||||
|
||||
@@ -71,23 +71,89 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
let chat: Chat;
|
||||
let state: SqliteStateAdapter;
|
||||
let setupConfig: ChannelSetup;
|
||||
// NOTE: populated at setup() and updateConversations(), but currently not
|
||||
// read by any inbound handler. When adapter-level gating lands (engage_mode
|
||||
// applied here) or when dynamic group registration is added, this map goes
|
||||
// stale after setup unless updateConversations() is actively called on every
|
||||
// messaging_groups / messaging_group_agents mutation. See ACTION-ITEMS.md
|
||||
// item 17.
|
||||
let conversations: Map<string, ConversationConfig>;
|
||||
// Keyed by platformId. Multiple agents may be wired to the same
|
||||
// conversation — this holds all their configs so the bridge can apply the
|
||||
// most-permissive engage rule at gate time and only subscribe when at
|
||||
// least one wiring requested 'mention-sticky'.
|
||||
//
|
||||
// STALENESS: populated at setup() and updateConversations(). If wirings
|
||||
// change after setup, updateConversations() must be called to refresh
|
||||
// (ACTION-ITEMS item 17).
|
||||
let conversations: Map<string, ConversationConfig[]>;
|
||||
let gatewayAbort: AbortController | null = null;
|
||||
|
||||
function buildConversationMap(configs: ConversationConfig[]): Map<string, ConversationConfig> {
|
||||
const map = new Map<string, ConversationConfig>();
|
||||
function buildConversationMap(configs: ConversationConfig[]): Map<string, ConversationConfig[]> {
|
||||
const map = new Map<string, ConversationConfig[]>();
|
||||
for (const conv of configs) {
|
||||
map.set(conv.platformId, conv);
|
||||
const existing = map.get(conv.platformId);
|
||||
if (existing) existing.push(conv);
|
||||
else map.set(conv.platformId, [conv]);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should a message from (channelId, kind) engage any of the wired agents?
|
||||
*
|
||||
* - `mention` — engages only when the message actually @-mentions
|
||||
* the bot (the bridge already sees it here because
|
||||
* Chat SDK only forwards subscribed / mentioned /
|
||||
* DM messages)
|
||||
* - `mention-sticky` — same as `mention` for gating, PLUS we subscribe
|
||||
* the thread so later messages arrive via the
|
||||
* subscribed path and fall through to an
|
||||
* engage-all style treatment
|
||||
* - `pattern` — regex test against message text; `.` = always
|
||||
*
|
||||
* We take the union across wired agents — if any one of them would engage,
|
||||
* the message goes through. Per-agent filtering after that happens in the
|
||||
* host router (see src/router.ts pickAgents).
|
||||
*/
|
||||
function shouldEngage(
|
||||
channelId: string,
|
||||
source: 'subscribed' | 'mention' | 'dm',
|
||||
text: string,
|
||||
): { engage: boolean; stickySubscribe: boolean } {
|
||||
const configs = conversations.get(channelId);
|
||||
// Unknown conversation — forward anyway (may be a new group that
|
||||
// hasn't been registered yet; central routing will log + drop cleanly).
|
||||
if (!configs || configs.length === 0) return { engage: true, stickySubscribe: false };
|
||||
|
||||
let engage = false;
|
||||
let stickySubscribe = false;
|
||||
|
||||
for (const cfg of configs) {
|
||||
switch (cfg.engageMode) {
|
||||
case 'mention':
|
||||
if (source === 'mention' || source === 'dm') engage = true;
|
||||
break;
|
||||
case 'mention-sticky':
|
||||
if (source === 'mention' || source === 'dm') {
|
||||
engage = true;
|
||||
stickySubscribe = true;
|
||||
} else if (source === 'subscribed') {
|
||||
// Thread was already subscribed on a prior mention — treat as
|
||||
// engage-all so follow-ups in the thread reach the agent.
|
||||
engage = true;
|
||||
}
|
||||
break;
|
||||
case 'pattern': {
|
||||
const pattern = cfg.engagePattern ?? '.';
|
||||
try {
|
||||
if (pattern === '.' || new RegExp(pattern).test(text)) engage = true;
|
||||
} catch {
|
||||
// Invalid regex → fail open so the admin can see something and fix.
|
||||
engage = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (engage && stickySubscribe) break;
|
||||
}
|
||||
|
||||
return { engage, stickySubscribe };
|
||||
}
|
||||
|
||||
async function messageToInbound(message: ChatMessage): Promise<InboundMessage> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const serialized = message.toJSON() as Record<string, any>;
|
||||
@@ -166,33 +232,54 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
logger: 'silent',
|
||||
});
|
||||
|
||||
// Subscribed threads — forward all messages
|
||||
// Subscribed threads — the conversation is already active (via prior
|
||||
// mention-sticky engagement or admin wiring). Gate on engageMode so a
|
||||
// plain 'mention' wiring doesn't keep firing after a one-off mention.
|
||||
chat.onSubscribedMessage(async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
const text = typeof message.content === 'string' ? message.content : '';
|
||||
const decision = shouldEngage(channelId, 'subscribed', text);
|
||||
if (!decision.engage) return;
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message));
|
||||
});
|
||||
|
||||
// @mention in unsubscribed thread — forward + subscribe
|
||||
// @mention in an unsubscribed thread — always engage; subscribe only
|
||||
// if the wiring is 'mention-sticky'.
|
||||
chat.onNewMention(async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
const text = typeof message.content === 'string' ? message.content : '';
|
||||
const decision = shouldEngage(channelId, 'mention', text);
|
||||
if (!decision.engage) return;
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message));
|
||||
if (decision.stickySubscribe) {
|
||||
await thread.subscribe();
|
||||
}
|
||||
});
|
||||
|
||||
// DMs — always forward + subscribe. Pass thread.id so sub-thread
|
||||
// context carries through to delivery (Slack users can open threads
|
||||
// inside a DM). The router collapses DM sub-threads to one session
|
||||
// (is_group=0 short-circuits the per-thread escalation).
|
||||
// DMs — apply engage rules too, but DMs typically default to pattern='.'
|
||||
// at setup time so this is a pass-through in practice. sticky subscribe
|
||||
// follows the same rule as a group mention.
|
||||
//
|
||||
// Thread id is passed through so sub-thread context reaches delivery
|
||||
// (Slack users can open threads inside a DM). The router collapses DM
|
||||
// sub-threads to one session (is_group=0 short-circuits the per-thread
|
||||
// escalation).
|
||||
chat.onDirectMessage(async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
const text = typeof message.content === 'string' ? message.content : '';
|
||||
const decision = shouldEngage(channelId, 'dm', text);
|
||||
log.info('Inbound DM received', {
|
||||
adapter: adapter.name,
|
||||
channelId,
|
||||
sender: (message.author as any)?.fullName ?? (message.author as any)?.userId ?? 'unknown',
|
||||
threadId: thread.id,
|
||||
engage: decision.engage,
|
||||
});
|
||||
if (!decision.engage) return;
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message));
|
||||
if (decision.stickySubscribe) {
|
||||
await thread.subscribe();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle button clicks (ask_user_question)
|
||||
|
||||
@@ -9,7 +9,7 @@ import path from 'path';
|
||||
|
||||
import { OneCLI } from '@onecli-sh/sdk';
|
||||
|
||||
import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, ONECLI_URL, TIMEZONE } from './config.js';
|
||||
import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, MAX_MESSAGES_PER_PROMPT, ONECLI_URL, TIMEZONE } from './config.js';
|
||||
import { readContainerConfig, writeContainerConfig } from './container-config.js';
|
||||
import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
@@ -246,6 +246,9 @@ async function buildContainerArgs(
|
||||
}
|
||||
args.push('-e', `NANOCLAW_AGENT_GROUP_ID=${agentGroup.id}`);
|
||||
args.push('-e', `NANOCLAW_AGENT_GROUP_NAME=${agentGroup.name}`);
|
||||
// Cap on how many pending messages reach one prompt. Accumulated context
|
||||
// (trigger=0 rows) rides along with wake-eligible rows up to this cap.
|
||||
args.push('-e', `NANOCLAW_MAX_MESSAGES_PER_PROMPT=${MAX_MESSAGES_PER_PROMPT}`);
|
||||
|
||||
// Provider-contributed env vars (e.g. XDG_DATA_HOME, OPENCODE_*, NO_PROXY).
|
||||
if (providerContribution.env) {
|
||||
|
||||
@@ -178,8 +178,10 @@ describe('messaging group agents', () => {
|
||||
id: 'mga-1',
|
||||
messaging_group_id: 'mg-1',
|
||||
agent_group_id: 'ag-1',
|
||||
trigger_rules: null,
|
||||
response_scope: 'all' as const,
|
||||
engage_mode: 'pattern' as const,
|
||||
engage_pattern: '.',
|
||||
sender_scope: 'all' as const,
|
||||
ignored_message_policy: 'drop' as const,
|
||||
session_mode: 'shared' as const,
|
||||
priority: 0,
|
||||
created_at: now(),
|
||||
@@ -229,7 +231,8 @@ describe('messaging group agents', () => {
|
||||
});
|
||||
|
||||
it('auto-creates an agent_destinations row for the wiring', async () => {
|
||||
const { getDestinationByTarget, getDestinations } = await import('../modules/agent-to-agent/db/agent-destinations.js');
|
||||
const { getDestinationByTarget, getDestinations } =
|
||||
await import('../modules/agent-to-agent/db/agent-destinations.js');
|
||||
createMessagingGroupAgent(mga());
|
||||
|
||||
const dest = getDestinationByTarget('ag-1', 'channel', 'mg-1');
|
||||
|
||||
@@ -87,8 +87,16 @@ export function deleteMessagingGroup(id: string): void {
|
||||
export function createMessagingGroupAgent(mga: MessagingGroupAgent): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES (@id, @messaging_group_id, @agent_group_id, @trigger_rules, @response_scope, @session_mode, @priority, @created_at)`,
|
||||
`INSERT INTO messaging_group_agents (
|
||||
id, messaging_group_id, agent_group_id,
|
||||
engage_mode, engage_pattern, sender_scope, ignored_message_policy,
|
||||
session_mode, priority, created_at
|
||||
)
|
||||
VALUES (
|
||||
@id, @messaging_group_id, @agent_group_id,
|
||||
@engage_mode, @engage_pattern, @sender_scope, @ignored_message_policy,
|
||||
@session_mode, @priority, @created_at
|
||||
)`,
|
||||
)
|
||||
.run(mga);
|
||||
|
||||
@@ -160,7 +168,12 @@ export function getMessagingGroupAgent(id: string): MessagingGroupAgent | undefi
|
||||
|
||||
export function updateMessagingGroupAgent(
|
||||
id: string,
|
||||
updates: Partial<Pick<MessagingGroupAgent, 'trigger_rules' | 'response_scope' | 'session_mode' | 'priority'>>,
|
||||
updates: Partial<
|
||||
Pick<
|
||||
MessagingGroupAgent,
|
||||
'engage_mode' | 'engage_pattern' | 'sender_scope' | 'ignored_message_policy' | 'session_mode' | 'priority'
|
||||
>
|
||||
>,
|
||||
): void {
|
||||
const fields: string[] = [];
|
||||
const values: Record<string, unknown> = { id };
|
||||
|
||||
101
src/db/migrations/010-engage-modes.ts
Normal file
101
src/db/migrations/010-engage-modes.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Replace `trigger_rules` (opaque JSON) + `response_scope` (conflated axis)
|
||||
* with four explicit orthogonal columns on messaging_group_agents:
|
||||
*
|
||||
* engage_mode 'pattern' | 'mention' | 'mention-sticky'
|
||||
* engage_pattern regex string (required when engage_mode='pattern';
|
||||
* '.' means "match everything" — the "always" flavor)
|
||||
* sender_scope 'all' | 'known'
|
||||
* ignored_message_policy 'drop' | 'accumulate'
|
||||
*
|
||||
* Backfill rules (applied per-row, reading the old JSON):
|
||||
* - If trigger_rules.pattern is a non-empty string → engage_mode='pattern',
|
||||
* engage_pattern = that value
|
||||
* - Else if trigger_rules.requiresTrigger === false OR response_scope='all'
|
||||
* → engage_mode='pattern', engage_pattern='.'
|
||||
* - Else (requires trigger but no pattern specified) → engage_mode='mention'
|
||||
* - sender_scope: 'known' when response_scope was 'allowlisted', 'all' otherwise
|
||||
* - ignored_message_policy: 'drop' (conservative default; no old-schema analog)
|
||||
*/
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { Migration } from './index.js';
|
||||
|
||||
import { log } from '../../log.js';
|
||||
|
||||
interface LegacyRow {
|
||||
id: string;
|
||||
trigger_rules: string | null;
|
||||
response_scope: string | null;
|
||||
}
|
||||
|
||||
function backfill(row: LegacyRow): {
|
||||
engage_mode: 'pattern' | 'mention' | 'mention-sticky';
|
||||
engage_pattern: string | null;
|
||||
sender_scope: 'all' | 'known';
|
||||
ignored_message_policy: 'drop' | 'accumulate';
|
||||
} {
|
||||
let parsed: Record<string, unknown> = {};
|
||||
if (row.trigger_rules) {
|
||||
try {
|
||||
parsed = JSON.parse(row.trigger_rules) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Invalid JSON falls through to conservative defaults.
|
||||
}
|
||||
}
|
||||
|
||||
const pattern = typeof parsed.pattern === 'string' && parsed.pattern.length > 0 ? (parsed.pattern as string) : null;
|
||||
const requiresTrigger = parsed.requiresTrigger;
|
||||
|
||||
let engage_mode: 'pattern' | 'mention' | 'mention-sticky' = 'mention';
|
||||
let engage_pattern: string | null = null;
|
||||
if (pattern) {
|
||||
engage_mode = 'pattern';
|
||||
engage_pattern = pattern;
|
||||
} else if (requiresTrigger === false || row.response_scope === 'all') {
|
||||
engage_mode = 'pattern';
|
||||
engage_pattern = '.';
|
||||
}
|
||||
|
||||
const sender_scope: 'all' | 'known' = row.response_scope === 'allowlisted' ? 'known' : 'all';
|
||||
|
||||
return { engage_mode, engage_pattern, sender_scope, ignored_message_policy: 'drop' };
|
||||
}
|
||||
|
||||
export const migration010: Migration = {
|
||||
version: 10,
|
||||
name: 'engage-modes',
|
||||
up: (db: Database.Database) => {
|
||||
// Add the four new columns alongside the existing two. SQLite ALTER ADD
|
||||
// is cheap and non-rewriting.
|
||||
db.exec(`
|
||||
ALTER TABLE messaging_group_agents ADD COLUMN engage_mode TEXT;
|
||||
ALTER TABLE messaging_group_agents ADD COLUMN engage_pattern TEXT;
|
||||
ALTER TABLE messaging_group_agents ADD COLUMN sender_scope TEXT;
|
||||
ALTER TABLE messaging_group_agents ADD COLUMN ignored_message_policy TEXT;
|
||||
`);
|
||||
|
||||
// Backfill existing rows in JS (parsing JSON per-row is painful in pure SQL).
|
||||
const rows = db.prepare('SELECT id, trigger_rules, response_scope FROM messaging_group_agents').all() as LegacyRow[];
|
||||
const update = db.prepare(
|
||||
`UPDATE messaging_group_agents
|
||||
SET engage_mode = ?,
|
||||
engage_pattern = ?,
|
||||
sender_scope = ?,
|
||||
ignored_message_policy = ?
|
||||
WHERE id = ?`,
|
||||
);
|
||||
for (const row of rows) {
|
||||
const v = backfill(row);
|
||||
update.run(v.engage_mode, v.engage_pattern, v.sender_scope, v.ignored_message_policy, row.id);
|
||||
}
|
||||
|
||||
// Drop the legacy columns. DROP COLUMN requires SQLite 3.35+ (2021); our
|
||||
// better-sqlite3 ships a current build.
|
||||
db.exec(`
|
||||
ALTER TABLE messaging_group_agents DROP COLUMN trigger_rules;
|
||||
ALTER TABLE messaging_group_agents DROP COLUMN response_scope;
|
||||
`);
|
||||
|
||||
log.info('engage-modes migration: backfilled rows', { count: rows.length });
|
||||
},
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { migration002 } from './002-chat-sdk-state.js';
|
||||
import { moduleAgentToAgentDestinations } from './module-agent-to-agent-destinations.js';
|
||||
import { migration008 } from './008-dropped-messages.js';
|
||||
import { migration009 } from './009-drop-pending-credentials.js';
|
||||
import { migration010 } from './010-engage-modes.js';
|
||||
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
|
||||
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
|
||||
|
||||
@@ -23,6 +24,7 @@ const migrations: Migration[] = [
|
||||
moduleApprovalsTitleOptions,
|
||||
migration008,
|
||||
migration009,
|
||||
migration010,
|
||||
];
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
@@ -52,8 +54,8 @@ export function runMigrations(db: Database.Database): void {
|
||||
for (const m of pending) {
|
||||
db.transaction(() => {
|
||||
m.up(db);
|
||||
const next =
|
||||
(db.prepare('SELECT COALESCE(MAX(version), 0) + 1 AS v FROM schema_version').get() as { v: number }).v;
|
||||
const next = (db.prepare('SELECT COALESCE(MAX(version), 0) + 1 AS v FROM schema_version').get() as { v: number })
|
||||
.v;
|
||||
db.prepare('INSERT INTO schema_version (version, name, applied) VALUES (?, ?, ?)').run(
|
||||
next,
|
||||
m.name,
|
||||
|
||||
@@ -30,13 +30,20 @@ CREATE TABLE messaging_groups (
|
||||
UNIQUE(channel_type, platform_id)
|
||||
);
|
||||
|
||||
-- Which agent groups handle which messaging groups
|
||||
-- Which agent groups handle which messaging groups.
|
||||
-- engage_mode / engage_pattern / sender_scope / ignored_message_policy are
|
||||
-- the four orthogonal axes that together replace v1's opaque trigger_rules
|
||||
-- JSON + response_scope enum. See docs/v1-vs-v2/ACTION-ITEMS.md item 1.
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
trigger_rules TEXT,
|
||||
response_scope TEXT DEFAULT 'all',
|
||||
engage_mode TEXT NOT NULL DEFAULT 'mention',
|
||||
-- 'pattern' | 'mention' | 'mention-sticky'
|
||||
engage_pattern TEXT, -- regex; required when engage_mode='pattern';
|
||||
-- '.' means "match every message" (the "always" flavor)
|
||||
sender_scope TEXT NOT NULL DEFAULT 'all', -- 'all' | 'known'
|
||||
ignored_message_policy TEXT NOT NULL DEFAULT 'drop', -- 'drop' | 'accumulate'
|
||||
session_mode TEXT DEFAULT 'shared',
|
||||
priority INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -138,6 +145,8 @@ CREATE TABLE IF NOT EXISTS messages_in (
|
||||
recurrence TEXT,
|
||||
series_id TEXT,
|
||||
tries INTEGER DEFAULT 0,
|
||||
trigger INTEGER NOT NULL DEFAULT 1,
|
||||
-- 0 = accumulated context (don't wake), 1 = wake agent
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
|
||||
@@ -95,13 +95,19 @@ export function insertMessage(
|
||||
content: string;
|
||||
processAfter: string | null;
|
||||
recurrence: string | null;
|
||||
/**
|
||||
* 1 = wake the agent (default); 0 = accumulate as context only.
|
||||
* Host countDueMessages gates on this; container reads everything.
|
||||
*/
|
||||
trigger?: 0 | 1;
|
||||
},
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence, series_id)
|
||||
VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id)`,
|
||||
`INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence, series_id, trigger)
|
||||
VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id, @trigger)`,
|
||||
).run({
|
||||
...message,
|
||||
trigger: message.trigger ?? 1,
|
||||
seq: nextEvenSeq(db),
|
||||
});
|
||||
}
|
||||
@@ -112,6 +118,7 @@ export function countDueMessages(db: Database.Database): number {
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as count FROM messages_in
|
||||
WHERE status = 'pending'
|
||||
AND trigger = 1
|
||||
AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))`,
|
||||
)
|
||||
.get() as { count: number }
|
||||
@@ -169,9 +176,7 @@ export interface ProcessingClaim {
|
||||
/** Return processing_ack rows still in 'processing' with their claim timestamps. */
|
||||
export function getProcessingClaims(outDb: Database.Database): ProcessingClaim[] {
|
||||
return outDb
|
||||
.prepare(
|
||||
"SELECT message_id, status_changed FROM processing_ack WHERE status = 'processing'",
|
||||
)
|
||||
.prepare("SELECT message_id, status_changed FROM processing_ack WHERE status = 'processing'")
|
||||
.all() as ProcessingClaim[];
|
||||
}
|
||||
|
||||
@@ -262,10 +267,9 @@ export function migrateDeliveredTable(db: Database.Database): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Adds series_id (groups all occurrences of a recurring task) to pre-existing
|
||||
// messages_in tables. No-op on fresh installs where the column is in the schema.
|
||||
// Backfills existing rows so cancel/pause/resume queries can rely on
|
||||
// series_id IS NOT NULL.
|
||||
// Adds columns added to messages_in after the initial v2 schema to
|
||||
// pre-existing session DBs. No-op on fresh installs where the columns are
|
||||
// in the baseline schema. Backfills existing rows so invariants hold.
|
||||
export function migrateMessagesInTable(db: Database.Database): void {
|
||||
const cols = new Set(
|
||||
(db.prepare("PRAGMA table_info('messages_in')").all() as Array<{ name: string }>).map((c) => c.name),
|
||||
@@ -275,4 +279,9 @@ export function migrateMessagesInTable(db: Database.Database): void {
|
||||
db.prepare('UPDATE messages_in SET series_id = id WHERE series_id IS NULL').run();
|
||||
db.prepare('CREATE INDEX IF NOT EXISTS idx_messages_in_series ON messages_in(series_id)').run();
|
||||
}
|
||||
if (!cols.has('trigger')) {
|
||||
// All pre-existing rows got written with the old "every inbound wakes
|
||||
// the agent" semantics, so backfill 1 and default 1 for new inserts.
|
||||
db.prepare('ALTER TABLE messages_in ADD COLUMN trigger INTEGER NOT NULL DEFAULT 1').run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,31 @@ export function findSession(messagingGroupId: string, threadId: string | null):
|
||||
.get(messagingGroupId, 'active') as Session | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session lookup scoped to a specific agent group. Needed when multiple
|
||||
* agents are wired to the same messaging group + thread (fan-out) — the
|
||||
* plain `findSession` would return whichever agent's session happened to
|
||||
* be first and route to the wrong container.
|
||||
*/
|
||||
export function findSessionForAgent(
|
||||
agentGroupId: string,
|
||||
messagingGroupId: string,
|
||||
threadId: string | null,
|
||||
): Session | undefined {
|
||||
if (threadId) {
|
||||
return getDb()
|
||||
.prepare(
|
||||
"SELECT * FROM sessions WHERE agent_group_id = ? AND messaging_group_id = ? AND thread_id = ? AND status = 'active'",
|
||||
)
|
||||
.get(agentGroupId, messagingGroupId, threadId) as Session | undefined;
|
||||
}
|
||||
return getDb()
|
||||
.prepare(
|
||||
"SELECT * FROM sessions WHERE agent_group_id = ? AND messaging_group_id = ? AND thread_id IS NULL AND status = 'active'",
|
||||
)
|
||||
.get(agentGroupId, messagingGroupId) as Session | undefined;
|
||||
}
|
||||
|
||||
/** Find an active session scoped to an agent group (ignoring messaging group). */
|
||||
export function findSessionByAgentGroup(agentGroupId: string): Session | undefined {
|
||||
return getDb()
|
||||
|
||||
@@ -199,8 +199,10 @@ describe('router', () => {
|
||||
id: 'mga-1',
|
||||
messaging_group_id: 'mg-1',
|
||||
agent_group_id: 'ag-1',
|
||||
trigger_rules: null,
|
||||
response_scope: 'all',
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: now(),
|
||||
@@ -295,6 +297,106 @@ describe('router', () => {
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('fans out to every matching agent, each in its own session', async () => {
|
||||
const { routeInbound } = await import('./router.js');
|
||||
const { wakeContainer } = await import('./container-runner.js');
|
||||
(wakeContainer as unknown as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
// Wire a second agent to the same messaging group.
|
||||
createAgentGroup({
|
||||
id: 'ag-2',
|
||||
name: 'Secondary Agent',
|
||||
folder: 'secondary-agent',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroupAgent({
|
||||
id: 'mga-2',
|
||||
messaging_group_id: 'mg-1',
|
||||
agent_group_id: 'ag-2',
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
await routeInbound({
|
||||
channelType: 'discord',
|
||||
platformId: 'chan-123',
|
||||
threadId: null,
|
||||
message: { id: 'msg-fan', kind: 'chat', content: JSON.stringify({ text: 'hello all' }), timestamp: now() },
|
||||
});
|
||||
|
||||
// Both agents should now have their own session and be woken.
|
||||
expect(wakeContainer).toHaveBeenCalledTimes(2);
|
||||
|
||||
const { getSessionsByAgentGroup } = await import('./db/sessions.js');
|
||||
expect(getSessionsByAgentGroup('ag-1')).toHaveLength(1);
|
||||
expect(getSessionsByAgentGroup('ag-2')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('accumulates without waking when engage fails + ignored_message_policy=accumulate', async () => {
|
||||
const { routeInbound } = await import('./router.js');
|
||||
const { wakeContainer } = await import('./container-runner.js');
|
||||
(wakeContainer as unknown as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
// Replace the seed row with a mention-only wiring whose accumulate
|
||||
// policy should store context even when the message doesn't mention us.
|
||||
const { updateMessagingGroupAgent } = await import('./db/messaging-groups.js');
|
||||
updateMessagingGroupAgent('mga-1', {
|
||||
engage_mode: 'mention',
|
||||
ignored_message_policy: 'accumulate',
|
||||
});
|
||||
|
||||
await routeInbound({
|
||||
channelType: 'discord',
|
||||
platformId: 'chan-123',
|
||||
threadId: null,
|
||||
message: {
|
||||
id: 'msg-nomatch',
|
||||
kind: 'chat',
|
||||
content: JSON.stringify({ text: 'no mention here' }),
|
||||
timestamp: now(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(wakeContainer).not.toHaveBeenCalled();
|
||||
|
||||
const session = findSession('mg-1', null);
|
||||
expect(session).toBeDefined();
|
||||
const db = new Database(inboundDbPath('ag-1', session!.id));
|
||||
const rows = db.prepare('SELECT id, trigger FROM messages_in').all() as Array<{
|
||||
id: string;
|
||||
trigger: number;
|
||||
}>;
|
||||
db.close();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].trigger).toBe(0);
|
||||
});
|
||||
|
||||
it('drops silently when engage fails + ignored_message_policy=drop', async () => {
|
||||
const { routeInbound } = await import('./router.js');
|
||||
const { wakeContainer } = await import('./container-runner.js');
|
||||
(wakeContainer as unknown as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
const { updateMessagingGroupAgent } = await import('./db/messaging-groups.js');
|
||||
updateMessagingGroupAgent('mga-1', { engage_mode: 'mention' }); // drop is the default
|
||||
|
||||
await routeInbound({
|
||||
channelType: 'discord',
|
||||
platformId: 'chan-123',
|
||||
threadId: null,
|
||||
message: { id: 'msg-drop', kind: 'chat', content: JSON.stringify({ text: 'ignored' }), timestamp: now() },
|
||||
});
|
||||
|
||||
expect(wakeContainer).not.toHaveBeenCalled();
|
||||
// No session should have been created for this agent.
|
||||
expect(findSession('mg-1', null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delivery', () => {
|
||||
|
||||
@@ -158,12 +158,11 @@ function buildConversationConfigs(channelType: string): ConversationConfig[] {
|
||||
for (const mg of groups) {
|
||||
const agents = getMessagingGroupAgents(mg.id);
|
||||
for (const agent of agents) {
|
||||
const triggerRules = agent.trigger_rules ? JSON.parse(agent.trigger_rules) : null;
|
||||
configs.push({
|
||||
platformId: mg.platform_id,
|
||||
agentGroupId: agent.agent_group_id,
|
||||
triggerPattern: triggerRules?.pattern,
|
||||
requiresTrigger: triggerRules?.requiresTrigger ?? false,
|
||||
engageMode: agent.engage_mode,
|
||||
engagePattern: agent.engage_pattern,
|
||||
sessionMode: agent.session_mode,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,9 +16,15 @@
|
||||
* access gate is not registered and core defaults to allow-all.
|
||||
*/
|
||||
import { recordDroppedMessage } from '../../db/dropped-messages.js';
|
||||
import { setAccessGate, setSenderResolver, type AccessGateResult, type InboundEvent } from '../../router.js';
|
||||
import {
|
||||
setAccessGate,
|
||||
setSenderResolver,
|
||||
setSenderScopeGate,
|
||||
type AccessGateResult,
|
||||
type InboundEvent,
|
||||
} from '../../router.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { MessagingGroup } from '../../types.js';
|
||||
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';
|
||||
import { canAccessAgentGroup } from './access.js';
|
||||
import { getUser, upsertUser } from './db/users.js';
|
||||
|
||||
@@ -132,3 +138,21 @@ setAccessGate((event, userId, mg, agentGroupId): AccessGateResult => {
|
||||
handleUnknownSender(mg, userId, agentGroupId, decision.reason, event);
|
||||
return { allowed: false, reason: decision.reason };
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-wiring sender-scope enforcement. Stricter than the messaging-group
|
||||
* `unknown_sender_policy` — a wiring can require `sender_scope='known'`
|
||||
* (explicit owner / admin / member) even on a 'public' messaging group.
|
||||
*
|
||||
* 'all' is a no-op; any sender passes. 'known' requires a userId that
|
||||
* canAccessAgentGroup accepts (owner, admin, or group member).
|
||||
*/
|
||||
setSenderScopeGate(
|
||||
(_event: InboundEvent, userId: string | null, _mg: MessagingGroup, agent: MessagingGroupAgent): AccessGateResult => {
|
||||
if (agent.sender_scope === 'all') return { allowed: true };
|
||||
if (!userId) return { allowed: false, reason: 'unknown_user_scope' };
|
||||
const decision = canAccessAgentGroup(userId, agent.agent_group_id);
|
||||
if (decision.allowed) return { allowed: true };
|
||||
return { allowed: false, reason: `sender_scope_${decision.reason}` };
|
||||
},
|
||||
);
|
||||
|
||||
197
src/router.ts
197
src/router.ts
@@ -18,14 +18,16 @@
|
||||
* for policy refusals.
|
||||
*/
|
||||
import { getChannelAdapter } from './channels/channel-registry.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import { recordDroppedMessage } from './db/dropped-messages.js';
|
||||
import { getMessagingGroupByPlatform, createMessagingGroup, getMessagingGroupAgents } from './db/messaging-groups.js';
|
||||
import { findSessionForAgent } from './db/sessions.js';
|
||||
import { startTypingRefresh } from './modules/typing/index.js';
|
||||
import { log } from './log.js';
|
||||
import { resolveSession, writeSessionMessage } from './session-manager.js';
|
||||
import { wakeContainer } from './container-runner.js';
|
||||
import { getSession } from './db/sessions.js';
|
||||
import type { MessagingGroup, MessagingGroupAgent } from './types.js';
|
||||
import type { AgentGroup, MessagingGroup, MessagingGroupAgent } from './types.js';
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -89,6 +91,29 @@ export function setAccessGate(fn: AccessGateFn): void {
|
||||
accessGate = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-wiring sender-scope hook. Runs alongside the access gate for each
|
||||
* agent that would otherwise engage — lets the permissions module enforce
|
||||
* `sender_scope='known'` on wirings that are stricter than the messaging
|
||||
* group's `unknown_sender_policy`. When the hook isn't registered (module
|
||||
* not installed), sender_scope is a no-op.
|
||||
*/
|
||||
export type SenderScopeGateFn = (
|
||||
event: InboundEvent,
|
||||
userId: string | null,
|
||||
mg: MessagingGroup,
|
||||
agent: MessagingGroupAgent,
|
||||
) => AccessGateResult;
|
||||
|
||||
let senderScopeGate: SenderScopeGateFn | null = null;
|
||||
|
||||
export function setSenderScopeGate(fn: SenderScopeGateFn): void {
|
||||
if (senderScopeGate) {
|
||||
log.warn('Sender-scope gate overwritten');
|
||||
}
|
||||
senderScopeGate = fn;
|
||||
}
|
||||
|
||||
function safeParseContent(raw: string): { text?: string; sender?: string; senderId?: string } {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
@@ -158,91 +183,167 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const match = pickAgent(agents, event);
|
||||
if (!match) {
|
||||
log.warn('MESSAGE DROPPED — no agent matched trigger rules', {
|
||||
messagingGroupId: mg.id,
|
||||
channelType: event.channelType,
|
||||
});
|
||||
// 4. Fan-out: evaluate each wired agent independently against engage_mode,
|
||||
// sender_scope, and access gate. An agent that engages gets its own
|
||||
// session and container wake. An agent that declines but has
|
||||
// ignored_message_policy='accumulate' still gets the message stored in
|
||||
// its session (trigger=0) so the context is available when it does
|
||||
// engage later. Drop policy = skip silently.
|
||||
const parsed = safeParseContent(event.message.content);
|
||||
const messageText = parsed.text ?? '';
|
||||
|
||||
let engagedCount = 0;
|
||||
let accumulatedCount = 0;
|
||||
|
||||
for (const agent of agents) {
|
||||
const agentGroup = getAgentGroup(agent.agent_group_id);
|
||||
if (!agentGroup) continue;
|
||||
|
||||
const engages = evaluateEngage(agent, agentGroup, messageText, mg, event.threadId);
|
||||
|
||||
const accessOk = engages && (!accessGate || accessGate(event, userId, mg, agent.agent_group_id).allowed);
|
||||
const scopeOk = engages && (!senderScopeGate || senderScopeGate(event, userId, mg, agent).allowed);
|
||||
|
||||
if (engages && accessOk && scopeOk) {
|
||||
await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, true);
|
||||
engagedCount++;
|
||||
} else if (agent.ignored_message_policy === 'accumulate') {
|
||||
await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, false);
|
||||
accumulatedCount++;
|
||||
} else {
|
||||
log.debug('Message not engaged for agent (drop policy)', {
|
||||
agentGroupId: agent.agent_group_id,
|
||||
engage_mode: agent.engage_mode,
|
||||
engages,
|
||||
accessOk,
|
||||
scopeOk,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (engagedCount + accumulatedCount === 0) {
|
||||
recordDroppedMessage({
|
||||
channel_type: event.channelType,
|
||||
platform_id: event.platformId,
|
||||
user_id: userId,
|
||||
sender_name: parsed.sender ?? null,
|
||||
reason: 'no_trigger_match',
|
||||
reason: 'no_agent_engaged',
|
||||
messaging_group_id: mg.id,
|
||||
agent_group_id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Access gate (if the permissions module is loaded). Otherwise
|
||||
// allow-all.
|
||||
if (accessGate) {
|
||||
const result = accessGate(event, userId, mg, match.agent_group_id);
|
||||
if (!result.allowed) {
|
||||
log.info('MESSAGE DROPPED — access gate refused', {
|
||||
messagingGroupId: mg.id,
|
||||
agentGroupId: match.agent_group_id,
|
||||
userId,
|
||||
reason: result.reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Resolve or create session.
|
||||
//
|
||||
// Adapter thread policy overrides the wiring's session_mode: if the adapter
|
||||
// is threaded, each thread gets its own session regardless of what the
|
||||
// wiring says. Agent-shared is preserved because it expresses a
|
||||
// cross-channel intent the adapter can't know about.
|
||||
//
|
||||
// Exception: DMs (is_group=0). Sub-threads within a DM are a UX affordance,
|
||||
// not a conversation boundary — treat the whole DM as one session and let
|
||||
// threadId flow through to delivery so replies land in the right sub-thread.
|
||||
let effectiveSessionMode = match.session_mode;
|
||||
if (adapter && adapter.supportsThreads && effectiveSessionMode !== 'agent-shared' && mg.is_group !== 0) {
|
||||
/**
|
||||
* Decide whether a given wired agent should engage on this message.
|
||||
*
|
||||
* 'pattern' — regex test on text; '.' = always
|
||||
* 'mention' — bot must be @-mentioned by its agent-group name
|
||||
* 'mention-sticky' — @mention OR an active per-thread session already
|
||||
* exists for this (agent, mg, thread). The session
|
||||
* existence IS our subscription state; once a thread
|
||||
* has engaged us once, follow-ups arrive with no
|
||||
* mention and should still fire.
|
||||
*/
|
||||
function evaluateEngage(
|
||||
agent: MessagingGroupAgent,
|
||||
agentGroup: AgentGroup,
|
||||
text: string,
|
||||
mg: MessagingGroup,
|
||||
threadId: string | null,
|
||||
): boolean {
|
||||
switch (agent.engage_mode) {
|
||||
case 'pattern': {
|
||||
const pat = agent.engage_pattern ?? '.';
|
||||
if (pat === '.') return true;
|
||||
try {
|
||||
return new RegExp(pat).test(text);
|
||||
} catch {
|
||||
// Bad regex: fail open so admin sees the agent responding + can fix.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
case 'mention':
|
||||
return hasMention(text, agentGroup.name);
|
||||
case 'mention-sticky': {
|
||||
if (hasMention(text, agentGroup.name)) return true;
|
||||
// Sticky follow-up: session already exists for this (agent, mg, thread)
|
||||
// — the thread was activated before, keep firing.
|
||||
if (mg.is_group === 0) return false; // DMs never use mention-sticky sensibly
|
||||
const existing = findSessionForAgent(agent.agent_group_id, mg.id, threadId);
|
||||
return existing !== undefined;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasMention(text: string, agentName: string): boolean {
|
||||
if (!agentName) return false;
|
||||
const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`@${escaped}\\b`, 'i').test(text);
|
||||
}
|
||||
|
||||
async function deliverToAgent(
|
||||
agent: MessagingGroupAgent,
|
||||
agentGroup: AgentGroup,
|
||||
mg: MessagingGroup,
|
||||
event: InboundEvent,
|
||||
userId: string | null,
|
||||
adapterSupportsThreads: boolean,
|
||||
wake: boolean,
|
||||
): Promise<void> {
|
||||
// Apply the adapter thread policy: threaded adapter in a group chat →
|
||||
// per-thread session regardless of wiring. agent-shared preserved (it's
|
||||
// a cross-channel directive the adapter doesn't know about). DMs collapse
|
||||
// sub-threads to one session (is_group=0 short-circuit).
|
||||
let effectiveSessionMode = agent.session_mode;
|
||||
if (adapterSupportsThreads && effectiveSessionMode !== 'agent-shared' && mg.is_group !== 0) {
|
||||
effectiveSessionMode = 'per-thread';
|
||||
}
|
||||
const { session, created } = resolveSession(match.agent_group_id, mg.id, event.threadId, effectiveSessionMode);
|
||||
|
||||
// 6. Write message to session DB
|
||||
const { session, created } = resolveSession(agent.agent_group_id, mg.id, event.threadId, effectiveSessionMode);
|
||||
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
id: event.message.id || generateId(),
|
||||
id: messageIdForAgent(event.message.id, agent.agent_group_id),
|
||||
kind: event.message.kind,
|
||||
timestamp: event.message.timestamp,
|
||||
platformId: event.platformId,
|
||||
channelType: event.channelType,
|
||||
threadId: event.threadId,
|
||||
content: event.message.content,
|
||||
trigger: wake ? 1 : 0,
|
||||
});
|
||||
|
||||
log.info('Message routed', {
|
||||
sessionId: session.id,
|
||||
agentGroup: match.agent_group_id,
|
||||
agentGroup: agent.agent_group_id,
|
||||
engage_mode: agent.engage_mode,
|
||||
kind: event.message.kind,
|
||||
userId,
|
||||
wake,
|
||||
created,
|
||||
agentGroupName: agentGroup.name,
|
||||
});
|
||||
|
||||
// 7. Show typing indicator while the agent processes.
|
||||
if (wake) {
|
||||
// Typing indicator + wake are only for the engaged branch; accumulated
|
||||
// messages sit silently until a real trigger fires.
|
||||
startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId);
|
||||
|
||||
// 8. Wake container
|
||||
const freshSession = getSession(session.id);
|
||||
if (freshSession) {
|
||||
await wakeContainer(freshSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the matching agent for an inbound event.
|
||||
* Currently: highest priority agent. Future: trigger rule matching.
|
||||
* When fanning out, the same inbound message lands in multiple per-agent
|
||||
* session DBs. messages_in.id is PRIMARY KEY, so reuse of the raw id would
|
||||
* collide across sessions (or, more subtly, within one session if re-routed
|
||||
* after a retry). Namespace by agent_group_id to keep ids unique per session.
|
||||
*/
|
||||
function pickAgent(agents: MessagingGroupAgent[], _event: InboundEvent): MessagingGroupAgent | null {
|
||||
// Agents are already ordered by priority DESC from the DB query
|
||||
// TODO: apply trigger_rules matching (pattern, mentionOnly, etc.)
|
||||
return agents[0] ?? null;
|
||||
function messageIdForAgent(baseId: string | undefined, agentGroupId: string): string {
|
||||
const id = baseId && baseId.length > 0 ? baseId : generateId();
|
||||
return `${id}:${agentGroupId}`;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,14 @@ import path from 'path';
|
||||
import type { OutboundFile } from './channels/adapter.js';
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { getMessagingGroup } from './db/messaging-groups.js';
|
||||
import { createSession, findSession, findSessionByAgentGroup, getSession, updateSession } from './db/sessions.js';
|
||||
import {
|
||||
createSession,
|
||||
findSession,
|
||||
findSessionByAgentGroup,
|
||||
findSessionForAgent,
|
||||
getSession,
|
||||
updateSession,
|
||||
} from './db/sessions.js';
|
||||
import {
|
||||
ensureSchema,
|
||||
openInboundDb as openInboundDbRaw,
|
||||
@@ -89,7 +96,9 @@ export function resolveSession(
|
||||
}
|
||||
} else if (messagingGroupId) {
|
||||
const lookupThreadId = sessionMode === 'shared' ? null : threadId;
|
||||
const existing = findSession(messagingGroupId, lookupThreadId);
|
||||
// Scope lookup by agent_group_id so fan-out to multiple agents in the
|
||||
// same chat doesn't accidentally deliver to the wrong agent's session.
|
||||
const existing = findSessionForAgent(agentGroupId, messagingGroupId, lookupThreadId);
|
||||
if (existing) {
|
||||
return { session: existing, created: false };
|
||||
}
|
||||
@@ -187,6 +196,13 @@ export function writeSessionMessage(
|
||||
content: string;
|
||||
processAfter?: string | null;
|
||||
recurrence?: string | null;
|
||||
/**
|
||||
* 1 = this message should wake the agent (the default); 0 = accumulate
|
||||
* as context only, don't wake. Host's countDueMessages gates on this
|
||||
* column; the container still reads all prior messages as context when
|
||||
* a trigger-1 message does arrive.
|
||||
*/
|
||||
trigger?: 0 | 1;
|
||||
},
|
||||
): void {
|
||||
// Extract base64 attachment data, save to inbox, replace with file paths
|
||||
@@ -204,6 +220,7 @@ export function writeSessionMessage(
|
||||
content,
|
||||
processAfter: message.processAfter ?? null,
|
||||
recurrence: message.recurrence ?? null,
|
||||
trigger: message.trigger ?? 1,
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
|
||||
15
src/types.ts
15
src/types.ts
@@ -67,12 +67,23 @@ export interface UserDm {
|
||||
resolved_at: string;
|
||||
}
|
||||
|
||||
export type EngageMode = 'pattern' | 'mention' | 'mention-sticky';
|
||||
export type SenderScope = 'all' | 'known';
|
||||
export type IgnoredMessagePolicy = 'drop' | 'accumulate';
|
||||
|
||||
export interface MessagingGroupAgent {
|
||||
id: string;
|
||||
messaging_group_id: string;
|
||||
agent_group_id: string;
|
||||
trigger_rules: string | null; // JSON: { pattern, mentionOnly, excludeSenders, includeSenders }
|
||||
response_scope: 'all' | 'triggered' | 'allowlisted';
|
||||
engage_mode: EngageMode;
|
||||
/**
|
||||
* Regex source string used when engage_mode='pattern'. `'.'` is the sentinel
|
||||
* for "match every message" (the "always" flavor). Ignored for 'mention' /
|
||||
* 'mention-sticky' modes.
|
||||
*/
|
||||
engage_pattern: string | null;
|
||||
sender_scope: SenderScope;
|
||||
ignored_message_policy: IgnoredMessagePolicy;
|
||||
session_mode: 'shared' | 'per-thread' | 'agent-shared';
|
||||
priority: number;
|
||||
created_at: string;
|
||||
|
||||
Reference in New Issue
Block a user