From 09e1861a22190ed453500bad571f4c2618664d48 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 10 Apr 2026 16:36:09 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20single-destination=20shortcut=20?= =?UTF-8?q?=E2=80=94=20no=20wrapping=20needed=20when=20there's=20only=20on?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an agent has exactly one configured destination, wrapping output in blocks is unnecessary. Plain text goes to the sole destination automatically. This preserves the simple "just reply" flow for the common case of one user on one channel. Applies in three places: - System prompt addendum: single-destination case gets a simplified explanation ("your messages are delivered to X, just write directly"). Multi-destination case keeps the syntax docs. - Main output parser: if zero blocks are found and there is exactly one destination, the entire cleaned text (with stripped) is sent to that destination. - send_message / send_file MCP tools: `to` parameter is now optional. With one destination, omitted defaults to it. With multiple, omitting returns an error listing the options. Multi-destination behavior is unchanged — explicit is still required, and untagged text is still scratchpad. groups/global/CLAUDE.md updated to describe both cases. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/destinations.ts | 16 ++++++ container/agent-runner/src/mcp-tools/core.ts | 60 ++++++++++++-------- container/agent-runner/src/poll-loop.ts | 51 +++++++++++------ groups/global/CLAUDE.md | 19 +++---- 4 files changed, 96 insertions(+), 50 deletions(-) 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