feat: agent-to-agent communication, dynamic agent creation, self-modification tools
Agent-to-agent: host routes messages with channel_type='agent' to target agent's inbound.db, enriches with sender info, wakes target container. Bidirectional routing works via inherited routing context. Dynamic agents: create_agent MCP tool + system action handler creates agent groups, folders, and optional CLAUDE.md on the fly. Self-modification: install_packages (apt/npm, requires admin approval), add_mcp_server (no approval), request_rebuild (builds per-agent-group Docker image with approved packages). Approval flow reuses interactive card infrastructure with pending_approvals table. Also includes fixes from prior session: attachment download, reply context extraction, message editing (platform message ID tracking), delivery retry limits, and card update on button click. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
335
src/delivery.ts
335
src/delivery.ts
@@ -11,16 +11,22 @@ import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { getRunningSessions, getActiveSessions, createPendingQuestion } from './db/sessions.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import { GROUPS_DIR } from './config.js';
|
||||
import { getRunningSessions, getActiveSessions, createPendingQuestion, getSession, createPendingApproval } from './db/sessions.js';
|
||||
import { getAgentGroup, getAdminAgentGroup, createAgentGroup, updateAgentGroup } from './db/agent-groups.js';
|
||||
import { getMessagingGroupsByAgentGroup } from './db/messaging-groups.js';
|
||||
import { log } from './log.js';
|
||||
import { openInboundDb, openOutboundDb, sessionDir, inboundDbPath } from './session-manager.js';
|
||||
import { resetContainerIdleTimer } from './container-runner.js';
|
||||
import { openInboundDb, openOutboundDb, sessionDir, inboundDbPath, resolveSession, writeSessionMessage, writeSystemResponse } from './session-manager.js';
|
||||
import { resetContainerIdleTimer, wakeContainer } from './container-runner.js';
|
||||
import type { OutboundFile } from './channels/adapter.js';
|
||||
import type { Session } from './types.js';
|
||||
|
||||
const ACTIVE_POLL_MS = 1000;
|
||||
const SWEEP_POLL_MS = 60_000;
|
||||
const MAX_DELIVERY_ATTEMPTS = 3;
|
||||
|
||||
/** Track delivery attempt counts. Resets on process restart (gives failed messages a fresh chance). */
|
||||
const deliveryAttempts = new Map<string, number>();
|
||||
|
||||
export interface ChannelDeliveryAdapter {
|
||||
deliver(
|
||||
@@ -30,7 +36,7 @@ export interface ChannelDeliveryAdapter {
|
||||
kind: string,
|
||||
content: string,
|
||||
files?: OutboundFile[],
|
||||
): Promise<void>;
|
||||
): Promise<string | undefined>;
|
||||
setTyping?(channelType: string, platformId: string, threadId: string | null): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -136,16 +142,44 @@ async function deliverSessionMessages(session: Session): Promise<void> {
|
||||
const undelivered = allDue.filter((m) => !deliveredIds.has(m.id));
|
||||
if (undelivered.length === 0) return;
|
||||
|
||||
// Ensure platform_message_id column exists (migration for existing sessions)
|
||||
migrateDeliveredTable(inDb);
|
||||
|
||||
for (const msg of undelivered) {
|
||||
try {
|
||||
await deliverMessage(msg, session, inDb);
|
||||
// Track delivery in inbound.db (host-owned) — not outbound.db
|
||||
const platformMsgId = await deliverMessage(msg, session, inDb);
|
||||
inDb
|
||||
.prepare("INSERT OR IGNORE INTO delivered (message_out_id, delivered_at) VALUES (?, datetime('now'))")
|
||||
.run(msg.id);
|
||||
.prepare(
|
||||
"INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, ?, 'delivered', datetime('now'))",
|
||||
)
|
||||
.run(msg.id, platformMsgId ?? null);
|
||||
deliveryAttempts.delete(msg.id);
|
||||
resetContainerIdleTimer(session.id);
|
||||
} catch (err) {
|
||||
log.error('Failed to deliver message', { messageId: msg.id, sessionId: session.id, err });
|
||||
const attempts = (deliveryAttempts.get(msg.id) ?? 0) + 1;
|
||||
deliveryAttempts.set(msg.id, attempts);
|
||||
if (attempts >= MAX_DELIVERY_ATTEMPTS) {
|
||||
log.error('Message delivery failed permanently, giving up', {
|
||||
messageId: msg.id,
|
||||
sessionId: session.id,
|
||||
attempts,
|
||||
err,
|
||||
});
|
||||
inDb
|
||||
.prepare(
|
||||
"INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, NULL, 'failed', datetime('now'))",
|
||||
)
|
||||
.run(msg.id);
|
||||
deliveryAttempts.delete(msg.id);
|
||||
} else {
|
||||
log.warn('Message delivery failed, will retry', {
|
||||
messageId: msg.id,
|
||||
sessionId: session.id,
|
||||
attempt: attempts,
|
||||
maxAttempts: MAX_DELIVERY_ATTEMPTS,
|
||||
err,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -165,7 +199,7 @@ async function deliverMessage(
|
||||
},
|
||||
session: Session,
|
||||
inDb: Database.Database,
|
||||
): Promise<void> {
|
||||
): Promise<string | undefined> {
|
||||
if (!deliveryAdapter) {
|
||||
log.warn('No delivery adapter configured, dropping message', { id: msg.id });
|
||||
return;
|
||||
@@ -181,8 +215,7 @@ async function deliverMessage(
|
||||
|
||||
// Agent-to-agent — route to target session
|
||||
if (msg.channel_type === 'agent') {
|
||||
log.info('Agent-to-agent message', { from: session.id, target: msg.platform_id });
|
||||
// TODO: route to target agent's session DB
|
||||
await routeAgentMessage(msg, session);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -222,11 +255,19 @@ async function deliverMessage(
|
||||
if (files.length === 0) files = undefined;
|
||||
}
|
||||
|
||||
await deliveryAdapter.deliver(msg.channel_type, msg.platform_id, msg.thread_id, msg.kind, msg.content, files);
|
||||
const platformMsgId = await deliveryAdapter.deliver(
|
||||
msg.channel_type,
|
||||
msg.platform_id,
|
||||
msg.thread_id,
|
||||
msg.kind,
|
||||
msg.content,
|
||||
files,
|
||||
);
|
||||
log.info('Message delivered', {
|
||||
id: msg.id,
|
||||
channelType: msg.channel_type,
|
||||
platformId: msg.platform_id,
|
||||
platformMsgId,
|
||||
fileCount: files?.length,
|
||||
});
|
||||
|
||||
@@ -234,6 +275,71 @@ async function deliverMessage(
|
||||
if (fs.existsSync(outboxDir)) {
|
||||
fs.rmSync(outboxDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return platformMsgId;
|
||||
}
|
||||
|
||||
/** Route an agent-to-agent message to the target agent's session. */
|
||||
async function routeAgentMessage(
|
||||
msg: { id: string; platform_id: string | null; content: string },
|
||||
sourceSession: Session,
|
||||
): Promise<void> {
|
||||
const targetAgentGroupId = msg.platform_id;
|
||||
if (!targetAgentGroupId) {
|
||||
log.warn('Agent message missing target agent group ID', { id: msg.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const targetGroup = getAgentGroup(targetAgentGroupId);
|
||||
if (!targetGroup) {
|
||||
log.warn('Target agent group not found', { id: msg.id, targetAgentGroupId });
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceGroup = getAgentGroup(sourceSession.agent_group_id);
|
||||
const sourceAgentName = sourceGroup?.name || sourceSession.agent_group_id;
|
||||
|
||||
// Find or create a session for the target agent
|
||||
const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared');
|
||||
|
||||
// Enrich content with sender info
|
||||
const content = JSON.parse(msg.content);
|
||||
const enrichedContent = JSON.stringify({
|
||||
text: content.text,
|
||||
sender: sourceAgentName,
|
||||
senderId: sourceSession.agent_group_id,
|
||||
});
|
||||
|
||||
const messageId = `agent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
writeSessionMessage(targetAgentGroupId, targetSession.id, {
|
||||
id: messageId,
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: sourceSession.agent_group_id,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: enrichedContent,
|
||||
});
|
||||
|
||||
log.info('Agent message routed', { from: sourceSession.agent_group_id, to: targetAgentGroupId, targetSession: targetSession.id });
|
||||
|
||||
const freshSession = getSession(targetSession.id);
|
||||
if (freshSession) {
|
||||
await wakeContainer(freshSession);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure the delivered table has new columns (migration for existing sessions). */
|
||||
function migrateDeliveredTable(db: Database.Database): void {
|
||||
const cols = new Set(
|
||||
(db.prepare("PRAGMA table_info('delivered')").all() as Array<{ name: string }>).map((c) => c.name),
|
||||
);
|
||||
if (!cols.has('platform_message_id')) {
|
||||
db.prepare('ALTER TABLE delivered ADD COLUMN platform_message_id TEXT').run();
|
||||
}
|
||||
if (!cols.has('status')) {
|
||||
db.prepare("ALTER TABLE delivered ADD COLUMN status TEXT NOT NULL DEFAULT 'delivered'").run();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -309,6 +415,207 @@ async function handleSystemAction(
|
||||
break;
|
||||
}
|
||||
|
||||
case 'create_agent': {
|
||||
const requestId = content.requestId as string;
|
||||
const name = content.name as string;
|
||||
let folder =
|
||||
(content.folder as string) || name.toLowerCase().replace(/[^a-z0-9_-]/g, '_').replace(/_+/g, '_');
|
||||
const instructions = content.instructions as string | null;
|
||||
|
||||
try {
|
||||
// Avoid duplicate folders
|
||||
const { getAgentGroupByFolder } = await import('./db/agent-groups.js');
|
||||
if (getAgentGroupByFolder(folder)) {
|
||||
folder = `${folder}_${Date.now()}`;
|
||||
}
|
||||
|
||||
const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
createAgentGroup({
|
||||
id: agentGroupId,
|
||||
name,
|
||||
folder,
|
||||
is_admin: 0,
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const groupPath = path.join(GROUPS_DIR, folder);
|
||||
fs.mkdirSync(groupPath, { recursive: true });
|
||||
|
||||
if (instructions) {
|
||||
fs.writeFileSync(path.join(groupPath, 'CLAUDE.md'), instructions);
|
||||
}
|
||||
|
||||
writeSystemResponse(session.agent_group_id, session.id, requestId, 'success', {
|
||||
agentGroupId,
|
||||
name,
|
||||
folder,
|
||||
});
|
||||
|
||||
log.info('Agent group created via system action', { agentGroupId, name, folder });
|
||||
} catch (e) {
|
||||
writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'add_mcp_server': {
|
||||
const requestId = content.requestId as string;
|
||||
const serverName = content.name as string;
|
||||
const command = content.command as string;
|
||||
const serverArgs = content.args as string[];
|
||||
const serverEnv = content.env as Record<string, string>;
|
||||
|
||||
try {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) throw new Error('Agent group not found');
|
||||
|
||||
const containerConfig = agentGroup.container_config ? JSON.parse(agentGroup.container_config) : {};
|
||||
if (!containerConfig.mcpServers) containerConfig.mcpServers = {};
|
||||
containerConfig.mcpServers[serverName] = { command, args: serverArgs || [], env: serverEnv || {} };
|
||||
|
||||
updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) });
|
||||
|
||||
writeSystemResponse(session.agent_group_id, session.id, requestId, 'success', {
|
||||
message: `MCP server "${serverName}" added. Will take effect on next container restart.`,
|
||||
});
|
||||
|
||||
log.info('MCP server added', { agentGroupId: session.agent_group_id, name: serverName });
|
||||
} catch (e) {
|
||||
writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'install_packages': {
|
||||
const requestId = content.requestId as string;
|
||||
const apt = (content.apt as string[]) || [];
|
||||
const npm = (content.npm as string[]) || [];
|
||||
const reason = content.reason as string;
|
||||
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) {
|
||||
writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { error: 'Agent group not found' });
|
||||
break;
|
||||
}
|
||||
|
||||
// Find admin channel for approval card
|
||||
const adminGroup = getAdminAgentGroup();
|
||||
let approvalChannelType: string | null = null;
|
||||
let approvalPlatformId: string | null = null;
|
||||
|
||||
if (adminGroup) {
|
||||
const adminMGs = getMessagingGroupsByAgentGroup(adminGroup.id);
|
||||
if (adminMGs.length > 0) {
|
||||
approvalChannelType = adminMGs[0].channel_type;
|
||||
approvalPlatformId = adminMGs[0].platform_id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!approvalChannelType || !approvalPlatformId) {
|
||||
writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', {
|
||||
error: 'No admin channel found for approval',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
createPendingApproval({
|
||||
approval_id: approvalId,
|
||||
session_id: session.id,
|
||||
request_id: requestId,
|
||||
action: 'install_packages',
|
||||
payload: JSON.stringify({ apt, npm, reason }),
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const packageList = [...apt.map((p: string) => `apt: ${p}`), ...npm.map((p: string) => `npm: ${p}`)].join(', ');
|
||||
if (deliveryAdapter) {
|
||||
await deliveryAdapter.deliver(
|
||||
approvalChannelType,
|
||||
approvalPlatformId,
|
||||
null,
|
||||
'chat-sdk',
|
||||
JSON.stringify({
|
||||
type: 'ask_question',
|
||||
questionId: approvalId,
|
||||
question: `Agent "${agentGroup.name}" requests package installation:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`,
|
||||
options: ['Approve', 'Reject'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
log.info('Package install approval requested', { approvalId, agentGroup: agentGroup.name, apt, npm });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'request_rebuild': {
|
||||
const requestId = content.requestId as string;
|
||||
const reason = content.reason as string;
|
||||
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) {
|
||||
writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { error: 'Agent group not found' });
|
||||
break;
|
||||
}
|
||||
|
||||
// Find admin channel for approval card
|
||||
const adminGroup2 = getAdminAgentGroup();
|
||||
let rebuildChannelType: string | null = null;
|
||||
let rebuildPlatformId: string | null = null;
|
||||
|
||||
if (adminGroup2) {
|
||||
const adminMGs2 = getMessagingGroupsByAgentGroup(adminGroup2.id);
|
||||
if (adminMGs2.length > 0) {
|
||||
rebuildChannelType = adminMGs2[0].channel_type;
|
||||
rebuildPlatformId = adminMGs2[0].platform_id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rebuildChannelType || !rebuildPlatformId) {
|
||||
writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', {
|
||||
error: 'No admin channel found for approval',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const rebuildApprovalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
createPendingApproval({
|
||||
approval_id: rebuildApprovalId,
|
||||
session_id: session.id,
|
||||
request_id: requestId,
|
||||
action: 'request_rebuild',
|
||||
payload: JSON.stringify({ reason }),
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (deliveryAdapter) {
|
||||
await deliveryAdapter.deliver(
|
||||
rebuildChannelType,
|
||||
rebuildPlatformId,
|
||||
null,
|
||||
'chat-sdk',
|
||||
JSON.stringify({
|
||||
type: 'ask_question',
|
||||
questionId: rebuildApprovalId,
|
||||
question: `Agent "${agentGroup.name}" requests a container rebuild.${reason ? `\nReason: ${reason}` : ''}`,
|
||||
options: ['Approve', 'Reject'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
log.info('Container rebuild approval requested', { approvalId: rebuildApprovalId, agentGroup: agentGroup.name });
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
log.warn('Unknown system action', { action });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user