feat(v2): builder-agent self-modification WIP + container-config as per-group file

Checkpoints the builder-agent dev-agent/worktree/swap flow (create_dev_agent,
request_swap, classifier, deadman, promote) before pivoting to a unified
draft-activate approach with OS-level RO enforcement. Lifts container_config
out of the agent_groups row into groups/<folder>/container.json so install_packages,
add_mcp_server, and rebuild flows can eventually route through the same draft
path as source edits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-15 18:42:10 +03:00
parent c54c779834
commit 75c2fde2b5
48 changed files with 4385 additions and 134 deletions

View File

@@ -23,11 +23,18 @@ export interface CommandInfo {
/**
* Categorize a message as a command or not.
* Only applies to chat/chat-sdk messages.
*
* The extracted `senderId` is compared against `NANOCLAW_ADMIN_USER_IDS`
* which stores ids in the namespaced form `<channel_type>:<raw>` (see
* src/db/users.ts). chat-sdk-bridge serializes `author.userId` as a raw
* platform id with no prefix, so we prefix it here. If the id already
* contains a `:` we assume it's pre-namespaced (non-chat-sdk adapters
* that populate `senderId` directly) and leave it alone.
*/
export function categorizeMessage(msg: MessageInRow): CommandInfo {
const content = parseContent(msg.content);
const text = (content.text || '').trim();
const senderId = content.senderId || content.author?.userId || null;
const senderId = extractSenderId(msg, content);
if (!text.startsWith('/')) {
return { category: 'none', command: '', text, senderId };
@@ -47,6 +54,17 @@ export function categorizeMessage(msg: MessageInRow): CommandInfo {
return { category: 'passthrough', command, text, senderId };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractSenderId(msg: MessageInRow, content: any): string | null {
const raw: string | null = content?.senderId || content?.author?.userId || null;
if (!raw) return null;
// Already namespaced (e.g. "telegram:123") — use as-is.
if (raw.includes(':')) return raw;
// Raw platform id from chat-sdk serialization — prefix with channel type.
if (!msg.channel_type) return raw;
return `${msg.channel_type}:${raw}`;
}
/**
* Routing context extracted from messages_in rows.
* Copied to messages_out by default so responses go back to the sender.

View File

@@ -31,7 +31,7 @@ export const createAgent: McpToolDefinition = {
tool: {
name: 'create_agent',
description:
'Create a new child agent with a given name. The name you choose becomes the destination name you use to message this agent. Admin-only. Fire-and-forget — you will receive a notification when the agent is created.',
"Create a long-lived companion sub-agent (research assistant, task manager, specialist) — the name becomes your destination for it. NOT for source-code changes — use `create_dev_agent` for those. Admin-only. Fire-and-forget.",
inputSchema: {
type: 'object' as const,
properties: {

View File

@@ -0,0 +1,116 @@
/**
* Builder-agent MCP tools: request_dev_changes (for originating agents) and
* request_swap (for dev agents).
*
* Both are fire-and-forget: the tool writes a system action row to
* messages_out and returns immediately. The host processes the request and
* notifies the agent via a chat message when complete.
*
* See `src/builder-agent/handlers.ts` on the host for the receive side.
*/
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 };
}
export const createDevAgent: McpToolDefinition = {
tool: {
name: 'create_dev_agent',
description:
"Spawn a dev agent to edit NanoClaw's own source code — new built-in MCP tools, runner/host bug fixes, new skill files, Dockerfile/package.json/migration changes, writing a new MCP server from scratch. Heaviest self-mod path: new container, git worktree, admin approval, swap-and-restart.\n\nPrefer lighter tools when they fit: `install_packages` (new apt/npm dep in your container), `add_mcp_server` (wire an EXISTING third-party server you can invoke by command+args), `trigger_credential_collection` (API key/token), `create_agent` (long-lived companion sub-agent), `request_rebuild` (rebuild after approved config change).\n\nTwo-step flow: (1) call with just a name — does NOT start work, (2) after the 'ready' notification, send task details via `<message to=\"<name>\">`. Do not include task details in this call.",
inputSchema: {
type: 'object' as const,
properties: {
name: {
type: 'string',
description:
'Short descriptive destination name for the dev agent (e.g. "dev-welcome-message", "dev-fix-typo"). Becomes the local destination you address it by. Tearing down a previous dev agent for this group is automatic on create.',
},
},
required: ['name'],
},
},
async handler(args) {
const name = (args.name as string)?.trim();
if (!name) return err('name is required');
const requestId = generateId();
writeMessageOut({
id: requestId,
kind: 'system',
content: JSON.stringify({
action: 'create_dev_agent',
requestId,
name,
}),
});
log(`create_dev_agent: ${requestId} → "${name}"`);
return ok(
`Dev agent creation submitted. You will be notified when it is ready. When you see that notification, send it a message with <message to="${name}">...task details here...</message> to kick off the work. The dev agent does NOT start working until you message it.`,
);
},
};
export const requestSwap: McpToolDefinition = {
tool: {
name: 'request_swap',
description:
'From a dev agent: submit your committed worktree changes for admin approval. The summaries become the human-readable portion of the approval card. Fire-and-forget.',
inputSchema: {
type: 'object' as const,
properties: {
overall_summary: {
type: 'string',
description:
'Overall summary of the code change: what it does, why, and any risk. This is what the admin/owner reads first, so be concrete.',
},
per_file_summaries: {
type: 'object',
description:
'Map of relative worktree path → one-sentence explanation of what changed in that file. Every changed file should have an entry.',
additionalProperties: { type: 'string' },
},
},
required: ['overall_summary', 'per_file_summaries'],
},
},
async handler(args) {
const overall = (args.overall_summary as string)?.trim();
const perFile = args.per_file_summaries as Record<string, string> | undefined;
if (!overall) return err('overall_summary is required');
if (!perFile || Object.keys(perFile).length === 0) return err('per_file_summaries is required and must be non-empty');
const requestId = generateId();
writeMessageOut({
id: requestId,
kind: 'system',
content: JSON.stringify({
action: 'request_swap',
overallSummary: overall,
perFileSummaries: perFile,
}),
});
log(`request_swap: ${requestId}${Object.keys(perFile).length} file(s)`);
return ok(
`Code change submitted. The host will classify the diff and route it for admin/owner approval. You will be notified once classification completes.`,
);
},
};
export const builderAgentTools: McpToolDefinition[] = [createDevAgent, requestSwap];

View File

@@ -35,7 +35,7 @@ export const triggerCredentialCollection: McpToolDefinition = {
tool: {
name: 'trigger_credential_collection',
description:
'Collect a credential (API key, token, etc.) from the user for a third-party service. Research the service first so you can pass the correct host pattern, header name, and value format. A card is sent to the user with a button that opens a secure input modal — the value is inserted directly into OneCLI and never enters your context. Blocks until the user saves, rejects, or the request fails.',
'Collect an API key / OAuth token / secret from the user for a third-party service. Research the service first so you pass the correct host pattern, header name, and value format. The value is injected straight into OneCLI and never enters your context. Blocks until saved/rejected/failed.',
inputSchema: {
type: 'object' as const,
properties: {

View File

@@ -16,6 +16,7 @@ import { interactiveTools } from './interactive.js';
import { agentTools } from './agents.js';
import { selfModTools } from './self-mod.js';
import { credentialTools } from './credentials.js';
import { builderAgentTools } from './builder-agent.js';
function log(msg: string): void {
console.error(`[mcp-tools] ${msg}`);
@@ -28,6 +29,7 @@ const allTools: McpToolDefinition[] = [
...agentTools,
...selfModTools,
...credentialTools,
...builderAgentTools,
];
const toolMap = new Map<string, McpToolDefinition>();

View File

@@ -35,7 +35,7 @@ export const installPackages: McpToolDefinition = {
tool: {
name: 'install_packages',
description:
'Request installation of apt or npm packages. Requires admin approval. Fire-and-forget: you will receive a notification when the request is approved or rejected. After approval, call request_rebuild to apply the changes.',
'Install apt and/or npm packages into YOUR per-agent container image. Prefer this over `create_dev_agent` when the request is just to make a package available. Requires admin approval; fire-and-forget. After approval, call `request_rebuild` to apply.',
inputSchema: {
type: 'object' as const,
properties: {
@@ -77,7 +77,7 @@ export const addMcpServer: McpToolDefinition = {
tool: {
name: 'add_mcp_server',
description:
"Request adding an MCP server to this agent's configuration. Requires admin approval. Fire-and-forget: you will be notified when approved/rejected. On approval, your container restarts with the new server.",
"Wire an EXISTING third-party MCP server into YOUR per-agent runtime config — you must already know the exact `command` + `args` to invoke it (e.g. `npx @modelcontextprotocol/server-github`). NOT for writing a new tool or server from scratch — use `create_dev_agent` for that. Requires admin approval; fire-and-forget.",
inputSchema: {
type: 'object' as const,
properties: {
@@ -116,7 +116,7 @@ export const requestRebuild: McpToolDefinition = {
tool: {
name: 'request_rebuild',
description:
'Request a container rebuild to apply pending package installations. Requires admin approval. Fire-and-forget: you will be notified when approved/rejected. On approval, your container restarts with the new image on the next message.',
'Rebuild YOUR container image to pick up approved `install_packages` / `add_mcp_server` changes. Requires admin approval; fire-and-forget.',
inputSchema: {
type: 'object' as const,
properties: {