Skip to content

Commit bc5a7d9

Browse files
committed
fix(auto-reply): sanitize stranded-reply retry prompt text (#85714)
The stranded-reply recovery retry embedded the raw private final text into a synthetic prompt asking the agent to redeliver via message(action=send). The message tool sanitizes runtime/inbound metadata but not reply directives, so directive tokens ([[reply_to:...]], [[audio_as_voice]], MEDIA: lines) in the final could survive the retry and be re-sent unsanitized, breaking the message_tool_only privacy/directive boundary. Run the same normalization the normal final-delivery path applies (normalizeReplyPayloadDirectives) on the final text before quoting it in the retry prompt, so the retry replays only the normalized visible text. Add regression coverage: a directive-laden final has its directives stripped from the retry prompt while the visible message survives and the recovery guard flags persist; a directive-free final is quoted unchanged.
1 parent 746101a commit bc5a7d9

2 files changed

Lines changed: 52 additions & 1 deletion

File tree

src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3579,6 +3579,45 @@ describe("runReplyAgent private message_tool_only final warning (#85714)", () =>
35793579
expect(retryPrompt).toContain("Here is the answer");
35803580
});
35813581

3582+
it("strips reply directives from the quoted final before replaying it (#85714)", async () => {
3583+
// A stranded final can embed reply directives ([[reply_to:...]], [[audio_as_voice]],
3584+
// MEDIA: lines). The message tool sanitizes runtime/inbound metadata but NOT reply
3585+
// directives, so quoting the raw final would let those tokens survive the retry and
3586+
// be re-sent unsanitized via message(action=send). The retry prompt must quote the
3587+
// normalized visible text, matching the normal final-delivery normalization.
3588+
await runPrivateFinalCase({
3589+
strandedReplyRecovery: true,
3590+
finalAssistantText:
3591+
"[[reply_to:123]] [[audio_as_voice]] Hello there — here is the substantive answer " +
3592+
"the user asked about. It is long enough to read like a real user-facing reply " +
3593+
"rather than a short private note, so recovery should trigger.\nMEDIA: /tmp/x.png",
3594+
});
3595+
expect(vi.mocked(enqueueFollowupRun)).toHaveBeenCalledTimes(1);
3596+
const retryRun = vi.mocked(enqueueFollowupRun).mock.calls[0]?.[1];
3597+
const retryPrompt = retryRun?.prompt ?? "";
3598+
// The visible message survives; the directive tokens do not.
3599+
expect(retryPrompt).toContain("Hello there");
3600+
expect(retryPrompt).not.toContain("[[reply_to:");
3601+
expect(retryPrompt).not.toContain("[[audio_as_voice]]");
3602+
expect(retryPrompt).not.toContain("MEDIA:");
3603+
expect(retryPrompt).not.toContain("/tmp/x.png");
3604+
// The retry still enqueues with the recovery guard and individual drain.
3605+
expect(retryRun?.strandedReplyRetry).toBe(true);
3606+
expect(retryRun?.disableCollectBatching).toBe(true);
3607+
});
3608+
3609+
it("quotes a plain directive-free final unchanged (#85714)", async () => {
3610+
// No directives means nothing to strip: the normalization must leave the
3611+
// quoted final byte-for-byte so the agent redelivers the intended reply.
3612+
const plainFinal =
3613+
"Here is the substantive answer the user asked about. It is long enough to read like " +
3614+
"a real user-facing reply rather than a short private note, so recovery should trigger.";
3615+
await runPrivateFinalCase({ strandedReplyRecovery: true, finalAssistantText: plainFinal });
3616+
expect(vi.mocked(enqueueFollowupRun)).toHaveBeenCalledTimes(1);
3617+
const retryPrompt = vi.mocked(enqueueFollowupRun).mock.calls[0]?.[1]?.prompt ?? "";
3618+
expect(retryPrompt).toContain(`"${plainFinal}"`);
3619+
});
3620+
35823621
it("does not enqueue retry for short private final replies", async () => {
35833622
await runPrivateFinalCase({
35843623
strandedReplyRecovery: true,

src/auto-reply/reply/agent-runner.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ import {
120120
type FollowupRun,
121121
type QueueSettings,
122122
} from "./queue.js";
123+
import { normalizeReplyPayloadDirectives } from "./reply-delivery.js";
123124
import { createReplyMediaContext } from "./reply-media-paths.js";
124125
import { resolveReplyOperationRunState } from "./reply-operation-run-state.js";
125126
import {
@@ -2614,12 +2615,23 @@ export async function runReplyAgent(params: {
26142615
// still fires unconditionally; only the retry enqueue is gated.
26152616
const strandedReplyRecoveryEnabled = cfg.messages?.strandedReplyRecovery === true;
26162617
if (!isRetryRun && strandedReplyRecoveryEnabled) {
2618+
// #85714: Strip reply directives before replaying the private final into
2619+
// the model. The message tool sanitizes runtime/inbound metadata but NOT
2620+
// reply directives, so quoting the raw final would let directive tokens
2621+
// ([[reply_to:...]], [[audio_as_voice]], MEDIA:, reactions) survive the
2622+
// retry and be re-sent unsanitized via message(action=send). Run the same
2623+
// normalization the normal final-delivery path applies (buildReplyPayloads).
2624+
const strandedReplyVisibleText =
2625+
normalizeReplyPayloadDirectives({
2626+
payload: { text: assistantFinalText },
2627+
parseMode: "always",
2628+
}).payload.text ?? "";
26172629
// #85714: Enqueue a retry turn so the agent can deliver via message(action=send),
26182630
// preserving the message_tool_only privacy contract instead of bypassing suppression.
26192631
const retryPrompt =
26202632
`[System] Your previous reply was not delivered to the conversation because ` +
26212633
`you did not call message(action=send). Your reply text was:\n\n` +
2622-
`"${assistantFinalText}"\n\n` +
2634+
`"${strandedReplyVisibleText}"\n\n` +
26232635
`Please deliver this reply now by calling message(action=send). ` +
26242636
`Do not add any extra commentary — just deliver the original reply.`;
26252637
const retryEnqueued = enqueueFollowupRun(

0 commit comments

Comments
 (0)