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:
gavrielc
2026-04-18 19:00:10 +03:00
parent c80a23e24f
commit 46b19dcf9c
13 changed files with 345 additions and 264 deletions

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

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

View File

@@ -0,0 +1,135 @@
/**
* Per-agent destination map + ACL.
*
* Each row means: agent `agent_group_id` is allowed to send messages to
* target (`target_type`, `target_id`), and refers to it locally as `local_name`.
*
* Names are local to each source agent — they exist only inside that agent's
* namespace. The host uses this table both for routing (resolve name → ID)
* and for permission checks (row exists ⇒ authorized).
*/
/**
* ⚠️ DESTINATION PROJECTION INVARIANT — READ BEFORE ADDING NEW CALL SITES.
*
* `agent_destinations` in the central DB is the source of truth, but the
* agent-runner container reads its destinations from a per-session
* projection in `inbound.db`. That projection is written by
* `writeDestinations(agentGroupId, sessionId)` in session-manager.ts.
*
* `spawnContainer` calls `writeDestinations` on every container wake, so a
* fresh container always sees the latest destinations. BUT: a container
* that is ALREADY running when you mutate the central table will keep
* serving the stale projection until its next wake — the central write
* does not propagate automatically.
*
* **Therefore: every time you call `createDestination` / `deleteDestination` /
* `deleteAllDestinationsTouching` from code that runs while an agent's
* container may be alive, you MUST also call `writeDestinations(agentGroupId,
* sessionId)` for each affected session.** Forgetting this manifests as
* "dropped: unknown destination" errors at send_message time.
*
* Affected call sites today (keep this list honest if you add more):
* - src/delivery.ts::handleSystemAction case 'create_agent'
* - src/db/messaging-groups.ts::createMessagingGroupAgent
*/
import type { AgentDestination } from '../../../types.js';
import { getDb } from '../../../db/connection.js';
/**
* ⚠️ Caller responsibility: after this returns, call
* `writeDestinations(row.agent_group_id, <sessionId>)` for each active
* session of that agent group so the change propagates to the running
* container's inbound.db. See the top-of-file invariant.
*/
export function createDestination(row: AgentDestination): void {
getDb()
.prepare(
`INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
VALUES (@agent_group_id, @local_name, @target_type, @target_id, @created_at)`,
)
.run(row);
}
export function getDestinations(agentGroupId: string): AgentDestination[] {
return getDb()
.prepare('SELECT * FROM agent_destinations WHERE agent_group_id = ?')
.all(agentGroupId) as AgentDestination[];
}
export function getDestinationByName(agentGroupId: string, localName: string): AgentDestination | undefined {
return getDb()
.prepare('SELECT * FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
.get(agentGroupId, localName) as AgentDestination | undefined;
}
/** Reverse lookup: what does this agent call the given target? */
export function getDestinationByTarget(
agentGroupId: string,
targetType: 'channel' | 'agent',
targetId: string,
): AgentDestination | undefined {
return getDb()
.prepare('SELECT * FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ?')
.get(agentGroupId, targetType, targetId) as AgentDestination | undefined;
}
/** Permission check: can this agent send to this target? */
export function hasDestination(agentGroupId: string, targetType: 'channel' | 'agent', targetId: string): boolean {
const row = getDb()
.prepare('SELECT 1 FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ? LIMIT 1')
.get(agentGroupId, targetType, targetId);
return !!row;
}
/**
* ⚠️ Caller responsibility: after this returns, call
* `writeDestinations(agentGroupId, <sessionId>)` for each active session
* so the deletion propagates to the running container's inbound.db.
*/
export function deleteDestination(agentGroupId: string, localName: string): void {
getDb()
.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
.run(agentGroupId, localName);
}
/**
* Delete every destination row where this agent group is either the owner
* or the target. Used when tearing down a dev agent after a swap request
* completes/rolls-back — drops the bidirectional destinations in one call.
*
* ⚠️ Caller responsibility: not only does `agentGroupId`'s own session
* projection need a refresh, but ALSO every OTHER agent group that had
* `agentGroupId` as a destination target. Use `getDestinationReferencers`
* below to find them BEFORE calling this (the rows are gone afterwards).
*/
export function deleteAllDestinationsTouching(agentGroupId: string): void {
getDb()
.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? OR (target_type = ? AND target_id = ?)')
.run(agentGroupId, 'agent', agentGroupId);
}
/**
* Return the list of agent_group_ids that currently have a destination
* row pointing at `targetAgentGroupId`. Call this BEFORE
* `deleteAllDestinationsTouching` if you need to know whose session
* projections to refresh after the delete — the rows are gone once the
* delete runs.
*/
export function getDestinationReferencers(targetAgentGroupId: string): string[] {
const rows = getDb()
.prepare(
"SELECT DISTINCT agent_group_id FROM agent_destinations WHERE target_type = 'agent' AND target_id = ? AND agent_group_id != ?",
)
.all(targetAgentGroupId, targetAgentGroupId) as Array<{ agent_group_id: string }>;
return rows.map((r) => r.agent_group_id);
}
/** Normalize a human-readable name into a lowercase, dash-separated identifier. */
export function normalizeName(name: string): string {
return (
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'unnamed'
);
}

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

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

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