From 46b19dcf9c71fbe02bd9903a2dc5b2fbdd401fa7 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 18 Apr 2026 19:00:10 +0300 Subject: [PATCH] refactor(modules): extract agent-to-agent as registry-based module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/container-runner.ts | 9 +- src/db/db-v2.test.ts | 6 +- src/db/messaging-groups.ts | 41 ++-- src/db/migrations/index.ts | 4 +- ... => module-agent-to-agent-destinations.ts} | 5 +- src/delivery.ts | 201 +++--------------- src/modules/agent-to-agent/agent-route.ts | 64 ++++++ src/modules/agent-to-agent/create-agent.ts | 126 +++++++++++ .../agent-to-agent}/db/agent-destinations.ts | 4 +- src/modules/agent-to-agent/index.ts | 22 ++ .../agent-to-agent/write-destinations.ts | 59 +++++ src/modules/index.ts | 1 + src/session-manager.ts | 67 +----- 13 files changed, 345 insertions(+), 264 deletions(-) rename src/db/migrations/{004-agent-destinations.ts => module-agent-to-agent-destinations.ts} (91%) create mode 100644 src/modules/agent-to-agent/agent-route.ts create mode 100644 src/modules/agent-to-agent/create-agent.ts rename src/{ => modules/agent-to-agent}/db/agent-destinations.ts (98%) create mode 100644 src/modules/agent-to-agent/index.ts create mode 100644 src/modules/agent-to-agent/write-destinations.ts diff --git a/src/container-runner.ts b/src/container-runner.ts index 1fe3e578a..a786816fe 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -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 { } // 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 diff --git a/src/db/db-v2.test.ts b/src/db/db-v2.test.ts index 4a9940bd8..87d816160 100644 --- a/src/db/db-v2.test.ts +++ b/src/db/db-v2.test.ts @@ -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()); diff --git a/src/db/messaging-groups.ts b/src/db/messaging-groups.ts index 2f66c5281..0c0ba224b 100644 --- a/src/db/messaging-groups.ts +++ b/src/db/messaging-groups.ts @@ -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, )` 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, + // )` afterwards. + if (!hasTable(getDb(), 'agent_destinations')) return; + const existing = getDestinationByTarget(mga.agent_group_id, 'channel', mga.messaging_group_id); if (existing) return; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index e3e417f0f..3a877971b 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -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, diff --git a/src/db/migrations/004-agent-destinations.ts b/src/db/migrations/module-agent-to-agent-destinations.ts similarity index 91% rename from src/db/migrations/004-agent-destinations.ts rename to src/db/migrations/module-agent-to-agent-destinations.ts index 503e97ed8..56437c1b8 100644 --- a/src/db/migrations/004-agent-destinations.ts +++ b/src/db/migrations/module-agent-to-agent-destinations.ts @@ -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) { diff --git a/src/delivery.ts b/src/delivery.ts index 6ea3d04f9..b924d2feb 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -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 ....`, - ); - 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 { diff --git a/src/modules/agent-to-agent/agent-route.ts b/src/modules/agent-to-agent/agent-route.ts new file mode 100644 index 000000000..760356cee --- /dev/null +++ b/src/modules/agent-to-agent/agent-route.ts @@ -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 { + 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); +} diff --git a/src/modules/agent-to-agent/create-agent.ts b/src/modules/agent-to-agent/create-agent.ts new file mode 100644 index 000000000..92d596477 --- /dev/null +++ b/src/modules/agent-to-agent/create-agent.ts @@ -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, session: Session): Promise { + 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 ....`, + ); + 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; +} diff --git a/src/db/agent-destinations.ts b/src/modules/agent-to-agent/db/agent-destinations.ts similarity index 98% rename from src/db/agent-destinations.ts rename to src/modules/agent-to-agent/db/agent-destinations.ts index a6cb0e5a3..8424fa437 100644 --- a/src/db/agent-destinations.ts +++ b/src/modules/agent-to-agent/db/agent-destinations.ts @@ -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 diff --git a/src/modules/agent-to-agent/index.ts b/src/modules/agent-to-agent/index.ts new file mode 100644 index 000000000..b4076475b --- /dev/null +++ b/src/modules/agent-to-agent/index.ts @@ -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); diff --git a/src/modules/agent-to-agent/write-destinations.ts b/src/modules/agent-to-agent/write-destinations.ts new file mode 100644 index 000000000..9489f22b0 --- /dev/null +++ b/src/modules/agent-to-agent/write-destinations.ts @@ -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 }); +} diff --git a/src/modules/index.ts b/src/modules/index.ts index 3ca1b5ff6..27e9517cb 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -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'; diff --git a/src/session-manager.ts b/src/session-manager.ts index 1c83d8014..218305677 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -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. *