diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index da1a8dd..a152a5e 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -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. */ diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index efb3b6b..d7ff0df 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -195,8 +195,13 @@ async function main(): Promise { 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 { 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, diff --git a/scripts/seed-discord.ts b/scripts/seed-discord.ts index 9aed1c5..3ea24e8 100644 --- a/scripts/seed-discord.ts +++ b/scripts/seed-discord.ts @@ -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(), diff --git a/scripts/test-v2-channel-e2e.ts b/scripts/test-v2-channel-e2e.ts index fc0a570..6721ff0 100644 --- a/scripts/test-v2-channel-e2e.ts +++ b/scripts/test-v2-channel-e2e.ts @@ -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, diff --git a/scripts/test-v2-host.ts b/scripts/test-v2-host.ts index b82bc99..2e49a3b 100644 --- a/scripts/test-v2-host.ts +++ b/scripts/test-v2-host.ts @@ -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(), diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 55efde1..33f3825 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -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'; } diff --git a/src/channels/channel-registry.test.ts b/src/channels/channel-registry.test.ts index 0e856f6..265a372 100644 --- a/src/channels/channel-registry.test.ts +++ b/src/channels/channel-registry.test.ts @@ -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(), diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 2d45b29..593a2ad 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -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; + // 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; let gatewayAbort: AbortController | null = null; - function buildConversationMap(configs: ConversationConfig[]): Map { - const map = new Map(); + function buildConversationMap(configs: ConversationConfig[]): Map { + const map = new Map(); 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 { // eslint-disable-next-line @typescript-eslint/no-explicit-any const serialized = message.toJSON() as Record; @@ -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)); - await thread.subscribe(); + 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)); - await thread.subscribe(); + if (decision.stickySubscribe) { + await thread.subscribe(); + } }); // Handle button clicks (ask_user_question) diff --git a/src/container-runner.ts b/src/container-runner.ts index 9764126..b357a0d 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -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) { diff --git a/src/db/db-v2.test.ts b/src/db/db-v2.test.ts index f8689eb..e0cebdf 100644 --- a/src/db/db-v2.test.ts +++ b/src/db/db-v2.test.ts @@ -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'); diff --git a/src/db/messaging-groups.ts b/src/db/messaging-groups.ts index 0c0ba22..db12583 100644 --- a/src/db/messaging-groups.ts +++ b/src/db/messaging-groups.ts @@ -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>, + updates: Partial< + Pick< + MessagingGroupAgent, + 'engage_mode' | 'engage_pattern' | 'sender_scope' | 'ignored_message_policy' | 'session_mode' | 'priority' + > + >, ): void { const fields: string[] = []; const values: Record = { id }; diff --git a/src/db/migrations/010-engage-modes.ts b/src/db/migrations/010-engage-modes.ts new file mode 100644 index 0000000..4bf9798 --- /dev/null +++ b/src/db/migrations/010-engage-modes.ts @@ -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 = {}; + if (row.trigger_rules) { + try { + parsed = JSON.parse(row.trigger_rules) as Record; + } 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 }); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 3a87797..d220688 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -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, diff --git a/src/db/schema.ts b/src/db/schema.ts index 47d4c9f..9dd887e 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -30,16 +30,23 @@ 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', - session_mode TEXT DEFAULT 'shared', - priority INTEGER DEFAULT 0, - created_at TEXT NOT NULL, + id TEXT PRIMARY KEY, + messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id), + agent_group_id TEXT NOT NULL REFERENCES agent_groups(id), + 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, UNIQUE(messaging_group_id, agent_group_id) ); @@ -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, diff --git a/src/db/session-db.ts b/src/db/session-db.ts index a73ca5c..aea255d 100644 --- a/src/db/session-db.ts +++ b/src/db/session-db.ts @@ -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(); + } } diff --git a/src/db/sessions.ts b/src/db/sessions.ts index 01e48cd..bdca8a6 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -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() diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 7269164..33d37ff 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -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).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).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).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', () => { diff --git a/src/index.ts b/src/index.ts index ffb2731..9bb51be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, }); } diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index e7cc282..ca97f8f 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -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}` }; + }, +); diff --git a/src/router.ts b/src/router.ts index 8971f7f..9b54cb2 100644 --- a/src/router.ts +++ b/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 { return; } - const match = pickAgent(agents, event); - if (!match) { - log.warn('MESSAGE DROPPED — no agent matched trigger rules', { - messagingGroupId: mg.id, - channelType: event.channelType, - }); - const parsed = safeParseContent(event.message.content); + // 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; +/** + * 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; } +} - // 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) { +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 { + // 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. - 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); + 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); + 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}`; } diff --git a/src/session-manager.ts b/src/session-manager.ts index 7aaef24..2a5ac1d 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -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(); diff --git a/src/types.ts b/src/types.ts index ad14441..b2674da 100644 --- a/src/types.ts +++ b/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;