mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
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');
|
||||
|
||||
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('`casa`');
|
||||
});
|
||||
@@ -55,7 +55,7 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', ()
|
||||
|
||||
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('Default routing');
|
||||
expect(prompt).toContain('`casa`');
|
||||
|
||||
@@ -115,10 +115,9 @@ function buildDestinationsSection(): string {
|
||||
}
|
||||
}
|
||||
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('Text outside of `<message>` blocks is scratchpad — logged but not sent anywhere.');
|
||||
lines.push('Use `<internal>...</internal>` to make scratchpad intent explicit.');
|
||||
lines.push('Bare text (outside of `<message>` or `<internal>` blocks) is not allowed and will not be delivered.');
|
||||
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…").',
|
||||
|
||||
@@ -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 { writeMessageOut } from './db/messages-out.js';
|
||||
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
@@ -265,6 +265,7 @@ async function processQuery(
|
||||
): Promise<QueryResult> {
|
||||
let queryContinuation: string | undefined;
|
||||
let done = false;
|
||||
let unwrappedNudged = false;
|
||||
|
||||
// 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
|
||||
@@ -338,6 +339,7 @@ async function processQuery(
|
||||
const keptIds = keep.map((m) => m.id);
|
||||
const prompt = formatMessages(keep);
|
||||
log(`Pushing ${keep.length} follow-up message(s) into active query`);
|
||||
unwrappedNudged = false;
|
||||
query.push(prompt);
|
||||
markCompleted(keptIds);
|
||||
} catch (err) {
|
||||
@@ -376,7 +378,18 @@ async function processQuery(
|
||||
// at all — either way the turn is finished.
|
||||
markCompleted(initialBatchIds);
|
||||
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>
|
||||
* 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;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
@@ -450,9 +463,11 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
|
||||
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`);
|
||||
}
|
||||
return { sent, hasUnwrapped };
|
||||
}
|
||||
|
||||
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
|
||||
|
||||
Reference in New Issue
Block a user