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

@@ -64,7 +64,7 @@ function generateId(): string {
*/
export function resolveSession(
agentGroupId: string,
messagingGroupId: string,
messagingGroupId: string | null,
threadId: string | null,
sessionMode: 'shared' | 'per-thread' | 'agent-shared',
): { session: Session; created: boolean } {
@@ -74,7 +74,7 @@ export function resolveSession(
if (existing) {
return { session: existing, created: false };
}
} else {
} else if (messagingGroupId) {
const lookupThreadId = sessionMode === 'shared' ? null : threadId;
const existing = findSession(messagingGroupId, lookupThreadId);
if (existing) {
@@ -144,6 +144,9 @@ export function writeSessionMessage(
recurrence?: string | null;
},
): void {
// Extract base64 attachment data, save to inbox, replace with file paths
const content = extractAttachmentFiles(agentGroupId, sessionId, message.id, message.content);
const dbPath = inboundDbPath(agentGroupId, sessionId);
const db = new Database(dbPath);
db.pragma('journal_mode = DELETE');
@@ -166,7 +169,7 @@ export function writeSessionMessage(
platformId: message.platformId ?? null,
channelType: message.channelType ?? null,
threadId: message.threadId ?? null,
content: message.content,
content,
processAfter: message.processAfter ?? null,
recurrence: message.recurrence ?? null,
});
@@ -177,6 +180,44 @@ export function writeSessionMessage(
updateSession(sessionId, { last_active: new Date().toISOString() });
}
/**
* If message content has attachments with base64 `data`, save them to
* the session's inbox directory and replace with `localPath`.
*/
function extractAttachmentFiles(
agentGroupId: string,
sessionId: string,
messageId: string,
contentStr: string,
): string {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(contentStr);
} catch {
return contentStr;
}
const attachments = parsed.attachments as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(attachments)) return contentStr;
let changed = false;
for (const att of attachments) {
if (typeof att.data === 'string') {
const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId);
fs.mkdirSync(inboxDir, { recursive: true });
const filename = (att.name as string) || `attachment-${Date.now()}`;
const filePath = path.join(inboxDir, filename);
fs.writeFileSync(filePath, Buffer.from(att.data as string, 'base64'));
att.localPath = `inbox/${messageId}/${filename}`;
delete att.data;
changed = true;
log.debug('Saved attachment to inbox', { messageId, filename, size: att.size });
}
}
return changed ? JSON.stringify(parsed) : contentStr;
}
/** Open the inbound DB for a session (host reads/writes). */
export function openInboundDb(agentGroupId: string, sessionId: string): Database.Database {
const dbPath = inboundDbPath(agentGroupId, sessionId);
@@ -201,6 +242,27 @@ export function openSessionDb(agentGroupId: string, sessionId: string): Database
return openInboundDb(agentGroupId, sessionId);
}
/** Write a system response to a session's inbound.db so the container's findQuestionResponse() picks it up. */
export function writeSystemResponse(
agentGroupId: string,
sessionId: string,
requestId: string,
status: string,
result: Record<string, unknown>,
): void {
writeSessionMessage(agentGroupId, sessionId, {
id: `sys-resp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'system',
timestamp: new Date().toISOString(),
content: JSON.stringify({
type: 'question_response',
questionId: requestId,
status,
result,
}),
});
}
/** Mark a container as running for a session. */
export function markContainerRunning(sessionId: string): void {
updateSession(sessionId, { container_status: 'running', last_active: new Date().toISOString() });