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:
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;
|
||||
}
|
||||
135
src/modules/agent-to-agent/db/agent-destinations.ts
Normal file
135
src/modules/agent-to-agent/db/agent-destinations.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
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 './scheduling/index.js';
|
||||
import './permissions/index.js';
|
||||
import './agent-to-agent/index.js';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user