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:
gavrielc
2026-04-10 01:10:34 +03:00
parent 9af9bc947a
commit d8fbd3b239
24 changed files with 1025 additions and 78 deletions

View File

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