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:
@@ -90,8 +90,10 @@ export function initTestSessionDb(): { inbound: Database.Database; outbound: Dat
|
||||
content TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE delivered (
|
||||
message_out_id TEXT PRIMARY KEY,
|
||||
delivered_at TEXT NOT NULL
|
||||
message_out_id TEXT PRIMARY KEY,
|
||||
platform_message_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'delivered',
|
||||
delivered_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
|
||||
@@ -70,16 +70,37 @@ export function writeMessageOut(msg: WriteMessageOut): number {
|
||||
/**
|
||||
* Look up a message's platform ID by seq number.
|
||||
* Searches both inbound and outbound DBs since seq spans both.
|
||||
*
|
||||
* For inbound messages, the Chat SDK message ID is already the platform message ID
|
||||
* (e.g., "6037840640:42" for Telegram).
|
||||
*
|
||||
* For outbound messages, the internal ID (msg-xxx) won't work for edits/reactions.
|
||||
* Instead, look up the platform_message_id from the delivered table (host writes this
|
||||
* after successful delivery).
|
||||
*/
|
||||
export function getMessageIdBySeq(seq: number): string | null {
|
||||
const inRow = getInboundDb().prepare('SELECT id FROM messages_in WHERE seq = ?').get(seq) as
|
||||
const inbound = getInboundDb();
|
||||
|
||||
// Inbound messages: ID is already the platform message ID
|
||||
const inRow = inbound.prepare('SELECT id FROM messages_in WHERE seq = ?').get(seq) as
|
||||
| { id: string }
|
||||
| undefined;
|
||||
if (inRow) return inRow.id;
|
||||
|
||||
// Outbound messages: look up platform message ID from delivered table
|
||||
const outRow = getOutboundDb().prepare('SELECT id FROM messages_out WHERE seq = ?').get(seq) as
|
||||
| { id: string }
|
||||
| undefined;
|
||||
return outRow?.id ?? null;
|
||||
if (!outRow) return null;
|
||||
|
||||
// Check if host has stored the platform message ID after delivery
|
||||
const deliveredRow = inbound
|
||||
.prepare('SELECT platform_message_id FROM delivered WHERE message_out_id = ?')
|
||||
.get(outRow.id) as { platform_message_id: string | null } | undefined;
|
||||
if (deliveredRow?.platform_message_id) return deliveredRow.platform_message_id;
|
||||
|
||||
// Fallback to internal ID (edits/reactions on undelivered messages won't work)
|
||||
return outRow.id;
|
||||
}
|
||||
|
||||
/** Get undelivered messages (for host polling — reads from outbound.db). */
|
||||
|
||||
@@ -109,13 +109,7 @@ function formatChatMessages(messages: MessageInRow[]): string {
|
||||
|
||||
const lines = ['<messages>'];
|
||||
for (const msg of messages) {
|
||||
const content = parseContent(msg.content);
|
||||
const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown';
|
||||
const time = formatTime(msg.timestamp);
|
||||
const text = content.text || '';
|
||||
const idAttr = msg.seq != null ? ` id="${msg.seq}"` : '';
|
||||
const attachmentsSuffix = formatAttachments(content.attachments);
|
||||
lines.push(`<message${idAttr} sender="${escapeXml(sender)}" time="${time}">${escapeXml(text)}${attachmentsSuffix}</message>`);
|
||||
lines.push(formatSingleChat(msg));
|
||||
}
|
||||
lines.push('</messages>');
|
||||
return lines.join('\n');
|
||||
@@ -127,8 +121,9 @@ function formatSingleChat(msg: MessageInRow): string {
|
||||
const time = formatTime(msg.timestamp);
|
||||
const text = content.text || '';
|
||||
const idAttr = msg.seq != null ? ` id="${msg.seq}"` : '';
|
||||
const replyPrefix = formatReplyContext(content.replyTo);
|
||||
const attachmentsSuffix = formatAttachments(content.attachments);
|
||||
return `<message${idAttr} sender="${escapeXml(sender)}" time="${time}">${escapeXml(text)}${attachmentsSuffix}</message>`;
|
||||
return `<message${idAttr} sender="${escapeXml(sender)}" time="${time}">${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
|
||||
}
|
||||
|
||||
function formatTaskMessage(msg: MessageInRow): string {
|
||||
@@ -153,13 +148,26 @@ function formatSystemMessage(msg: MessageInRow): string {
|
||||
return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function formatReplyContext(replyTo: any): string {
|
||||
if (!replyTo) return '';
|
||||
const sender = replyTo.sender || 'Unknown';
|
||||
const text = replyTo.text || '';
|
||||
const preview = text.length > 100 ? text.slice(0, 100) + '…' : text;
|
||||
return `\n<reply-to sender="${escapeXml(sender)}">${escapeXml(preview)}</reply-to>\n`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function formatAttachments(attachments: any[] | undefined): string {
|
||||
if (!Array.isArray(attachments) || attachments.length === 0) return '';
|
||||
const parts = attachments.map((a) => {
|
||||
const name = a.name || a.filename || 'attachment';
|
||||
const type = a.type || 'file';
|
||||
const localPath = a.localPath ? `/workspace/${a.localPath}` : '';
|
||||
const url = a.url || '';
|
||||
if (localPath) {
|
||||
return `[${type}: ${escapeXml(name)} — saved to ${escapeXml(localPath)}]`;
|
||||
}
|
||||
return url ? `[${type}: ${escapeXml(name)} (${escapeXml(url)})]` : `[${type}: ${escapeXml(name)}]`;
|
||||
});
|
||||
return '\n' + parts.join('\n');
|
||||
|
||||
@@ -76,20 +76,36 @@ async function main(): Promise<void> {
|
||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW: '165000',
|
||||
};
|
||||
|
||||
// Build MCP servers config: nanoclaw built-in + any additional from host
|
||||
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
|
||||
nanoclaw: {
|
||||
command: 'node',
|
||||
args: [mcpServerPath],
|
||||
env: {
|
||||
SESSION_INBOUND_DB_PATH: process.env.SESSION_INBOUND_DB_PATH || '/workspace/inbound.db',
|
||||
SESSION_OUTBOUND_DB_PATH: process.env.SESSION_OUTBOUND_DB_PATH || '/workspace/outbound.db',
|
||||
SESSION_HEARTBEAT_PATH: process.env.SESSION_HEARTBEAT_PATH || '/workspace/.heartbeat',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Merge additional MCP servers from host configuration
|
||||
if (process.env.NANOCLAW_MCP_SERVERS) {
|
||||
try {
|
||||
const additional = JSON.parse(process.env.NANOCLAW_MCP_SERVERS) as Record<string, { command: string; args: string[]; env: Record<string, string> }>;
|
||||
for (const [name, config] of Object.entries(additional)) {
|
||||
mcpServers[name] = config;
|
||||
log(`Additional MCP server: ${name} (${config.command})`);
|
||||
}
|
||||
} catch (e) {
|
||||
log(`Failed to parse NANOCLAW_MCP_SERVERS: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
await runPollLoop({
|
||||
provider,
|
||||
cwd: CWD,
|
||||
mcpServers: {
|
||||
nanoclaw: {
|
||||
command: 'node',
|
||||
args: [mcpServerPath],
|
||||
env: {
|
||||
SESSION_INBOUND_DB_PATH: process.env.SESSION_INBOUND_DB_PATH || '/workspace/inbound.db',
|
||||
SESSION_OUTBOUND_DB_PATH: process.env.SESSION_OUTBOUND_DB_PATH || '/workspace/outbound.db',
|
||||
SESSION_HEARTBEAT_PATH: process.env.SESSION_HEARTBEAT_PATH || '/workspace/.heartbeat',
|
||||
},
|
||||
},
|
||||
},
|
||||
mcpServers,
|
||||
systemPrompt,
|
||||
env,
|
||||
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Agent-to-agent MCP tools: send_to_agent.
|
||||
* Agent-to-agent MCP tools: send_to_agent, create_agent.
|
||||
*/
|
||||
import { findQuestionResponse, markCompleted } from '../db/messages-in.js';
|
||||
import { writeMessageOut } from '../db/messages-out.js';
|
||||
import type { McpToolDefinition } from './types.js';
|
||||
|
||||
@@ -20,6 +21,10 @@ function err(text: string) {
|
||||
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const sendToAgent: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'send_to_agent',
|
||||
@@ -55,4 +60,56 @@ export const sendToAgent: McpToolDefinition = {
|
||||
},
|
||||
};
|
||||
|
||||
export const agentTools: McpToolDefinition[] = [sendToAgent];
|
||||
export const createAgent: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'create_agent',
|
||||
description: 'Create a new agent group dynamically. Returns the new agent group ID.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Agent display name' },
|
||||
instructions: { type: 'string', description: 'CLAUDE.md content (agent instructions/personality)' },
|
||||
folder: { type: 'string', description: 'Folder name (default: auto-generated from name)' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const name = args.name as string;
|
||||
if (!name) return err('name is required');
|
||||
|
||||
const requestId = generateId();
|
||||
|
||||
writeMessageOut({
|
||||
id: requestId,
|
||||
kind: 'system',
|
||||
content: JSON.stringify({
|
||||
action: 'create_agent',
|
||||
requestId,
|
||||
name,
|
||||
instructions: (args.instructions as string) || null,
|
||||
folder: (args.folder as string) || null,
|
||||
}),
|
||||
});
|
||||
|
||||
log(`create_agent: ${requestId} → "${name}"`);
|
||||
|
||||
// Poll for host response
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const response = findQuestionResponse(requestId);
|
||||
if (response) {
|
||||
const parsed = JSON.parse(response.content);
|
||||
markCompleted([response.id]);
|
||||
if (parsed.status === 'success') {
|
||||
return ok(`Agent created: ${parsed.result.agentGroupId} (name: ${parsed.result.name}, folder: ${parsed.result.folder})`);
|
||||
}
|
||||
return err(parsed.result?.error || 'Failed to create agent');
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
return err('Timed out waiting for agent creation response');
|
||||
},
|
||||
};
|
||||
|
||||
export const agentTools: McpToolDefinition[] = [sendToAgent, createAgent];
|
||||
|
||||
@@ -14,12 +14,13 @@ import { coreTools } from './core.js';
|
||||
import { schedulingTools } from './scheduling.js';
|
||||
import { interactiveTools } from './interactive.js';
|
||||
import { agentTools } from './agents.js';
|
||||
import { selfModTools } from './self-mod.js';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[mcp-tools] ${msg}`);
|
||||
}
|
||||
|
||||
const allTools: McpToolDefinition[] = [...coreTools, ...schedulingTools, ...interactiveTools, ...agentTools];
|
||||
const allTools: McpToolDefinition[] = [...coreTools, ...schedulingTools, ...interactiveTools, ...agentTools, ...selfModTools];
|
||||
|
||||
const toolMap = new Map<string, McpToolDefinition>();
|
||||
for (const t of allTools) {
|
||||
|
||||
155
container/agent-runner/src/mcp-tools/self-mod.ts
Normal file
155
container/agent-runner/src/mcp-tools/self-mod.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Self-modification MCP tools: install_packages, add_mcp_server, request_rebuild.
|
||||
*
|
||||
* These tools request changes to the agent's container configuration.
|
||||
* install_packages and request_rebuild require admin approval.
|
||||
* add_mcp_server takes effect on next container restart without approval.
|
||||
*/
|
||||
import { findQuestionResponse, markCompleted } from '../db/messages-in.js';
|
||||
import { writeMessageOut } from '../db/messages-out.js';
|
||||
import type { McpToolDefinition } from './types.js';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[mcp-tools] ${msg}`);
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function ok(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }] };
|
||||
}
|
||||
|
||||
function err(text: string) {
|
||||
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function pollForResponse(requestId: string, timeoutMs: number) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const response = findQuestionResponse(requestId);
|
||||
if (response) {
|
||||
const parsed = JSON.parse(response.content);
|
||||
markCompleted([response.id]);
|
||||
if (parsed.status === 'success') {
|
||||
return ok(JSON.stringify(parsed.result || 'Success'));
|
||||
}
|
||||
return err(parsed.result?.error || parsed.selectedOption || 'Request denied');
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
return err(`Request timed out after ${timeoutMs / 1000}s`);
|
||||
}
|
||||
|
||||
export const installPackages: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'install_packages',
|
||||
description:
|
||||
'Request installation of system (apt) or Node.js (npm) packages in the container. Requires admin approval. Takes effect after container rebuild.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
apt: { type: 'array', items: { type: 'string' }, description: 'apt packages to install' },
|
||||
npm: { type: 'array', items: { type: 'string' }, description: 'npm packages to install globally' },
|
||||
reason: { type: 'string', description: 'Why these packages are needed' },
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const apt = (args.apt as string[]) || [];
|
||||
const npm = (args.npm as string[]) || [];
|
||||
if (apt.length === 0 && npm.length === 0) return err('At least one apt or npm package is required');
|
||||
|
||||
const requestId = generateId();
|
||||
writeMessageOut({
|
||||
id: requestId,
|
||||
kind: 'system',
|
||||
content: JSON.stringify({
|
||||
action: 'install_packages',
|
||||
requestId,
|
||||
apt,
|
||||
npm,
|
||||
reason: (args.reason as string) || '',
|
||||
}),
|
||||
});
|
||||
|
||||
log(`install_packages: ${requestId} → apt=[${apt.join(',')}] npm=[${npm.join(',')}]`);
|
||||
return await pollForResponse(requestId, 300_000);
|
||||
},
|
||||
};
|
||||
|
||||
export const addMcpServer: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'add_mcp_server',
|
||||
description:
|
||||
"Add an MCP server to this agent's configuration. Takes effect on next container restart (no rebuild needed, no approval required).",
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
name: { type: 'string', description: 'MCP server name (unique identifier)' },
|
||||
command: { type: 'string', description: 'Command to run the MCP server' },
|
||||
args: { type: 'array', items: { type: 'string' }, description: 'Command arguments' },
|
||||
env: { type: 'object', description: 'Environment variables for the server' },
|
||||
},
|
||||
required: ['name', 'command'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const name = args.name as string;
|
||||
const command = args.command as string;
|
||||
if (!name || !command) return err('name and command are required');
|
||||
|
||||
const requestId = generateId();
|
||||
writeMessageOut({
|
||||
id: requestId,
|
||||
kind: 'system',
|
||||
content: JSON.stringify({
|
||||
action: 'add_mcp_server',
|
||||
requestId,
|
||||
name,
|
||||
command,
|
||||
args: (args.args as string[]) || [],
|
||||
env: (args.env as Record<string, string>) || {},
|
||||
}),
|
||||
});
|
||||
|
||||
log(`add_mcp_server: ${requestId} → "${name}" (${command})`);
|
||||
return await pollForResponse(requestId, 30_000);
|
||||
},
|
||||
};
|
||||
|
||||
export const requestRebuild: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'request_rebuild',
|
||||
description:
|
||||
'Request a container rebuild to apply pending package installations. Requires admin approval. The current container will be stopped and restarted with the new image.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
reason: { type: 'string', description: 'Why the rebuild is needed' },
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const requestId = generateId();
|
||||
writeMessageOut({
|
||||
id: requestId,
|
||||
kind: 'system',
|
||||
content: JSON.stringify({
|
||||
action: 'request_rebuild',
|
||||
requestId,
|
||||
reason: (args.reason as string) || '',
|
||||
}),
|
||||
});
|
||||
|
||||
log(`request_rebuild: ${requestId}`);
|
||||
return await pollForResponse(requestId, 300_000);
|
||||
},
|
||||
};
|
||||
|
||||
export const selfModTools: McpToolDefinition[] = [installPackages, addMcpServer, requestRebuild];
|
||||
Reference in New Issue
Block a user