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