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

@@ -67,8 +67,8 @@ export interface ChannelAdapter {
teardown(): Promise<void>;
isConnected(): boolean;
// Outbound delivery
deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise<void>;
// Outbound delivery — returns the platform message ID if available
deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise<string | undefined>;
// Optional
setTyping?(platformId: string, threadId: string | null): Promise<void>;

View File

@@ -54,8 +54,9 @@ function createMockAdapter(
return setupConfig !== null;
},
async deliver(_platformId: string, _threadId: string | null, message: OutboundMessage) {
async deliver(_platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
delivered.push(message);
return undefined;
},
async setTyping() {},
@@ -213,8 +214,8 @@ describe('channel + router integration', () => {
setDeliveryAdapter({
async deliver(channelType, platformId, threadId, kind, content) {
const adapter = getChannelAdapter(channelType);
if (!adapter) return;
await adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content) });
if (!adapter) return undefined;
return adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content) });
},
});

View File

@@ -30,11 +30,23 @@ interface GatewayAdapter extends Adapter {
): Promise<Response>;
}
/** Reply context extracted from a platform's raw message. */
export interface ReplyContext {
text: string;
sender: string;
}
/** Extract reply context from a platform-specific raw message. Return null if no reply. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ReplyContextExtractor = (raw: Record<string, any>) => ReplyContext | null;
export interface ChatSdkBridgeConfig {
adapter: Adapter;
concurrency?: ConcurrencyStrategy;
/** Bot token for authenticating forwarded Gateway events (required for interaction handling). */
botToken?: string;
/** Platform-specific reply context extraction. */
extractReplyContext?: ReplyContextExtractor;
}
export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter {
@@ -53,11 +65,50 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
return map;
}
function messageToInbound(message: ChatMessage): InboundMessage {
async function messageToInbound(message: ChatMessage): Promise<InboundMessage> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const serialized = message.toJSON() as Record<string, any>;
// Download attachment data before serialization loses fetchData()
if (message.attachments && message.attachments.length > 0) {
const enriched = [];
for (const att of message.attachments) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const entry: Record<string, any> = {
type: att.type,
name: att.name,
mimeType: att.mimeType,
size: att.size,
width: (att as unknown as Record<string, unknown>).width,
height: (att as unknown as Record<string, unknown>).height,
};
if (att.fetchData) {
try {
const buffer = await att.fetchData();
entry.data = buffer.toString('base64');
} catch (err) {
log.warn('Failed to download attachment', { type: att.type, err });
}
}
enriched.push(entry);
}
serialized.attachments = enriched;
}
// Extract reply context via platform-specific hook
if (config.extractReplyContext && message.raw) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const replyTo = config.extractReplyContext(message.raw as Record<string, any>);
if (replyTo) serialized.replyTo = replyTo;
}
// Drop raw to save DB space (can be very large)
serialized.raw = undefined;
return {
id: message.id,
kind: 'chat-sdk',
content: message.toJSON(),
content: serialized,
timestamp: message.metadata.dateSent.toISOString(),
};
}
@@ -83,20 +134,20 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
// Subscribed threads — forward all messages
chat.onSubscribedMessage(async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
setupConfig.onInbound(channelId, thread.id, messageToInbound(message));
setupConfig.onInbound(channelId, thread.id, await messageToInbound(message));
});
// @mention in unsubscribed thread — forward + subscribe
chat.onNewMention(async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
setupConfig.onInbound(channelId, thread.id, messageToInbound(message));
setupConfig.onInbound(channelId, thread.id, await messageToInbound(message));
await thread.subscribe();
});
// DMs — always forward + subscribe
chat.onDirectMessage(async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
setupConfig.onInbound(channelId, null, messageToInbound(message));
setupConfig.onInbound(channelId, null, await messageToInbound(message));
await thread.subscribe();
});
@@ -108,6 +159,17 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
const questionId = parts[1];
const selectedOption = event.value || '';
const userId = event.user?.userId || '';
// Update the card to show the selected answer and remove buttons
try {
const tid = event.threadId;
await adapter.editMessage(tid, event.messageId, {
markdown: `❓ **Question**\n\n${selectedOption ? `✅ **${selectedOption}**` : '(clicked)'}`,
});
} catch (err) {
log.warn('Failed to update card after action', { err });
}
setupConfig.onAction(questionId, selectedOption, userId);
});
@@ -161,7 +223,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
log.info('Chat SDK bridge initialized', { adapter: adapter.name });
},
async deliver(platformId: string, threadId: string | null, message) {
async deliver(platformId: string, threadId: string | null, message): Promise<string | undefined> {
// platformId is already in the adapter's encoded format (e.g. "telegram:6037840640",
// "discord:guildId:channelId") — use it directly as the thread ID
const tid = threadId ?? platformId;
@@ -190,24 +252,36 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
Actions(options.map((opt) => Button({ id: `ncq:${questionId}:${opt}`, label: opt, value: opt }))),
],
});
await adapter.postMessage(tid, { card, fallbackText: `${content.question}\nOptions: ${options.join(', ')}` });
return;
const result = await adapter.postMessage(tid, {
card,
fallbackText: `${content.question}\nOptions: ${options.join(', ')}`,
});
return result?.id;
}
// Normal message
const text = (content.markdown as string) || (content.text as string);
if (text) {
// Attach files if present (FileUpload format: { data, filename })
const fileUploads = message.files?.map((f) => ({ data: f.data, filename: f.filename }));
const fileUploads = message.files?.map((f: { data: Buffer; filename: string }) => ({
data: f.data,
filename: f.filename,
}));
if (fileUploads && fileUploads.length > 0) {
await adapter.postMessage(tid, { markdown: text, files: fileUploads });
const result = await adapter.postMessage(tid, { markdown: text, files: fileUploads });
return result?.id;
} else {
await adapter.postMessage(tid, { markdown: text });
const result = await adapter.postMessage(tid, { markdown: text });
return result?.id;
}
} else if (message.files && message.files.length > 0) {
// Files only, no text
const fileUploads = message.files.map((f) => ({ data: f.data, filename: f.filename }));
await adapter.postMessage(tid, { markdown: '', files: fileUploads });
const fileUploads = message.files.map((f: { data: Buffer; filename: string }) => ({
data: f.data,
filename: f.filename,
}));
const result = await adapter.postMessage(tid, { markdown: '', files: fileUploads });
return result?.id;
}
},

View File

@@ -5,9 +5,19 @@
import { createDiscordAdapter } from '@chat-adapter/discord';
import { readEnvFile } from '../env.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractReplyContext(raw: Record<string, any>): ReplyContext | null {
if (!raw.referenced_message) return null;
const reply = raw.referenced_message;
return {
text: reply.content || '',
sender: reply.author?.global_name || reply.author?.username || 'Unknown',
};
}
registerChannelAdapter('discord', {
factory: () => {
const env = readEnvFile(['DISCORD_BOT_TOKEN', 'DISCORD_PUBLIC_KEY', 'DISCORD_APPLICATION_ID']);
@@ -17,6 +27,11 @@ registerChannelAdapter('discord', {
publicKey: env.DISCORD_PUBLIC_KEY,
applicationId: env.DISCORD_APPLICATION_ID,
});
return createChatSdkBridge({ adapter: discordAdapter, concurrency: 'concurrent', botToken: env.DISCORD_BOT_TOKEN });
return createChatSdkBridge({
adapter: discordAdapter,
concurrency: 'concurrent',
botToken: env.DISCORD_BOT_TOKEN,
extractReplyContext,
});
},
});

View File

@@ -5,9 +5,19 @@
import { createTelegramAdapter } from '@chat-adapter/telegram';
import { readEnvFile } from '../env.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractReplyContext(raw: Record<string, any>): ReplyContext | null {
if (!raw.reply_to_message) return null;
const reply = raw.reply_to_message;
return {
text: reply.text || reply.caption || '',
sender: reply.from?.first_name || reply.from?.username || 'Unknown',
};
}
registerChannelAdapter('telegram', {
factory: () => {
const env = readEnvFile(['TELEGRAM_BOT_TOKEN']);
@@ -16,6 +26,6 @@ registerChannelAdapter('telegram', {
botToken: env.TELEGRAM_BOT_TOKEN,
mode: 'polling',
});
return createChatSdkBridge({ adapter: telegramAdapter, concurrency: 'concurrent' });
return createChatSdkBridge({ adapter: telegramAdapter, concurrency: 'concurrent', extractReplyContext });
},
});