feat: single-destination shortcut — no wrapping needed when there's only one
When an agent has exactly one configured destination, wrapping output in
<message to="..."> 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 <message to="..."> syntax docs.
- Main output parser: if zero <message> blocks are found and there is
exactly one destination, the entire cleaned text (with <internal>
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 <message to="..."> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<internal>...</internal>`.',
|
||||
'',
|
||||
'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})` : '';
|
||||
|
||||
@@ -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})`);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 <message to="name">...</message> blocks
|
||||
* and dispatch each one to its resolved destination. Text outside of blocks
|
||||
* (including <internal>...</internal>) is scratchpad — logged but not sent.
|
||||
* (including <internal>...</internal>) is normally scratchpad — logged but
|
||||
* not sent.
|
||||
*
|
||||
* If the agent emits zero <message> 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 <message> blocks, the entire
|
||||
* cleaned text (with <internal> 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 = /<message\s+to="([^"]+)"\s*>([\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(/<internal>[\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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user