Files
nanoclaw/container/agent-runner/src/formatter.ts
Mike Nolet 1ebb2dc8d2 fix(poll-loop): slash commands silently broken on warm containers
The follow-up poller filtered /clear out of every tick without acking
the row, and pushed every other slash command through plain
formatMessages() (XML wrapping). On a warm container the outer
while(true) loop never regains control, so:

  - /clear sat pending in messages_in forever (no response at all)
  - /compact, /cost, /context, /files, /remote-control arrived at the
    SDK as XML-wrapped user text and were never dispatched as commands

Both modes are invisible to host monitoring: rows are either left
pending without a processing_ack claim, or marked completed normally;
heartbeat keeps firing inside the SDK event loop.

When the follow-up poller observes any slash command (admin or
passthrough — categorizeMessage decides), end the active query so the
current turn winds down cleanly and the outer loop wakes, re-fetches
the same pending set, and runs them through the canonical path
(/clear handler + formatMessagesWithCommands raw dispatch). Leave the
rows untouched so the outer-loop fetch sees the same set the poller
saw.

Cost: each slash command on a warm container forces close+reopen of
the SDK stream — a few seconds of subprocess startup. The Anthropic
prompt cache is server-side with a 5-min TTL keyed on prefix hash, so
stream lifecycle does not affect cache lifetime; close+reopen within
5 min still gets cache hits.

Also corrects the warm-stream rationale comment on processQuery, which
implied keeping the stream open preserved cache warmth — it doesn't.

Testing evidence — cache stays warm across stream close+reopen:

  Turn 1 (warm session):
    Usage: in=6 out=245 cache_create=92 cache_read=22996
    Full cache hit (22996 tokens).

  Turn 2 — /clear arrives:
    Pending slash command — ending stream so outer loop can process
    Clearing session (resetting continuation)
    Usage: in=6 out=95 cache_create=9393 cache_read=13600
    System prompt + tool defs (~13600 tokens) still hit cache;
    conversation history is gone (continuation reset) so the new turn
    writes fresh context.

  Turn 3 — /cost arrives:
    Pending slash command — ending stream so outer loop can process
    Usage: in=0 out=0 cache_create=0 cache_read=0 wall=0.0s api=0.0s
    /cost is a CLI built-in: dispatched locally by the SDK, no API
    call. Pre-fix this would have arrived as XML-wrapped user text
    and never dispatched — confirms the broader fix works.

  Turn 4 (next chat after /cost):
    Usage: in=6 out=142 cache_create=328 cache_read=22993
    Full cache hit again (22993 tokens read, 328 written). Despite the
    /cost-induced stream close+reopen, the server-side prompt cache
    survived: the new sdkQuery() resumed the same continuation, the
    request prefix matched the cached entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 08:35:14 +02:00

271 lines
10 KiB
TypeScript

import { findByRouting } from './destinations.js';
import type { MessageInRow } from './db/messages-in.js';
import { TIMEZONE, formatLocalTime } from './timezone.js';
/**
* Command categories for messages starting with '/'.
* - admin: sender must be in NANOCLAW_ADMIN_USER_IDS
* - filtered: silently drop (mark completed without processing)
* - passthrough: pass raw to the agent (no XML wrapping)
* - none: not a command — format normally
*/
export type CommandCategory = 'admin' | 'filtered' | 'passthrough' | 'none';
const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact', '/context', '/cost', '/files']);
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/start']);
export interface CommandInfo {
category: CommandCategory;
command: string; // the command name (e.g., '/clear')
text: string; // full original text
senderId: string | null;
}
/**
* 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 = extractSenderId(msg, content);
if (!text.startsWith('/')) {
return { category: 'none', command: '', text, senderId };
}
// Extract the command name (e.g., '/clear' from '/clear some args')
const command = text.split(/\s/)[0].toLowerCase();
if (ADMIN_COMMANDS.has(command)) {
return { category: 'admin', command, text, senderId };
}
if (FILTERED_COMMANDS.has(command)) {
return { category: 'filtered', command, text, senderId };
}
return { category: 'passthrough', command, text, senderId };
}
/**
* Narrow check for /clear — the only command the runner handles directly.
* All other command gating (filtered, admin) is done by the host router
* before messages reach the container.
*/
export function isClearCommand(msg: MessageInRow): boolean {
const content = parseContent(msg.content);
const text = (content.text || '').trim();
return text.toLowerCase().startsWith('/clear');
}
/**
* True for any chat that needs the outer loop's command path: /clear plus
* admin/passthrough slash commands the SDK can only dispatch when they are
* a query's first input. Used by the follow-up poller to bail out and let
* the outer loop reopen the query.
*/
export function isRunnerCommand(msg: MessageInRow): boolean {
if (msg.kind !== 'chat' && msg.kind !== 'chat-sdk') return false;
const cat = categorizeMessage(msg).category;
return cat === 'admin' || cat === 'passthrough';
}
// 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.
*/
export interface RoutingContext {
platformId: string | null;
channelType: string | null;
threadId: string | null;
inReplyTo: string | null;
}
/**
* Extract routing context from a batch of messages.
* Uses the first message's routing fields.
*/
export function extractRouting(messages: MessageInRow[]): RoutingContext {
const first = messages[0];
return {
platformId: first?.platform_id ?? null,
channelType: first?.channel_type ?? null,
threadId: first?.thread_id ?? null,
inReplyTo: first?.id ?? null,
};
}
/**
* Format a batch of messages_in rows into a prompt string.
*
* Prepends a `<context timezone="<IANA>" />` header so the agent always knows
* what timezone it's in — every timestamp it sees in message bodies is the
* user's local time, and every time it produces (schedules, suggests) should
* be interpreted as local time in that same zone. This header is v1 behavior
* (src/v1/router.ts:20-22); dropping it led to misinterpretations where the
* agent scheduled tasks for the wrong hour.
*
* Strips routing fields — the agent never sees platform_id, channel_type, thread_id.
*/
export function formatMessages(messages: MessageInRow[]): string {
const header = `<context timezone="${escapeXml(TIMEZONE)}" />\n`;
if (messages.length === 0) return header;
// Group by kind
const chatMessages = messages.filter((m) => m.kind === 'chat' || m.kind === 'chat-sdk');
const taskMessages = messages.filter((m) => m.kind === 'task');
const webhookMessages = messages.filter((m) => m.kind === 'webhook');
const systemMessages = messages.filter((m) => m.kind === 'system');
const parts: string[] = [];
if (chatMessages.length > 0) {
parts.push(formatChatMessages(chatMessages));
}
if (taskMessages.length > 0) {
parts.push(...taskMessages.map(formatTaskMessage));
}
if (webhookMessages.length > 0) {
parts.push(...webhookMessages.map(formatWebhookMessage));
}
if (systemMessages.length > 0) {
parts.push(...systemMessages.map(formatSystemMessage));
}
return header + parts.join('\n\n');
}
function formatChatMessages(messages: MessageInRow[]): string {
if (messages.length === 1) {
return formatSingleChat(messages[0]);
}
const lines = ['<messages>'];
for (const msg of messages) {
lines.push(formatSingleChat(msg));
}
lines.push('</messages>');
return lines.join('\n');
}
function formatSingleChat(msg: MessageInRow): string {
const content = parseContent(msg.content);
const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown';
const time = formatLocalTime(msg.timestamp, TIMEZONE);
const text = content.text || '';
const idAttr = msg.seq != null ? ` id="${msg.seq}"` : '';
const replyAttr = content.replyTo?.id ? ` reply_to="${escapeXml(String(content.replyTo.id))}"` : '';
const replyPrefix = formatReplyContext(content.replyTo);
const attachmentsSuffix = formatAttachments(content.attachments);
// Look up the destination name for the origin (reverse map lookup).
// If not found, fall back to a raw channel:platform_id marker so nothing
// gets silently dropped — this should only happen if the destination was
// removed between when the message was received and when it's being processed.
const fromDest = findByRouting(msg.channel_type, msg.platform_id);
const fromAttr = fromDest
? ` from="${escapeXml(fromDest.name)}"`
: msg.channel_type || msg.platform_id
? ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`
: '';
return `<message${idAttr}${fromAttr} sender="${escapeXml(sender)}" time="${escapeXml(time)}"${replyAttr}>${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
}
function formatTaskMessage(msg: MessageInRow): string {
const content = parseContent(msg.content);
const parts = ['[SCHEDULED TASK]'];
if (content.scriptOutput) {
parts.push('', 'Script output:', JSON.stringify(content.scriptOutput, null, 2));
}
parts.push('', 'Instructions:', content.prompt || '');
return parts.join('\n');
}
function formatWebhookMessage(msg: MessageInRow): string {
const content = parseContent(msg.content);
const source = content.source || 'unknown';
const event = content.event || 'unknown';
return `[WEBHOOK: ${source}/${event}]\n\n${JSON.stringify(content.payload || content, null, 2)}`;
}
function formatSystemMessage(msg: MessageInRow): string {
const content = parseContent(msg.content);
return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`;
}
/**
* Render the quoted original inside the <message> body.
*
* Matches v1 format (src/v1/router.ts:10-18): `<quoted_message from="X">Y</quoted_message>`.
* Requires BOTH sender and text — if only id is present the reply_to attribute
* on the parent <message> carries the link without an inline preview.
*
* No truncation here (v1 didn't truncate).
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function formatReplyContext(replyTo: any): string {
if (!replyTo) return '';
const sender = replyTo.sender;
const text = replyTo.text;
if (!sender || !text) return '';
return `\n <quoted_message from="${escapeXml(sender)}">${escapeXml(text)}</quoted_message>\n`;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function formatAttachments(attachments: any[] | undefined): string {
if (!Array.isArray(attachments) || attachments.length === 0) return '';
const parts = attachments.map((a) => {
const name = a.name || a.filename || 'attachment';
const type = a.type || 'file';
const localPath = a.localPath ? `/workspace/${a.localPath}` : '';
const url = a.url || '';
if (localPath) {
return `[${type}: ${escapeXml(name)} — saved to ${escapeXml(localPath)}]`;
}
return url ? `[${type}: ${escapeXml(name)} (${escapeXml(url)})]` : `[${type}: ${escapeXml(name)}]`;
});
return '\n' + parts.join('\n');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function parseContent(json: string): any {
try {
return JSON.parse(json);
} catch {
return { text: json };
}
}
function escapeXml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* Strip `<internal>...</internal>` blocks from agent output, then trim.
* Ported from v1 (src/v1/router.ts:25-27). Used to remove the agent's
* own scratchpad/reasoning before a reply goes out over a channel.
*/
export function stripInternalTags(text: string): string {
return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
}