diff --git a/container/agent-runner/src/destinations.ts b/container/agent-runner/src/destinations.ts index 663dcd4..57f151d 100644 --- a/container/agent-runner/src/destinations.ts +++ b/container/agent-runner/src/destinations.ts @@ -73,6 +73,22 @@ export function buildSystemPromptAddendum(): string { ].join('\n'); } + // Single-destination shortcut: the agent just writes its response normally. + // No wrapping needed. This preserves the simple case (one user, one channel). + if (cache.length === 1) { + const d = cache[0]; + const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : ''; + return [ + '## Sending messages', + '', + `Your messages are delivered to \`${d.name}\`${label}. Just write your response directly — no special wrapping needed.`, + '', + 'To mark something as scratchpad (logged but not sent), wrap it in `...`.', + '', + 'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool.', + ].join('\n'); + } + const lines = ['## Sending messages', '', 'You can send messages to the following destinations:', '']; for (const d of cache) { const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : ''; diff --git a/container/agent-runner/src/mcp-tools/core.ts b/container/agent-runner/src/mcp-tools/core.ts index d36b029..0180b72 100644 --- a/container/agent-runner/src/mcp-tools/core.ts +++ b/container/agent-runner/src/mcp-tools/core.ts @@ -35,37 +35,52 @@ function destinationList(): string { return all.map((d) => d.name).join(', '); } +/** + * Resolve a destination name to routing fields. + * If `to` is omitted and the agent has exactly one destination, that one is used. + * With multiple destinations, omitting `to` is an error. + */ function resolveRouting( - to: string, -): { channel_type: string; platform_id: string } | { error: string } { - const dest = findByName(to); - if (!dest) return { error: `Unknown destination "${to}". Known: ${destinationList()}` }; - if (dest.type === 'channel') { - return { channel_type: dest.channelType!, platform_id: dest.platformId! }; + to: string | undefined, +): { channel_type: string; platform_id: string; resolvedName: string } | { error: string } { + let name = to; + if (!name) { + const all = getAllDestinations(); + if (all.length === 0) return { error: 'No destinations configured.' }; + if (all.length > 1) { + return { + error: `You have multiple destinations — specify "to". Options: ${all.map((d) => d.name).join(', ')}`, + }; + } + name = all[0].name; } - return { channel_type: 'agent', platform_id: dest.agentGroupId! }; + const dest = findByName(name); + if (!dest) return { error: `Unknown destination "${name}". Known: ${destinationList()}` }; + if (dest.type === 'channel') { + return { channel_type: dest.channelType!, platform_id: dest.platformId!, resolvedName: name }; + } + return { channel_type: 'agent', platform_id: dest.agentGroupId!, resolvedName: name }; } export const sendMessage: McpToolDefinition = { tool: { name: 'send_message', description: - 'Send a message to a named destination. Use destination names from your system prompt (not raw IDs).', + 'Send a message to a named destination. If you have only one destination, you can omit `to`.', inputSchema: { type: 'object' as const, properties: { - to: { type: 'string', description: 'Destination name (e.g., "family", "worker-1")' }, + to: { type: 'string', description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.' }, text: { type: 'string', description: 'Message content' }, }, - required: ['to', 'text'], + required: ['text'], }, }, async handler(args) { - const to = args.to as string; const text = args.text as string; - if (!to || !text) return err('to and text are required'); + if (!text) return err('text is required'); - const routing = resolveRouting(to); + const routing = resolveRouting(args.to as string | undefined); if ('error' in routing) return err(routing.error); const id = generateId(); @@ -78,32 +93,31 @@ export const sendMessage: McpToolDefinition = { content: JSON.stringify({ text }), }); - log(`send_message: #${seq} → ${to}`); - return ok(`Message sent to ${to} (id: ${seq})`); + log(`send_message: #${seq} → ${routing.resolvedName}`); + return ok(`Message sent to ${routing.resolvedName} (id: ${seq})`); }, }; export const sendFile: McpToolDefinition = { tool: { name: 'send_file', - description: 'Send a file to a named destination.', + description: 'Send a file to a named destination. If you have only one destination, you can omit `to`.', inputSchema: { type: 'object' as const, properties: { - to: { type: 'string', description: 'Destination name' }, + to: { type: 'string', description: 'Destination name. Optional if you have only one destination.' }, path: { type: 'string', description: 'File path (relative to /workspace/agent/ or absolute)' }, text: { type: 'string', description: 'Optional accompanying message' }, filename: { type: 'string', description: 'Display name (default: basename of path)' }, }, - required: ['to', 'path'], + required: ['path'], }, }, async handler(args) { - const to = args.to as string; const filePath = args.path as string; - if (!to || !filePath) return err('to and path are required'); + if (!filePath) return err('path is required'); - const routing = resolveRouting(to); + const routing = resolveRouting(args.to as string | undefined); if ('error' in routing) return err(routing.error); const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve('/workspace/agent', filePath); @@ -125,8 +139,8 @@ export const sendFile: McpToolDefinition = { content: JSON.stringify({ text: (args.text as string) || '', files: [filename] }), }); - log(`send_file: ${id} → ${to} (${filename})`); - return ok(`File sent to ${to} (id: ${id}, filename: ${filename})`); + log(`send_file: ${id} → ${routing.resolvedName} (${filename})`); + return ok(`File sent to ${routing.resolvedName} (id: ${id}, filename: ${filename})`); }, }; diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 6b358de..83d0316 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -1,4 +1,4 @@ -import { findByName } from './destinations.js'; +import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js'; import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js'; import { writeMessageOut } from './db/messages-out.js'; import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; @@ -296,11 +296,14 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void { /** * Parse the agent's final text for ... blocks * and dispatch each one to its resolved destination. Text outside of blocks - * (including ...) is scratchpad — logged but not sent. + * (including ...) is normally scratchpad — logged but + * not sent. * - * If the agent emits zero blocks AND non-empty text, log a warning: - * the agent produced output with no recipient. That's usually a bug in the - * agent — the system prompt tells it to wrap user-visible text in blocks. + * Single-destination shortcut: if the agent has exactly one configured + * destination AND the output contains zero blocks, the entire + * cleaned text (with tags stripped) is sent to that destination. + * This preserves the simple case of one user on one channel — the agent + * doesn't need to know about wrapping syntax at all. */ function dispatchResultText(text: string, routing: RoutingContext): void { const MESSAGE_RE = /([\s\S]*?)<\/message>/g; @@ -324,18 +327,7 @@ function dispatchResultText(text: string, routing: RoutingContext): void { scratchpadParts.push(`[dropped: unknown destination "${toName}"] ${body}`); continue; } - - const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!; - const channelType = dest.type === 'channel' ? dest.channelType! : 'agent'; - writeMessageOut({ - id: generateId(), - in_reply_to: routing.inReplyTo, - kind: 'chat', - platform_id: platformId, - channel_type: channelType, - thread_id: null, - content: JSON.stringify({ text: body }), - }); + sendToDestination(dest, body, routing); sent++; } if (lastIndex < text.length) { @@ -346,6 +338,17 @@ function dispatchResultText(text: string, routing: RoutingContext): void { .join('') .replace(/[\s\S]*?<\/internal>/g, '') .trim(); + + // Single-destination shortcut: the agent wrote plain text and has exactly + // one destination. Send the entire cleaned text to it. + if (sent === 0 && scratchpad) { + const all = getAllDestinations(); + if (all.length === 1) { + sendToDestination(all[0], scratchpad, routing); + return; + } + } + if (scratchpad) { log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`); } @@ -355,6 +358,20 @@ function dispatchResultText(text: string, routing: RoutingContext): void { } } +function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void { + const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!; + const channelType = dest.type === 'channel' ? dest.channelType! : 'agent'; + writeMessageOut({ + id: generateId(), + in_reply_to: routing.inReplyTo, + kind: 'chat', + platform_id: platformId, + channel_type: channelType, + thread_id: null, + content: JSON.stringify({ text: body }), + }); +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index c95469e..cc5480f 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -16,24 +16,23 @@ You are Main, a personal assistant. You help with tasks, answer questions, and c Be concise — every message costs the reader's attention. -### Named destinations +### Destinations -You don't send messages to a "current conversation" — every outbound message goes to an explicitly named destination. The list of destinations available to you is injected into your system prompt at the start of every turn. - -**To send a message**, wrap it in a `...` block. You can include multiple blocks in one response to send to multiple destinations. Text outside of `` blocks is scratchpad — logged but never sent anywhere. +Each turn, your system prompt lists the destinations available to you. If you only have one destination, just write your response directly — it goes there automatically. If you have multiple, wrap each message in a `...` block: ``` On my way home, 15 minutes +kick off the pipeline ``` -Inbound messages are labeled with `from="name"` so you know which destination they came from and can reply by using that same name as `to=`. +Inbound messages are labeled with `from="name"` so you can tell which destination they came from and reply using that same name. ### Mid-turn updates -Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before your final output) — it takes the same `to` destination name. Pace your updates to the length of the work: +Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before your final output). If you have one destination, `to` is optional; with multiple, specify it. Pace your updates to the length of the work: -- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final `` block. -- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") via `send_message` so the user knows you got the message. +- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final response. +- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") so the user knows you got the message. - **Long-running work (many minutes, multi-step tasks):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages. **Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call. @@ -42,12 +41,12 @@ Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before yo ### Internal thoughts -If part of your output is internal reasoning rather than something for the reader, wrap it in `` tags — or just leave it as plain text outside any `` block. Both are scratchpad. +Wrap reasoning in `...` tags to mark it as scratchpad — logged but not sent. With multiple destinations, any text outside of `` blocks is also treated as scratchpad. With a single destination, only explicit `` tags are scratchpad; the rest of your response is sent. ``` Compiled all three reports, ready to summarize. -Here are the key findings from the research… +Here are the key findings from the research… ``` ### Sub-agents and teammates