The poll loop had a bare-text routing fallback in dispatchResultText: when the agent produced text without <message to="..."> wrapping, it would auto- route to the session's originating channel (via a frozen RoutingContext) or to the single configured destination. This caused three problems: 1. Routing drift: RoutingContext was extracted once from the initial batch and never refreshed. When the initial batch was a null-routed cron task and a real chat arrived mid-query, replies were silently dropped to scratchpad because the frozen routing had all-null fields. 2. Cross-channel thread bleed: sendToDestination applied a single routing.threadId to every outbound message regardless of destination. In agent-shared sessions (multiple channels sharing one session), one channel's thread ID was stamped onto messages to a different channel. 3. Inconsistent formatting: task, webhook, and system messages had no origin metadata in their formatted output, so the agent couldn't tell which destination they came from — even when the underlying messages_in rows carried routing fields. Changes: - Remove the bare-text routing fallbacks in dispatchResultText (both the routing-based and single-destination shortcuts). All agent output must be wrapped in <message to="name">...</message>. Bare text is scratchpad. - Update buildDestinationsSection() to require explicit wrapping for all groups, including single-destination. No more "no special wrapping needed" shortcut. - Resolve thread_id per-destination via resolveDestinationThread(), which queries messages_in for the most recent message matching the target channel+platform. Falls back to null (top-level channel message) when no prior inbound exists for that destination. - Extract originAttr() helper in formatter.ts and apply it to all message types. Tasks now render as <task from="dest" time="...">, webhooks as <webhook from="dest" source="..." event="...">, system responses as <system_response from="dest" ...>. The agent always sees where a message originated. - Add a PreCompact shell hook (compact-instructions.ts) that outputs custom compaction instructions, telling the compactor to preserve recent message XML structure and routing metadata in the summary. Wired via settings.json in the .claude-shared scaffold, with a migration path (ensurePreCompactHook) for existing groups. Relation to open PRs: - #2277 (mergeRouting) becomes unnecessary — the routing fallback it patches no longer exists. Can be closed. - #2327 (post-compaction destination reminder) is complementary — it handles the post-compaction push, this handles pre-compaction instructions. Both can merge independently. - #2328 (default routing instruction) is complementary — it adds "reply to the from= destination" guidance to the multi-destination section. Compatible with the unified instruction format here. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
128 lines
4.6 KiB
TypeScript
128 lines
4.6 KiB
TypeScript
/**
|
|
* Destination map — lives in inbound.db's `destinations` table.
|
|
*
|
|
* The host writes this table before every container wake AND on demand
|
|
* (e.g. when a new child agent is created mid-session). The container
|
|
* queries the table live on every lookup, so admin changes take effect
|
|
* immediately — no restart required.
|
|
*
|
|
* This table is BOTH the routing map and the container-visible ACL.
|
|
* The host re-validates on the delivery side against the central DB,
|
|
* so even if this table is stale the host's enforcement is authoritative.
|
|
*/
|
|
import { getInboundDb } from './db/connection.js';
|
|
|
|
export interface DestinationEntry {
|
|
name: string;
|
|
displayName: string;
|
|
type: 'channel' | 'agent';
|
|
channelType?: string;
|
|
platformId?: string;
|
|
agentGroupId?: string;
|
|
}
|
|
|
|
interface DestRow {
|
|
name: string;
|
|
display_name: string | null;
|
|
type: 'channel' | 'agent';
|
|
channel_type: string | null;
|
|
platform_id: string | null;
|
|
agent_group_id: string | null;
|
|
}
|
|
|
|
function rowToEntry(row: DestRow): DestinationEntry {
|
|
return {
|
|
name: row.name,
|
|
displayName: row.display_name ?? row.name,
|
|
type: row.type,
|
|
channelType: row.channel_type ?? undefined,
|
|
platformId: row.platform_id ?? undefined,
|
|
agentGroupId: row.agent_group_id ?? undefined,
|
|
};
|
|
}
|
|
|
|
export function getAllDestinations(): DestinationEntry[] {
|
|
const rows = getInboundDb().prepare('SELECT * FROM destinations ORDER BY name').all() as DestRow[];
|
|
return rows.map(rowToEntry);
|
|
}
|
|
|
|
export function findByName(name: string): DestinationEntry | undefined {
|
|
const row = getInboundDb().prepare('SELECT * FROM destinations WHERE name = ?').get(name) as DestRow | undefined;
|
|
return row ? rowToEntry(row) : undefined;
|
|
}
|
|
|
|
/**
|
|
* Reverse lookup: given routing fields from an inbound message, find
|
|
* which destination they correspond to (what does this agent call the sender?).
|
|
*/
|
|
export function findByRouting(
|
|
channelType: string | null | undefined,
|
|
platformId: string | null | undefined,
|
|
): DestinationEntry | undefined {
|
|
if (!channelType || !platformId) return undefined;
|
|
const db = getInboundDb();
|
|
const row =
|
|
channelType === 'agent'
|
|
? (db
|
|
.prepare("SELECT * FROM destinations WHERE type = 'agent' AND agent_group_id = ?")
|
|
.get(platformId) as DestRow | undefined)
|
|
: (db
|
|
.prepare("SELECT * FROM destinations WHERE type = 'channel' AND channel_type = ? AND platform_id = ?")
|
|
.get(channelType, platformId) as DestRow | undefined);
|
|
return row ? rowToEntry(row) : undefined;
|
|
}
|
|
|
|
/**
|
|
* Generate the system-prompt addendum: agent identity + destination map.
|
|
*
|
|
* Identity is injected here (not in the shared CLAUDE.md) because it's
|
|
* per-agent-group and changes when the operator renames an agent, while
|
|
* the shared base is identical across all agents.
|
|
*/
|
|
export function buildSystemPromptAddendum(assistantName?: string): string {
|
|
const sections: string[] = [];
|
|
|
|
if (assistantName) {
|
|
sections.push(['# You are ' + assistantName, '', `Your name is **${assistantName}**. Use it when the channel asks who you are, when introducing yourself, and when signing any message that explicitly calls for a signature.`].join('\n'));
|
|
}
|
|
|
|
sections.push(buildDestinationsSection());
|
|
|
|
return sections.join('\n\n');
|
|
}
|
|
|
|
function buildDestinationsSection(): string {
|
|
const all = getAllDestinations();
|
|
|
|
if (all.length === 0) {
|
|
return [
|
|
'## Sending messages',
|
|
'',
|
|
'You currently have no configured destinations. You cannot send messages until an admin wires one up.',
|
|
].join('\n');
|
|
}
|
|
|
|
const lines = ['## Sending messages', ''];
|
|
if (all.length === 1) {
|
|
const d = all[0];
|
|
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
|
lines.push(`Your destination is \`${d.name}\`${label}.`);
|
|
} else {
|
|
lines.push('You can send messages to the following destinations:', '');
|
|
for (const d of all) {
|
|
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
|
lines.push(`- \`${d.name}\`${label}`);
|
|
}
|
|
}
|
|
lines.push('');
|
|
lines.push('**Every response must be wrapped** in a `<message to="name">...</message>` block.');
|
|
lines.push('You can include multiple `<message>` blocks in one response to send to multiple destinations.');
|
|
lines.push('Text outside of `<message>` blocks is scratchpad — logged but not sent anywhere.');
|
|
lines.push('Use `<internal>...</internal>` to make scratchpad intent explicit.');
|
|
lines.push('');
|
|
lines.push(
|
|
'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool with the `to` parameter set to a destination name.',
|
|
);
|
|
return lines.join('\n');
|
|
}
|