Merge pull request #1848 from qwibitai/refactor/pr6-agent-to-agent

refactor(modules): extract agent-to-agent as registry-based module
This commit is contained in:
gavrielc
2026-04-18 19:12:17 +03:00
committed by GitHub
13 changed files with 345 additions and 264 deletions
+6 -3
View File
@@ -31,7 +31,6 @@ import {
markContainerRunning,
markContainerStopped,
sessionDir,
writeDestinations,
writeSessionRouting,
} from './session-manager.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
// changes take effect on wake.
writeDestinations(agentGroup.id, session.id);
// changes take effect on wake. Destinations come from the agent-to-agent
// 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);
// Resolve the effective provider + any host-side contribution it declares
+3 -3
View File
@@ -230,7 +230,7 @@ describe('messaging group agents', () => {
});
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());
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 () => {
const { getDestinations } = await import('./agent-destinations.js');
const { getDestinations } = await import('../modules/agent-to-agent/db/agent-destinations.js');
createMessagingGroupAgent(mga());
// 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
@@ -251,7 +251,7 @@ describe('messaging group agents', () => {
});
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
// should get distinct local_names (gen, gen-2).
createMessagingGroupAgent(mga());
+28 -13
View File
@@ -1,11 +1,20 @@
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 {
createDestination,
getDestinationByName,
getDestinationByTarget,
normalizeName,
} from './agent-destinations.js';
import { getDb } from './connection.js';
} from '../modules/agent-to-agent/db/agent-destinations.js';
import { getDb, hasTable } from './connection.js';
// ── Messaging Groups ──
@@ -84,21 +93,27 @@ export function createMessagingGroupAgent(mga: MessagingGroupAgent): void {
.run(mga);
// 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
// `agent_destinations` row. It does NOT project into any running
// agent's session inbound.db (see top-of-file invariant in
// src/db/agent-destinations.ts). In practice this is fine because the
// only real callers are one-shot setup scripts (setup/register.ts,
// scripts/init-first-agent.ts, /manage-channels skill) that run in a
// separate process from the host. Any already-running container for
// `mga.agent_group_id` will keep serving the stale projection until
// its next wake (idle timeout or next inbound message) at which
// point spawnContainer's writeDestinations call refreshes from central.
// If you call this from code that runs INSIDE the host process and
// need the refresh to happen immediately, explicitly call
// `writeDestinations(mga.agent_group_id, <sessionId>)` afterwards.
// src/modules/agent-to-agent/db/agent-destinations.ts). In practice this
// is fine because the only real callers are one-shot setup scripts
// (setup/register.ts, scripts/init-first-agent.ts, /manage-channels
// skill) that run in a separate process from the host. Any already-
// running container for `mga.agent_group_id` will keep serving the
// stale projection until its next wake (idle timeout or next inbound
// message) at which point spawnContainer's writeDestinations call
// refreshes from central. If you call this from code that runs INSIDE
// the host process and need the refresh to happen immediately,
// 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);
if (existing) return;
+2 -2
View File
@@ -3,7 +3,7 @@ import type Database from 'better-sqlite3';
import { log } from '../../log.js';
import { migration001 } from './001-initial.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 { migration009 } from './009-drop-pending-credentials.js';
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
@@ -19,7 +19,7 @@ const migrations: Migration[] = [
migration001,
migration002,
moduleApprovalsPendingApprovals,
migration004,
moduleAgentToAgentDestinations,
moduleApprovalsTitleOptions,
migration008,
migration009,
@@ -15,7 +15,10 @@ import type { Migration } from './index.js';
* while admin calls the child "worker-1". The (agent_group_id, local_name)
* 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,
name: 'agent-destinations',
up(db: Database.Database) {
+25 -176
View File
@@ -11,10 +11,8 @@ import type Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { GROUPS_DIR } from './config.js';
import { getRunningSessions, getActiveSessions, createPendingQuestion, getSession } from './db/sessions.js';
import { getAgentGroup, createAgentGroup, updateAgentGroup, getAgentGroupByFolder } from './db/agent-groups.js';
import { createDestination, getDestinationByName, hasDestination, normalizeName } from './db/agent-destinations.js';
import { getAgentGroup } from './db/agent-groups.js';
import { getDb, hasTable } from './db/connection.js';
import { getMessagingGroupByPlatform } from './db/messaging-groups.js';
import {
@@ -26,19 +24,11 @@ import {
} from './db/session-db.js';
import { log } from './log.js';
import { normalizeOptions } from './channels/ask-question.js';
import {
openInboundDb,
openOutboundDb,
sessionDir,
resolveSession,
writeDestinations,
writeSessionMessage,
} from './session-manager.js';
import { openInboundDb, openOutboundDb, sessionDir, writeSessionMessage } from './session-manager.js';
import { resetContainerIdleTimer, wakeContainer } from './container-runner.js';
import { initGroupFilesystem } from './group-init.js';
import { pauseTypingRefreshAfterDelivery, setTypingAdapter } from './modules/typing/index.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 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). */
export function startActiveDeliveryPoll(): void {
if (activePolling) return;
@@ -293,45 +260,16 @@ async function deliverMessage(
return;
}
// Agent-to-agent — route to target session (with permission check).
// Permission is enforced via agent_destinations — the source agent must have
// a row for the target. Content is copied verbatim; the target's formatter
// will look up the source agent in its own local map to display a name.
// Agent-to-agent — route to target session via the agent-to-agent module.
// Guarded by the channel_type check. If the module isn't installed the
// `agent_destinations` table won't exist and `routeAgentMessage`'s permission
// check will throw, which falls into the normal retry → mark-failed path.
if (msg.channel_type === 'agent') {
const targetAgentGroupId = msg.platform_id;
if (!targetAgentGroupId) {
throw new Error(`agent-to-agent message ${msg.id} is missing a target agent group id`);
if (!hasTable(getDb(), 'agent_destinations')) {
throw new Error(`agent-to-agent module not installed — cannot route message ${msg.id}`);
}
// Self-messages are always allowed — used for system notes injected back
// into an agent's own session (e.g. post-approval follow-up prompts).
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);
const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js');
await routeAgentMessage(msg, session);
return;
}
@@ -359,12 +297,19 @@ async function deliverMessage(
const isOriginChat = session.messaging_group_id === mg.id;
// Guarded: without the agent-to-agent module, `agent_destinations`
// doesn't exist and we permit all non-origin channel sends (the
// origin-chat case is always allowed regardless).
const checkDestinations = hasTable(getDb(), 'agent_destinations');
if (!isOriginChat && checkDestinations && !hasDestination(session.agent_group_id, 'channel', mg.id)) {
throw new Error(
`unauthorized channel destination: ${session.agent_group_id} cannot send to ${mg.channel_type}/${mg.platform_id}`,
);
// origin-chat case is always allowed regardless). Inlined SQL instead
// of importing `hasDestination` so core doesn't depend on the module.
if (!isOriginChat && hasTable(getDb(), 'agent_destinations')) {
const row = getDb()
.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;
}
switch (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 });
}
log.warn('Unknown system action', { action });
}
export function stopDeliveryPolls(): void {
+64
View 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
View 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/db/messaging-groups.ts::createMessagingGroupAgent
*/
import type { AgentDestination } from '../types.js';
import { getDb } from './connection.js';
import type { AgentDestination } from '../../../types.js';
import { getDb } from '../../../db/connection.js';
/**
* Caller responsibility: after this returns, call
+22
View 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);
@@ -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 });
}
+1
View File
@@ -17,4 +17,5 @@ import './interactive/index.js';
import './approvals/index.js';
import './scheduling/index.js';
import './permissions/index.js';
import './agent-to-agent/index.js';
+3 -64
View File
@@ -15,9 +15,6 @@ import fs from 'fs';
import path from 'path';
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 { createSession, findSession, findSessionByAgentGroup, getSession, updateSession } from './db/sessions.js';
import {
@@ -25,10 +22,8 @@ import {
openInboundDb as openInboundDbRaw,
openOutboundDb as openOutboundDbRaw,
upsertSessionRouting,
replaceDestinations,
insertMessage,
migrateMessagesInTable,
type DestinationRow,
} from './db/session-db.js';
import { log } from './log.js';
import type { Session } from './types.js';
@@ -130,16 +125,6 @@ export function initSessionFolder(agentGroupId: string, sessionId: string): void
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.
*
@@ -147,8 +132,9 @@ export function initSessionFolder(agentGroupId: string, sessionId: string): void
* for outbound messages when the agent doesn't specify an explicit destination.
* Derived from session.messaging_group_id → messaging_groups row + session.thread_id.
*
* Called on every container wake alongside writeDestinations() so the latest
* routing is always in place, including after admin rewiring.
* Called on every container wake alongside the agent-to-agent module's
* writeDestinations() (when installed) so the latest routing is always in
* place, including after admin rewiring.
*/
export function writeSessionRouting(agentGroupId: string, sessionId: string): void {
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 });
}
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.
*