fix(poll-loop): nudge agent when output lacks message wrapping
When the agent outputs bare text without <message to="..."> blocks, nothing gets delivered — silent failure. Now the poll-loop pushes a one-shot correction back into the active query telling the agent to re-send with proper wrapping. Capped at once per user turn to avoid loops; resets when a new follow-up message arrives. Also updates destination instructions to require explicit <internal> wrapping for scratchpad instead of treating bare text as implicit scratchpad. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,7 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', ()
|
|||||||
|
|
||||||
const prompt = buildSystemPromptAddendum('Casa');
|
const prompt = buildSystemPromptAddendum('Casa');
|
||||||
|
|
||||||
expect(prompt).toContain('Every response must be wrapped');
|
expect(prompt).toContain('All output must be wrapped');
|
||||||
expect(prompt).toContain('<message to="name">');
|
expect(prompt).toContain('<message to="name">');
|
||||||
expect(prompt).toContain('`casa`');
|
expect(prompt).toContain('`casa`');
|
||||||
});
|
});
|
||||||
@@ -55,7 +55,7 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', ()
|
|||||||
|
|
||||||
const prompt = buildSystemPromptAddendum('Casa');
|
const prompt = buildSystemPromptAddendum('Casa');
|
||||||
|
|
||||||
expect(prompt).toContain('Every response must be wrapped');
|
expect(prompt).toContain('All output must be wrapped');
|
||||||
expect(prompt).toContain('<message to="name">');
|
expect(prompt).toContain('<message to="name">');
|
||||||
expect(prompt).toContain('Default routing');
|
expect(prompt).toContain('Default routing');
|
||||||
expect(prompt).toContain('`casa`');
|
expect(prompt).toContain('`casa`');
|
||||||
|
|||||||
@@ -115,10 +115,9 @@ function buildDestinationsSection(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines.push('');
|
lines.push('');
|
||||||
lines.push('**Every response must be wrapped** in a `<message to="name">...</message>` block.');
|
lines.push('**All output must be wrapped.** Use `<message to="name">...</message>` for content to send, or `<internal>...</internal>` for scratchpad.');
|
||||||
lines.push('You can include multiple `<message>` blocks in one response to send to multiple destinations.');
|
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('Bare text (outside of `<message>` or `<internal>` blocks) is not allowed and will not be delivered.');
|
||||||
lines.push('Use `<internal>...</internal>` to make scratchpad intent explicit.');
|
|
||||||
lines.push('');
|
lines.push('');
|
||||||
lines.push(
|
lines.push(
|
||||||
'**Default routing**: when replying to an incoming message, address the same destination the message came `from` — every inbound `<message>` tag carries a `from="name"` attribute that names the origin destination. Only address a different destination when the request itself asks you to (e.g., "tell Laura that…").',
|
'**Default routing**: when replying to an incoming message, address the same destination the message came `from` — every inbound `<message>` tag carries a `from="name"` attribute that names the origin destination. Only address a different destination when the request itself asks you to (e.g., "tell Laura that…").',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { findByName, type DestinationEntry } from './destinations.js';
|
import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js';
|
||||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||||
import { writeMessageOut } from './db/messages-out.js';
|
import { writeMessageOut } from './db/messages-out.js';
|
||||||
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||||
@@ -265,6 +265,7 @@ async function processQuery(
|
|||||||
): Promise<QueryResult> {
|
): Promise<QueryResult> {
|
||||||
let queryContinuation: string | undefined;
|
let queryContinuation: string | undefined;
|
||||||
let done = false;
|
let done = false;
|
||||||
|
let unwrappedNudged = false;
|
||||||
|
|
||||||
// Concurrent polling: push follow-ups into the active query as they arrive.
|
// Concurrent polling: push follow-ups into the active query as they arrive.
|
||||||
// We do NOT force-end the stream on silence — keeping the query open avoids
|
// We do NOT force-end the stream on silence — keeping the query open avoids
|
||||||
@@ -338,6 +339,7 @@ async function processQuery(
|
|||||||
const keptIds = keep.map((m) => m.id);
|
const keptIds = keep.map((m) => m.id);
|
||||||
const prompt = formatMessages(keep);
|
const prompt = formatMessages(keep);
|
||||||
log(`Pushing ${keep.length} follow-up message(s) into active query`);
|
log(`Pushing ${keep.length} follow-up message(s) into active query`);
|
||||||
|
unwrappedNudged = false;
|
||||||
query.push(prompt);
|
query.push(prompt);
|
||||||
markCompleted(keptIds);
|
markCompleted(keptIds);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -376,7 +378,18 @@ async function processQuery(
|
|||||||
// at all — either way the turn is finished.
|
// at all — either way the turn is finished.
|
||||||
markCompleted(initialBatchIds);
|
markCompleted(initialBatchIds);
|
||||||
if (event.text) {
|
if (event.text) {
|
||||||
dispatchResultText(event.text, routing);
|
const { hasUnwrapped } = dispatchResultText(event.text, routing);
|
||||||
|
if (hasUnwrapped && !unwrappedNudged) {
|
||||||
|
unwrappedNudged = true;
|
||||||
|
const destinations = getAllDestinations();
|
||||||
|
const names = destinations.map((d) => d.name).join(', ');
|
||||||
|
query.push(
|
||||||
|
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
|
||||||
|
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
|
||||||
|
`Your destinations: ${names}. ` +
|
||||||
|
`Please re-send your response with the correct wrapping.</system>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,7 +428,7 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
|||||||
* The agent must always wrap output in <message to="name">...</message>
|
* The agent must always wrap output in <message to="name">...</message>
|
||||||
* blocks, even with a single destination. Bare text is scratchpad only.
|
* blocks, even with a single destination. Bare text is scratchpad only.
|
||||||
*/
|
*/
|
||||||
function dispatchResultText(text: string, routing: RoutingContext): void {
|
function dispatchResultText(text: string, routing: RoutingContext): { sent: number; hasUnwrapped: boolean } {
|
||||||
const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;
|
const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;
|
||||||
|
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
@@ -450,9 +463,11 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
|
|||||||
log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
|
log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sent === 0 && text.trim()) {
|
const hasUnwrapped = sent === 0 && !!scratchpad;
|
||||||
|
if (hasUnwrapped) {
|
||||||
log(`WARNING: agent output had no <message to="..."> blocks — nothing was sent`);
|
log(`WARNING: agent output had no <message to="..."> blocks — nothing was sent`);
|
||||||
}
|
}
|
||||||
|
return { sent, hasUnwrapped };
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
|
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user