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:
gavrielc
2026-04-20 01:30:04 +03:00
parent 6a815190c0
commit 16b9499532
22 changed files with 688 additions and 125 deletions

View File

@@ -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. */

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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';
}

View File

@@ -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(),

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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');

View File

@@ -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 };

View 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 });
},
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -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();
}
}

View File

@@ -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()

View File

@@ -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', () => {

View File

@@ -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,
});
}

View File

@@ -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}` };
},
);

View File

@@ -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}`;
}

View File

@@ -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();

View File

@@ -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;