refactor(modules): extract agent-to-agent as registry-based module
Last extraction of Phase 3. Moves inter-agent messaging + create_agent +
destination projection into src/modules/agent-to-agent/. Core retains:
- `channel_type === 'agent'` dispatch in delivery.ts, guarded by
hasTable('agent_destinations') + dynamic import into module.
- Channel-permission ACL in delivery.ts, guarded by hasTable, with
inlined SQL (no module import from core).
- writeDestinations call in container-runner.ts, guarded by hasTable +
dynamic import into module.
- createMessagingGroupAgent's destination side effect in db/messaging-groups.ts,
guarded by hasTable. This is a documented transitional tier violation
(core imports from optional module), analogous to src/access.ts.
Migration `004-agent-destinations.ts` renamed to `module-agent-to-agent-
destinations.ts` preserving `name: 'agent-destinations'` so existing DBs
don't re-run it.
delivery.ts: 600 → 449 lines. handleSystemAction's last switch case gone
(just registry + default log-and-drop). notifyAgent helper removed (only
create_agent used it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,6 @@ import {
|
|||||||
markContainerRunning,
|
markContainerRunning,
|
||||||
markContainerStopped,
|
markContainerStopped,
|
||||||
sessionDir,
|
sessionDir,
|
||||||
writeDestinations,
|
|
||||||
writeSessionRouting,
|
writeSessionRouting,
|
||||||
} from './session-manager.js';
|
} from './session-manager.js';
|
||||||
import type { AgentGroup, Session } from './types.js';
|
import type { AgentGroup, Session } from './types.js';
|
||||||
@@ -90,8 +89,12 @@ async function spawnContainer(session: Session): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the destination map and default reply routing so any admin
|
// Refresh the destination map and default reply routing so any admin
|
||||||
// changes take effect on wake.
|
// changes take effect on wake. Destinations come from the agent-to-agent
|
||||||
writeDestinations(agentGroup.id, session.id);
|
// module — skip when the module isn't installed (table absent).
|
||||||
|
if (hasTable(getDb(), 'agent_destinations')) {
|
||||||
|
const { writeDestinations } = await import('./modules/agent-to-agent/write-destinations.js');
|
||||||
|
writeDestinations(agentGroup.id, session.id);
|
||||||
|
}
|
||||||
writeSessionRouting(agentGroup.id, session.id);
|
writeSessionRouting(agentGroup.id, session.id);
|
||||||
|
|
||||||
// Resolve the effective provider + any host-side contribution it declares
|
// Resolve the effective provider + any host-side contribution it declares
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ 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('./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');
|
||||||
@@ -240,7 +240,7 @@ describe('messaging group agents', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not duplicate destination row on re-wiring', async () => {
|
it('does not duplicate destination row on re-wiring', async () => {
|
||||||
const { getDestinations } = await import('./agent-destinations.js');
|
const { getDestinations } = await import('../modules/agent-to-agent/db/agent-destinations.js');
|
||||||
createMessagingGroupAgent(mga());
|
createMessagingGroupAgent(mga());
|
||||||
// Re-create the same wiring throws (PK unique), but even if we got the
|
// Re-create the same wiring throws (PK unique), but even if we got the
|
||||||
// row in some other way (e.g. via createDestination directly followed
|
// row in some other way (e.g. via createDestination directly followed
|
||||||
@@ -251,7 +251,7 @@ describe('messaging group agents', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('breaks local_name collisions within an agent group', async () => {
|
it('breaks local_name collisions within an agent group', async () => {
|
||||||
const { getDestinations } = await import('./agent-destinations.js');
|
const { getDestinations } = await import('../modules/agent-to-agent/db/agent-destinations.js');
|
||||||
// Two messaging groups with the same `name` wired to the same agent
|
// Two messaging groups with the same `name` wired to the same agent
|
||||||
// should get distinct local_names (gen, gen-2).
|
// should get distinct local_names (gen, gen-2).
|
||||||
createMessagingGroupAgent(mga());
|
createMessagingGroupAgent(mga());
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import type { MessagingGroup, MessagingGroupAgent } from '../types.js';
|
import type { MessagingGroup, MessagingGroupAgent } from '../types.js';
|
||||||
|
// Transitional tier violation: core imports from optional agent-to-agent module.
|
||||||
|
// `createMessagingGroupAgent` auto-creates a destination row on wiring — the
|
||||||
|
// two concerns are currently bundled. When agent-to-agent isn't installed,
|
||||||
|
// the table doesn't exist and this import chain remains dormant because
|
||||||
|
// `createMessagingGroupAgent` is only called from setup/admin paths that
|
||||||
|
// also only run when wiring channels to agents (which implicitly requires
|
||||||
|
// agent-to-agent for the destination ACL to mean anything). A cleaner split
|
||||||
|
// (or making the destination side effect module-owned) is tracked in the
|
||||||
|
// refactor plan.
|
||||||
import {
|
import {
|
||||||
createDestination,
|
createDestination,
|
||||||
getDestinationByName,
|
getDestinationByName,
|
||||||
getDestinationByTarget,
|
getDestinationByTarget,
|
||||||
normalizeName,
|
normalizeName,
|
||||||
} from './agent-destinations.js';
|
} from '../modules/agent-to-agent/db/agent-destinations.js';
|
||||||
import { getDb } from './connection.js';
|
import { getDb, hasTable } from './connection.js';
|
||||||
|
|
||||||
// ── Messaging Groups ──
|
// ── Messaging Groups ──
|
||||||
|
|
||||||
@@ -84,21 +93,27 @@ export function createMessagingGroupAgent(mga: MessagingGroupAgent): void {
|
|||||||
.run(mga);
|
.run(mga);
|
||||||
|
|
||||||
// Auto-create an agent_destinations row so delivery's ACL doesn't block
|
// Auto-create an agent_destinations row so delivery's ACL doesn't block
|
||||||
// outbound messages that target this chat.
|
// outbound messages that target this chat. Guarded: when the agent-to-agent
|
||||||
|
// module isn't installed the table doesn't exist — skip silently. Without
|
||||||
|
// the module, the ACL check in delivery is also skipped (same guard), so
|
||||||
|
// channel sends still work.
|
||||||
//
|
//
|
||||||
// ⚠️ DESTINATION PROJECTION NOTE: this function only writes the central
|
// ⚠️ DESTINATION PROJECTION NOTE: this function only writes the central
|
||||||
// `agent_destinations` row. It does NOT project into any running
|
// `agent_destinations` row. It does NOT project into any running
|
||||||
// agent's session inbound.db (see top-of-file invariant in
|
// agent's session inbound.db (see top-of-file invariant in
|
||||||
// src/db/agent-destinations.ts). In practice this is fine because the
|
// src/modules/agent-to-agent/db/agent-destinations.ts). In practice this
|
||||||
// only real callers are one-shot setup scripts (setup/register.ts,
|
// is fine because the only real callers are one-shot setup scripts
|
||||||
// scripts/init-first-agent.ts, /manage-channels skill) that run in a
|
// (setup/register.ts, scripts/init-first-agent.ts, /manage-channels
|
||||||
// separate process from the host. Any already-running container for
|
// skill) that run in a separate process from the host. Any already-
|
||||||
// `mga.agent_group_id` will keep serving the stale projection until
|
// running container for `mga.agent_group_id` will keep serving the
|
||||||
// its next wake (idle timeout or next inbound message) at which
|
// stale projection until its next wake (idle timeout or next inbound
|
||||||
// point spawnContainer's writeDestinations call refreshes from central.
|
// message) at which point spawnContainer's writeDestinations call
|
||||||
// If you call this from code that runs INSIDE the host process and
|
// refreshes from central. If you call this from code that runs INSIDE
|
||||||
// need the refresh to happen immediately, explicitly call
|
// the host process and need the refresh to happen immediately,
|
||||||
// `writeDestinations(mga.agent_group_id, <sessionId>)` afterwards.
|
// explicitly call the module's `writeDestinations(mga.agent_group_id,
|
||||||
|
// <sessionId>)` afterwards.
|
||||||
|
if (!hasTable(getDb(), 'agent_destinations')) return;
|
||||||
|
|
||||||
const existing = getDestinationByTarget(mga.agent_group_id, 'channel', mga.messaging_group_id);
|
const existing = getDestinationByTarget(mga.agent_group_id, 'channel', mga.messaging_group_id);
|
||||||
if (existing) return;
|
if (existing) return;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type Database from 'better-sqlite3';
|
|||||||
import { log } from '../../log.js';
|
import { log } from '../../log.js';
|
||||||
import { migration001 } from './001-initial.js';
|
import { migration001 } from './001-initial.js';
|
||||||
import { migration002 } from './002-chat-sdk-state.js';
|
import { migration002 } from './002-chat-sdk-state.js';
|
||||||
import { migration004 } from './004-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 { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
|
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
|
||||||
@@ -19,7 +19,7 @@ const migrations: Migration[] = [
|
|||||||
migration001,
|
migration001,
|
||||||
migration002,
|
migration002,
|
||||||
moduleApprovalsPendingApprovals,
|
moduleApprovalsPendingApprovals,
|
||||||
migration004,
|
moduleAgentToAgentDestinations,
|
||||||
moduleApprovalsTitleOptions,
|
moduleApprovalsTitleOptions,
|
||||||
migration008,
|
migration008,
|
||||||
migration009,
|
migration009,
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import type { Migration } from './index.js';
|
|||||||
* while admin calls the child "worker-1". The (agent_group_id, local_name)
|
* while admin calls the child "worker-1". The (agent_group_id, local_name)
|
||||||
* PK enforces uniqueness within a single agent's namespace only.
|
* PK enforces uniqueness within a single agent's namespace only.
|
||||||
*/
|
*/
|
||||||
export const migration004: Migration = {
|
// Retains the original `name` ('agent-destinations') so existing DBs that
|
||||||
|
// already recorded this migration under that name don't re-run it. The
|
||||||
|
// module- prefix lives on the filename / export identifier only.
|
||||||
|
export const moduleAgentToAgentDestinations: Migration = {
|
||||||
version: 4,
|
version: 4,
|
||||||
name: 'agent-destinations',
|
name: 'agent-destinations',
|
||||||
up(db: Database.Database) {
|
up(db: Database.Database) {
|
||||||
201
src/delivery.ts
201
src/delivery.ts
@@ -11,10 +11,8 @@ import type Database from 'better-sqlite3';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { GROUPS_DIR } from './config.js';
|
|
||||||
import { getRunningSessions, getActiveSessions, createPendingQuestion, getSession } from './db/sessions.js';
|
import { getRunningSessions, getActiveSessions, createPendingQuestion, getSession } from './db/sessions.js';
|
||||||
import { getAgentGroup, createAgentGroup, updateAgentGroup, getAgentGroupByFolder } from './db/agent-groups.js';
|
import { getAgentGroup } from './db/agent-groups.js';
|
||||||
import { createDestination, getDestinationByName, hasDestination, normalizeName } from './db/agent-destinations.js';
|
|
||||||
import { getDb, hasTable } from './db/connection.js';
|
import { getDb, hasTable } from './db/connection.js';
|
||||||
import { getMessagingGroupByPlatform } from './db/messaging-groups.js';
|
import { getMessagingGroupByPlatform } from './db/messaging-groups.js';
|
||||||
import {
|
import {
|
||||||
@@ -26,19 +24,11 @@ import {
|
|||||||
} from './db/session-db.js';
|
} from './db/session-db.js';
|
||||||
import { log } from './log.js';
|
import { log } from './log.js';
|
||||||
import { normalizeOptions } from './channels/ask-question.js';
|
import { normalizeOptions } from './channels/ask-question.js';
|
||||||
import {
|
import { openInboundDb, openOutboundDb, sessionDir, writeSessionMessage } from './session-manager.js';
|
||||||
openInboundDb,
|
|
||||||
openOutboundDb,
|
|
||||||
sessionDir,
|
|
||||||
resolveSession,
|
|
||||||
writeDestinations,
|
|
||||||
writeSessionMessage,
|
|
||||||
} from './session-manager.js';
|
|
||||||
import { resetContainerIdleTimer, wakeContainer } from './container-runner.js';
|
import { resetContainerIdleTimer, wakeContainer } from './container-runner.js';
|
||||||
import { initGroupFilesystem } from './group-init.js';
|
|
||||||
import { pauseTypingRefreshAfterDelivery, setTypingAdapter } from './modules/typing/index.js';
|
import { pauseTypingRefreshAfterDelivery, setTypingAdapter } from './modules/typing/index.js';
|
||||||
import type { OutboundFile } from './channels/adapter.js';
|
import type { OutboundFile } from './channels/adapter.js';
|
||||||
import type { AgentGroup, Session } from './types.js';
|
import type { Session } from './types.js';
|
||||||
|
|
||||||
const ACTIVE_POLL_MS = 1000;
|
const ACTIVE_POLL_MS = 1000;
|
||||||
const SWEEP_POLL_MS = 60_000;
|
const SWEEP_POLL_MS = 60_000;
|
||||||
@@ -117,29 +107,6 @@ export function setDeliveryAdapter(adapter: ChannelDeliveryAdapter): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Deliver a system notification to an agent as a regular chat message.
|
|
||||||
* Used for fire-and-forget responses from host actions (create_agent result,
|
|
||||||
* approval outcomes, etc.). The agent sees it as an inbound chat message
|
|
||||||
* with sender="system".
|
|
||||||
*/
|
|
||||||
function notifyAgent(session: Session, text: string): void {
|
|
||||||
writeSessionMessage(session.agent_group_id, session.id, {
|
|
||||||
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
||||||
kind: 'chat',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
platformId: session.agent_group_id,
|
|
||||||
channelType: 'agent',
|
|
||||||
threadId: null,
|
|
||||||
content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
|
|
||||||
});
|
|
||||||
// Wake the container so it picks up the notification promptly
|
|
||||||
const fresh = getSession(session.id);
|
|
||||||
if (fresh) {
|
|
||||||
wakeContainer(fresh).catch((err) => log.error('Failed to wake container after notification', { err }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Start the active container poll loop (~1s). */
|
/** Start the active container poll loop (~1s). */
|
||||||
export function startActiveDeliveryPoll(): void {
|
export function startActiveDeliveryPoll(): void {
|
||||||
if (activePolling) return;
|
if (activePolling) return;
|
||||||
@@ -293,45 +260,16 @@ async function deliverMessage(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent-to-agent — route to target session (with permission check).
|
// Agent-to-agent — route to target session via the agent-to-agent module.
|
||||||
// Permission is enforced via agent_destinations — the source agent must have
|
// Guarded by the channel_type check. If the module isn't installed the
|
||||||
// a row for the target. Content is copied verbatim; the target's formatter
|
// `agent_destinations` table won't exist and `routeAgentMessage`'s permission
|
||||||
// will look up the source agent in its own local map to display a name.
|
// check will throw, which falls into the normal retry → mark-failed path.
|
||||||
if (msg.channel_type === 'agent') {
|
if (msg.channel_type === 'agent') {
|
||||||
const targetAgentGroupId = msg.platform_id;
|
if (!hasTable(getDb(), 'agent_destinations')) {
|
||||||
if (!targetAgentGroupId) {
|
throw new Error(`agent-to-agent module not installed — cannot route message ${msg.id}`);
|
||||||
throw new Error(`agent-to-agent message ${msg.id} is missing a target agent group id`);
|
|
||||||
}
|
}
|
||||||
// Self-messages are always allowed — used for system notes injected back
|
const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js');
|
||||||
// into an agent's own session (e.g. post-approval follow-up prompts).
|
await routeAgentMessage(msg, session);
|
||||||
if (
|
|
||||||
targetAgentGroupId !== session.agent_group_id &&
|
|
||||||
!hasDestination(session.agent_group_id, 'agent', targetAgentGroupId)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`unauthorized agent-to-agent: ${session.agent_group_id} has no destination for ${targetAgentGroupId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!getAgentGroup(targetAgentGroupId)) {
|
|
||||||
throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`);
|
|
||||||
}
|
|
||||||
const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared');
|
|
||||||
writeSessionMessage(targetAgentGroupId, targetSession.id, {
|
|
||||||
id: `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
||||||
kind: 'chat',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
platformId: session.agent_group_id,
|
|
||||||
channelType: 'agent',
|
|
||||||
threadId: null,
|
|
||||||
content: msg.content,
|
|
||||||
});
|
|
||||||
log.info('Agent message routed', {
|
|
||||||
from: session.agent_group_id,
|
|
||||||
to: targetAgentGroupId,
|
|
||||||
targetSession: targetSession.id,
|
|
||||||
});
|
|
||||||
const fresh = getSession(targetSession.id);
|
|
||||||
if (fresh) await wakeContainer(fresh);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,12 +297,19 @@ async function deliverMessage(
|
|||||||
const isOriginChat = session.messaging_group_id === mg.id;
|
const isOriginChat = session.messaging_group_id === mg.id;
|
||||||
// Guarded: without the agent-to-agent module, `agent_destinations`
|
// Guarded: without the agent-to-agent module, `agent_destinations`
|
||||||
// doesn't exist and we permit all non-origin channel sends (the
|
// doesn't exist and we permit all non-origin channel sends (the
|
||||||
// origin-chat case is always allowed regardless).
|
// origin-chat case is always allowed regardless). Inlined SQL instead
|
||||||
const checkDestinations = hasTable(getDb(), 'agent_destinations');
|
// of importing `hasDestination` so core doesn't depend on the module.
|
||||||
if (!isOriginChat && checkDestinations && !hasDestination(session.agent_group_id, 'channel', mg.id)) {
|
if (!isOriginChat && hasTable(getDb(), 'agent_destinations')) {
|
||||||
throw new Error(
|
const row = getDb()
|
||||||
`unauthorized channel destination: ${session.agent_group_id} cannot send to ${mg.channel_type}/${mg.platform_id}`,
|
.prepare(
|
||||||
);
|
'SELECT 1 FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ? LIMIT 1',
|
||||||
|
)
|
||||||
|
.get(session.agent_group_id, 'channel', mg.id);
|
||||||
|
if (!row) {
|
||||||
|
throw new Error(
|
||||||
|
`unauthorized channel destination: ${session.agent_group_id} cannot send to ${mg.channel_type}/${mg.platform_id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,103 +440,7 @@ async function handleSystemAction(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (action) {
|
log.warn('Unknown system action', { action });
|
||||||
case 'create_agent': {
|
|
||||||
const requestId = content.requestId as string;
|
|
||||||
const name = content.name as string;
|
|
||||||
const instructions = content.instructions as string | null;
|
|
||||||
|
|
||||||
const sourceGroup = getAgentGroup(session.agent_group_id);
|
|
||||||
if (!sourceGroup) {
|
|
||||||
notifyAgent(session, `create_agent failed: source agent group not found.`);
|
|
||||||
log.warn('create_agent failed: missing source group', { sessionAgentGroup: session.agent_group_id, name });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const localName = normalizeName(name);
|
|
||||||
|
|
||||||
// Collision in the creator's destination namespace
|
|
||||||
if (getDestinationByName(sourceGroup.id, localName)) {
|
|
||||||
notifyAgent(session, `Cannot create agent "${name}": you already have a destination named "${localName}".`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive a safe folder name, deduplicated globally across agent_groups.folder
|
|
||||||
let folder = localName;
|
|
||||||
let suffix = 2;
|
|
||||||
while (getAgentGroupByFolder(folder)) {
|
|
||||||
folder = `${localName}-${suffix}`;
|
|
||||||
suffix++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupPath = path.join(GROUPS_DIR, folder);
|
|
||||||
const resolvedPath = path.resolve(groupPath);
|
|
||||||
const resolvedGroupsDir = path.resolve(GROUPS_DIR);
|
|
||||||
if (!resolvedPath.startsWith(resolvedGroupsDir + path.sep)) {
|
|
||||||
notifyAgent(session, `Cannot create agent "${name}": invalid folder path.`);
|
|
||||||
log.error('create_agent path traversal attempt', { folder, resolvedPath });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
const newGroup: AgentGroup = {
|
|
||||||
id: agentGroupId,
|
|
||||||
name,
|
|
||||||
folder,
|
|
||||||
agent_provider: null,
|
|
||||||
created_at: now,
|
|
||||||
};
|
|
||||||
createAgentGroup(newGroup);
|
|
||||||
initGroupFilesystem(newGroup, { instructions: instructions ?? undefined });
|
|
||||||
|
|
||||||
// Insert bidirectional destination rows (= ACL grants).
|
|
||||||
// Creator refers to child by the name it chose; child refers to creator as "parent".
|
|
||||||
createDestination({
|
|
||||||
agent_group_id: sourceGroup.id,
|
|
||||||
local_name: localName,
|
|
||||||
target_type: 'agent',
|
|
||||||
target_id: agentGroupId,
|
|
||||||
created_at: now,
|
|
||||||
});
|
|
||||||
// Handle the unlikely case where the child already has a "parent" destination
|
|
||||||
// (shouldn't happen for a brand-new agent, but be safe).
|
|
||||||
let parentName = 'parent';
|
|
||||||
let parentSuffix = 2;
|
|
||||||
while (getDestinationByName(agentGroupId, parentName)) {
|
|
||||||
parentName = `parent-${parentSuffix}`;
|
|
||||||
parentSuffix++;
|
|
||||||
}
|
|
||||||
createDestination({
|
|
||||||
agent_group_id: agentGroupId,
|
|
||||||
local_name: parentName,
|
|
||||||
target_type: 'agent',
|
|
||||||
target_id: sourceGroup.id,
|
|
||||||
created_at: now,
|
|
||||||
});
|
|
||||||
|
|
||||||
// REQUIRED: project the new destination into the running
|
|
||||||
// container's inbound.db. See the top-of-file invariant in
|
|
||||||
// src/db/agent-destinations.ts — forgetting this causes
|
|
||||||
// "dropped: unknown destination" when the parent tries to send
|
|
||||||
// to the newly-created child.
|
|
||||||
writeDestinations(session.agent_group_id, session.id);
|
|
||||||
|
|
||||||
// Fire-and-forget notification back to the creator
|
|
||||||
notifyAgent(
|
|
||||||
session,
|
|
||||||
`Agent "${localName}" created. You can now message it with <message to="${localName}">...</message>.`,
|
|
||||||
);
|
|
||||||
log.info('Agent group created', { agentGroupId, name, localName, folder, parent: sourceGroup.id });
|
|
||||||
// Note: requestId is unused — this is fire-and-forget, not request/response.
|
|
||||||
void requestId;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
log.warn('Unknown system action', { action });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopDeliveryPolls(): void {
|
export function stopDeliveryPolls(): void {
|
||||||
|
|||||||
64
src/modules/agent-to-agent/agent-route.ts
Normal file
64
src/modules/agent-to-agent/agent-route.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Agent-to-agent message routing.
|
||||||
|
*
|
||||||
|
* Outbound messages with `channel_type === 'agent'` target another agent
|
||||||
|
* group rather than a channel. Permission is enforced via `agent_destinations` —
|
||||||
|
* the source agent must have a row for the target. Content is copied verbatim;
|
||||||
|
* the target's formatter looks up the source agent in its own local map to
|
||||||
|
* display a name.
|
||||||
|
*
|
||||||
|
* Self-messages are always allowed (used for system notes injected back into
|
||||||
|
* an agent's own session, e.g. post-approval follow-up prompts).
|
||||||
|
*
|
||||||
|
* Core delivery.ts dispatches into this via a dynamic import guarded by a
|
||||||
|
* `channel_type === 'agent'` check. When the module is absent the check in
|
||||||
|
* core throws with a "module not installed" message so retry → mark failed.
|
||||||
|
*/
|
||||||
|
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||||
|
import { getSession } from '../../db/sessions.js';
|
||||||
|
import { wakeContainer } from '../../container-runner.js';
|
||||||
|
import { log } from '../../log.js';
|
||||||
|
import { resolveSession, writeSessionMessage } from '../../session-manager.js';
|
||||||
|
import type { Session } from '../../types.js';
|
||||||
|
import { hasDestination } from './db/agent-destinations.js';
|
||||||
|
|
||||||
|
export interface RoutableAgentMessage {
|
||||||
|
id: string;
|
||||||
|
platform_id: string | null;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function routeAgentMessage(msg: RoutableAgentMessage, session: Session): Promise<void> {
|
||||||
|
const targetAgentGroupId = msg.platform_id;
|
||||||
|
if (!targetAgentGroupId) {
|
||||||
|
throw new Error(`agent-to-agent message ${msg.id} is missing a target agent group id`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
targetAgentGroupId !== session.agent_group_id &&
|
||||||
|
!hasDestination(session.agent_group_id, 'agent', targetAgentGroupId)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`unauthorized agent-to-agent: ${session.agent_group_id} has no destination for ${targetAgentGroupId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!getAgentGroup(targetAgentGroupId)) {
|
||||||
|
throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`);
|
||||||
|
}
|
||||||
|
const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared');
|
||||||
|
writeSessionMessage(targetAgentGroupId, targetSession.id, {
|
||||||
|
id: `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
kind: 'chat',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
platformId: session.agent_group_id,
|
||||||
|
channelType: 'agent',
|
||||||
|
threadId: null,
|
||||||
|
content: msg.content,
|
||||||
|
});
|
||||||
|
log.info('Agent message routed', {
|
||||||
|
from: session.agent_group_id,
|
||||||
|
to: targetAgentGroupId,
|
||||||
|
targetSession: targetSession.id,
|
||||||
|
});
|
||||||
|
const fresh = getSession(targetSession.id);
|
||||||
|
if (fresh) await wakeContainer(fresh);
|
||||||
|
}
|
||||||
126
src/modules/agent-to-agent/create-agent.ts
Normal file
126
src/modules/agent-to-agent/create-agent.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* `create_agent` delivery-action handler.
|
||||||
|
*
|
||||||
|
* Spawns a new agent group on demand from the parent agent, wires bidirectional
|
||||||
|
* agent_destinations rows, projects the new destination into the parent's
|
||||||
|
* running container, and notifies the parent.
|
||||||
|
*/
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { GROUPS_DIR } from '../../config.js';
|
||||||
|
import { createAgentGroup, getAgentGroup, getAgentGroupByFolder } from '../../db/agent-groups.js';
|
||||||
|
import { getSession } from '../../db/sessions.js';
|
||||||
|
import { wakeContainer } from '../../container-runner.js';
|
||||||
|
import { initGroupFilesystem } from '../../group-init.js';
|
||||||
|
import { log } from '../../log.js';
|
||||||
|
import { writeSessionMessage } from '../../session-manager.js';
|
||||||
|
import type { AgentGroup, Session } from '../../types.js';
|
||||||
|
import { createDestination, getDestinationByName, normalizeName } from './db/agent-destinations.js';
|
||||||
|
import { writeDestinations } from './write-destinations.js';
|
||||||
|
|
||||||
|
function notifyAgent(session: Session, text: string): void {
|
||||||
|
writeSessionMessage(session.agent_group_id, session.id, {
|
||||||
|
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
kind: 'chat',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
platformId: session.agent_group_id,
|
||||||
|
channelType: 'agent',
|
||||||
|
threadId: null,
|
||||||
|
content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
|
||||||
|
});
|
||||||
|
const fresh = getSession(session.id);
|
||||||
|
if (fresh) {
|
||||||
|
wakeContainer(fresh).catch((err) => log.error('Failed to wake container after notification', { err }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCreateAgent(content: Record<string, unknown>, session: Session): Promise<void> {
|
||||||
|
const requestId = content.requestId as string;
|
||||||
|
const name = content.name as string;
|
||||||
|
const instructions = content.instructions as string | null;
|
||||||
|
|
||||||
|
const sourceGroup = getAgentGroup(session.agent_group_id);
|
||||||
|
if (!sourceGroup) {
|
||||||
|
notifyAgent(session, `create_agent failed: source agent group not found.`);
|
||||||
|
log.warn('create_agent failed: missing source group', { sessionAgentGroup: session.agent_group_id, name });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localName = normalizeName(name);
|
||||||
|
|
||||||
|
// Collision in the creator's destination namespace
|
||||||
|
if (getDestinationByName(sourceGroup.id, localName)) {
|
||||||
|
notifyAgent(session, `Cannot create agent "${name}": you already have a destination named "${localName}".`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive a safe folder name, deduplicated globally across agent_groups.folder
|
||||||
|
let folder = localName;
|
||||||
|
let suffix = 2;
|
||||||
|
while (getAgentGroupByFolder(folder)) {
|
||||||
|
folder = `${localName}-${suffix}`;
|
||||||
|
suffix++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupPath = path.join(GROUPS_DIR, folder);
|
||||||
|
const resolvedPath = path.resolve(groupPath);
|
||||||
|
const resolvedGroupsDir = path.resolve(GROUPS_DIR);
|
||||||
|
if (!resolvedPath.startsWith(resolvedGroupsDir + path.sep)) {
|
||||||
|
notifyAgent(session, `Cannot create agent "${name}": invalid folder path.`);
|
||||||
|
log.error('create_agent path traversal attempt', { folder, resolvedPath });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const newGroup: AgentGroup = {
|
||||||
|
id: agentGroupId,
|
||||||
|
name,
|
||||||
|
folder,
|
||||||
|
agent_provider: null,
|
||||||
|
created_at: now,
|
||||||
|
};
|
||||||
|
createAgentGroup(newGroup);
|
||||||
|
initGroupFilesystem(newGroup, { instructions: instructions ?? undefined });
|
||||||
|
|
||||||
|
// Insert bidirectional destination rows (= ACL grants).
|
||||||
|
// Creator refers to child by the name it chose; child refers to creator as "parent".
|
||||||
|
createDestination({
|
||||||
|
agent_group_id: sourceGroup.id,
|
||||||
|
local_name: localName,
|
||||||
|
target_type: 'agent',
|
||||||
|
target_id: agentGroupId,
|
||||||
|
created_at: now,
|
||||||
|
});
|
||||||
|
// Handle the unlikely case where the child already has a "parent" destination
|
||||||
|
// (shouldn't happen for a brand-new agent, but be safe).
|
||||||
|
let parentName = 'parent';
|
||||||
|
let parentSuffix = 2;
|
||||||
|
while (getDestinationByName(agentGroupId, parentName)) {
|
||||||
|
parentName = `parent-${parentSuffix}`;
|
||||||
|
parentSuffix++;
|
||||||
|
}
|
||||||
|
createDestination({
|
||||||
|
agent_group_id: agentGroupId,
|
||||||
|
local_name: parentName,
|
||||||
|
target_type: 'agent',
|
||||||
|
target_id: sourceGroup.id,
|
||||||
|
created_at: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
// REQUIRED: project the new destination into the running container's
|
||||||
|
// inbound.db. See the top-of-file invariant in db/agent-destinations.ts
|
||||||
|
// — forgetting this causes "dropped: unknown destination" when the parent
|
||||||
|
// tries to send to the newly-created child.
|
||||||
|
writeDestinations(session.agent_group_id, session.id);
|
||||||
|
|
||||||
|
// Fire-and-forget notification back to the creator
|
||||||
|
notifyAgent(
|
||||||
|
session,
|
||||||
|
`Agent "${localName}" created. You can now message it with <message to="${localName}">...</message>.`,
|
||||||
|
);
|
||||||
|
log.info('Agent group created', { agentGroupId, name, localName, folder, parent: sourceGroup.id });
|
||||||
|
// Note: requestId is unused — this is fire-and-forget, not request/response.
|
||||||
|
void requestId;
|
||||||
|
}
|
||||||
@@ -32,8 +32,8 @@
|
|||||||
* - src/delivery.ts::handleSystemAction case 'create_agent'
|
* - src/delivery.ts::handleSystemAction case 'create_agent'
|
||||||
* - src/db/messaging-groups.ts::createMessagingGroupAgent
|
* - src/db/messaging-groups.ts::createMessagingGroupAgent
|
||||||
*/
|
*/
|
||||||
import type { AgentDestination } from '../types.js';
|
import type { AgentDestination } from '../../../types.js';
|
||||||
import { getDb } from './connection.js';
|
import { getDb } from '../../../db/connection.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⚠️ Caller responsibility: after this returns, call
|
* ⚠️ Caller responsibility: after this returns, call
|
||||||
22
src/modules/agent-to-agent/index.ts
Normal file
22
src/modules/agent-to-agent/index.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Agent-to-agent module — inter-agent messaging and on-demand agent creation.
|
||||||
|
*
|
||||||
|
* Registers one delivery action (`create_agent`). The sibling `channel_type === 'agent'`
|
||||||
|
* routing path is NOT a system action — core `delivery.ts` dispatches into
|
||||||
|
* `./agent-route.js` via a dynamic import when it sees `msg.channel_type === 'agent'`.
|
||||||
|
*
|
||||||
|
* Host integration points:
|
||||||
|
* - `src/container-runner.ts::spawnContainer` dynamically imports
|
||||||
|
* `./write-destinations.js` on every wake (guarded by `hasTable('agent_destinations')`).
|
||||||
|
* - `src/delivery.ts::deliverMessage` dynamically imports `./agent-route.js`
|
||||||
|
* when `msg.channel_type === 'agent'`.
|
||||||
|
*
|
||||||
|
* Without this module: `agent_destinations` table absent ⇒ container-runner
|
||||||
|
* skips destination projection, ACL check in delivery skips, `create_agent`
|
||||||
|
* system action logs "Unknown system action", `channel_type='agent'` messages
|
||||||
|
* throw because the module isn't installed.
|
||||||
|
*/
|
||||||
|
import { registerDeliveryAction } from '../../delivery.js';
|
||||||
|
import { handleCreateAgent } from './create-agent.js';
|
||||||
|
|
||||||
|
registerDeliveryAction('create_agent', handleCreateAgent);
|
||||||
59
src/modules/agent-to-agent/write-destinations.ts
Normal file
59
src/modules/agent-to-agent/write-destinations.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Project the agent's central `agent_destinations` rows into its per-session
|
||||||
|
* `inbound.db` so the running container can resolve names locally. Called on
|
||||||
|
* every container wake and after admin-time destination edits (e.g. create_agent).
|
||||||
|
*
|
||||||
|
* Core container-runner calls this via a dynamic import guarded by a
|
||||||
|
* `hasTable('agent_destinations')` check — without the agent-to-agent module
|
||||||
|
* installed, the central table doesn't exist and the projection is skipped.
|
||||||
|
*/
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||||
|
import { getMessagingGroup } from '../../db/messaging-groups.js';
|
||||||
|
import { replaceDestinations, type DestinationRow } from '../../db/session-db.js';
|
||||||
|
import { log } from '../../log.js';
|
||||||
|
import { inboundDbPath, openInboundDb } from '../../session-manager.js';
|
||||||
|
import { getDestinations } from './db/agent-destinations.js';
|
||||||
|
|
||||||
|
export function writeDestinations(agentGroupId: string, sessionId: string): void {
|
||||||
|
const dbPath = inboundDbPath(agentGroupId, sessionId);
|
||||||
|
if (!fs.existsSync(dbPath)) return;
|
||||||
|
|
||||||
|
const rows = getDestinations(agentGroupId);
|
||||||
|
const resolved: DestinationRow[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.target_type === 'channel') {
|
||||||
|
const mg = getMessagingGroup(row.target_id);
|
||||||
|
if (!mg) continue;
|
||||||
|
resolved.push({
|
||||||
|
name: row.local_name,
|
||||||
|
display_name: mg.name ?? row.local_name,
|
||||||
|
type: 'channel',
|
||||||
|
channel_type: mg.channel_type,
|
||||||
|
platform_id: mg.platform_id,
|
||||||
|
agent_group_id: null,
|
||||||
|
});
|
||||||
|
} else if (row.target_type === 'agent') {
|
||||||
|
const ag = getAgentGroup(row.target_id);
|
||||||
|
if (!ag) continue;
|
||||||
|
resolved.push({
|
||||||
|
name: row.local_name,
|
||||||
|
display_name: ag.name,
|
||||||
|
type: 'agent',
|
||||||
|
channel_type: null,
|
||||||
|
platform_id: null,
|
||||||
|
agent_group_id: ag.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = openInboundDb(agentGroupId, sessionId);
|
||||||
|
try {
|
||||||
|
replaceDestinations(db, resolved);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
log.debug('Destination map written', { sessionId, count: resolved.length });
|
||||||
|
}
|
||||||
@@ -17,4 +17,5 @@ import './interactive/index.js';
|
|||||||
import './approvals/index.js';
|
import './approvals/index.js';
|
||||||
import './scheduling/index.js';
|
import './scheduling/index.js';
|
||||||
import './permissions/index.js';
|
import './permissions/index.js';
|
||||||
|
import './agent-to-agent/index.js';
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { DATA_DIR } from './config.js';
|
import { DATA_DIR } from './config.js';
|
||||||
import { getAgentGroup } from './db/agent-groups.js';
|
|
||||||
import { getDestinations } from './db/agent-destinations.js';
|
|
||||||
import { getDb, hasTable } from './db/connection.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, getSession, updateSession } from './db/sessions.js';
|
||||||
import {
|
import {
|
||||||
@@ -25,10 +22,8 @@ import {
|
|||||||
openInboundDb as openInboundDbRaw,
|
openInboundDb as openInboundDbRaw,
|
||||||
openOutboundDb as openOutboundDbRaw,
|
openOutboundDb as openOutboundDbRaw,
|
||||||
upsertSessionRouting,
|
upsertSessionRouting,
|
||||||
replaceDestinations,
|
|
||||||
insertMessage,
|
insertMessage,
|
||||||
migrateMessagesInTable,
|
migrateMessagesInTable,
|
||||||
type DestinationRow,
|
|
||||||
} from './db/session-db.js';
|
} from './db/session-db.js';
|
||||||
import { log } from './log.js';
|
import { log } from './log.js';
|
||||||
import type { Session } from './types.js';
|
import type { Session } from './types.js';
|
||||||
@@ -130,16 +125,6 @@ export function initSessionFolder(agentGroupId: string, sessionId: string): void
|
|||||||
ensureSchema(outboundDbPath(agentGroupId, sessionId), 'outbound');
|
ensureSchema(outboundDbPath(agentGroupId, sessionId), 'outbound');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Write the session's destination map into its inbound.db `destinations` table.
|
|
||||||
*
|
|
||||||
* Called before every container wake so admin changes take effect on next start —
|
|
||||||
* but the container also re-queries on demand, so mid-session admin changes
|
|
||||||
* (e.g. spawning a new child agent) can also call this to push the new map
|
|
||||||
* without restarting the container.
|
|
||||||
*
|
|
||||||
* Uses DELETE + INSERT in a transaction for a clean overwrite.
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Write the default reply routing for a session into its inbound.db.
|
* Write the default reply routing for a session into its inbound.db.
|
||||||
*
|
*
|
||||||
@@ -147,8 +132,9 @@ export function initSessionFolder(agentGroupId: string, sessionId: string): void
|
|||||||
* for outbound messages when the agent doesn't specify an explicit destination.
|
* for outbound messages when the agent doesn't specify an explicit destination.
|
||||||
* Derived from session.messaging_group_id → messaging_groups row + session.thread_id.
|
* Derived from session.messaging_group_id → messaging_groups row + session.thread_id.
|
||||||
*
|
*
|
||||||
* Called on every container wake alongside writeDestinations() so the latest
|
* Called on every container wake alongside the agent-to-agent module's
|
||||||
* routing is always in place, including after admin rewiring.
|
* writeDestinations() (when installed) so the latest routing is always in
|
||||||
|
* place, including after admin rewiring.
|
||||||
*/
|
*/
|
||||||
export function writeSessionRouting(agentGroupId: string, sessionId: string): void {
|
export function writeSessionRouting(agentGroupId: string, sessionId: string): void {
|
||||||
const dbPath = inboundDbPath(agentGroupId, sessionId);
|
const dbPath = inboundDbPath(agentGroupId, sessionId);
|
||||||
@@ -180,53 +166,6 @@ export function writeSessionRouting(agentGroupId: string, sessionId: string): vo
|
|||||||
log.debug('Session routing written', { sessionId, channelType, platformId, threadId: session.thread_id });
|
log.debug('Session routing written', { sessionId, channelType, platformId, threadId: session.thread_id });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeDestinations(agentGroupId: string, sessionId: string): void {
|
|
||||||
const dbPath = inboundDbPath(agentGroupId, sessionId);
|
|
||||||
if (!fs.existsSync(dbPath)) return;
|
|
||||||
|
|
||||||
// Guarded: when the agent-to-agent module isn't installed, the
|
|
||||||
// `agent_destinations` table doesn't exist. Skip silently — core
|
|
||||||
// container spawn continues without projecting destinations.
|
|
||||||
if (!hasTable(getDb(), 'agent_destinations')) return;
|
|
||||||
|
|
||||||
const rows = getDestinations(agentGroupId);
|
|
||||||
const resolved: DestinationRow[] = [];
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
if (row.target_type === 'channel') {
|
|
||||||
const mg = getMessagingGroup(row.target_id);
|
|
||||||
if (!mg) continue;
|
|
||||||
resolved.push({
|
|
||||||
name: row.local_name,
|
|
||||||
display_name: mg.name ?? row.local_name,
|
|
||||||
type: 'channel',
|
|
||||||
channel_type: mg.channel_type,
|
|
||||||
platform_id: mg.platform_id,
|
|
||||||
agent_group_id: null,
|
|
||||||
});
|
|
||||||
} else if (row.target_type === 'agent') {
|
|
||||||
const ag = getAgentGroup(row.target_id);
|
|
||||||
if (!ag) continue;
|
|
||||||
resolved.push({
|
|
||||||
name: row.local_name,
|
|
||||||
display_name: ag.name,
|
|
||||||
type: 'agent',
|
|
||||||
channel_type: null,
|
|
||||||
platform_id: null,
|
|
||||||
agent_group_id: ag.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = openInboundDb(agentGroupId, sessionId);
|
|
||||||
try {
|
|
||||||
replaceDestinations(db, resolved);
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
}
|
|
||||||
log.debug('Destination map written', { sessionId, count: resolved.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write a message to a session's inbound DB (messages_in). Host-only.
|
* Write a message to a session's inbound DB (messages_in). Host-only.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user