fix(auto-reply): restore per-turn message-tool delivery contract

Addressed source-reply turns now carry the message-tool-only delivery hint in runtime prompt context, matching the room-event per-turn contract without persisting the hint into transcript rows.

Surface: auto-reply prompt envelopes and agent messaging guidance. Fixes #99371.

Release-note: fixes Telegram group replies under message_tool_only delivery being composed privately instead of being prompted to use message(action=send).
This commit is contained in:
Ayaan Zaidi
2026-07-02 23:04:59 -07:00
parent 0bf66ab7bd
commit 4e6933c84f
5 changed files with 144 additions and 8 deletions

View File

@@ -1104,7 +1104,7 @@ describe("buildAgentSystemPrompt", () => {
);
expect(prompt).not.toContain("Attach media: `MEDIA:<path-or-url>`");
expect(prompt).toContain(
"Group/channel etiquette: message-tool-only delivery does not require visible output",
"Group/channel etiquette: for stale threads, jokes, lightweight acknowledgements, or low-value chatter, prefer a reaction when available or no channel message; when a visible reply is warranted, use `message(action=send)` because final text stays private.",
);
expect(prompt).toContain("The target defaults to the current source channel");
expect(prompt).toContain("do not repeat that visible content in your final answer");
@@ -1131,7 +1131,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("include `target` and `message`; `target` is required for this turn");
expect(prompt).toContain(
"Group/channel etiquette: message-tool-only delivery does not require visible output",
"Group/channel etiquette: for stale threads, jokes, lightweight acknowledgements, or low-value chatter, prefer a reaction when available or no channel message; when a visible reply is warranted, use `message(action=send)` because final text stays private.",
);
expect(prompt).not.toContain("The target defaults to the current source channel");
});

View File

@@ -538,7 +538,7 @@ function buildMessagingSection(params: {
"### message tool",
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
groupMessageToolOnly
? "- Group/channel etiquette: message-tool-only delivery does not require visible output. For stale threads, jokes, lightweight acknowledgements, or low-value chatter, prefer a reaction when available or no channel message; post only when you have concrete value to add."
? "- Group/channel etiquette: for stale threads, jokes, lightweight acknowledgements, or low-value chatter, prefer a reaction when available or no channel message; when a visible reply is warranted, use `message(action=send)` because final text stays private."
: "",
messageToolOnly
? params.requireExplicitMessageTarget

View File

@@ -10,6 +10,7 @@ import {
} from "../../agents/embedded-agent-runner/runs.js";
import type { SessionEntry } from "../../config/sessions.js";
import { HEARTBEAT_RUN_SCOPE } from "../../infra/heartbeat-run-scope.js";
import { MESSAGE_TOOL_ONLY_DELIVERY_HINT } from "../../plugin-sdk/message-tool-delivery-hints.js";
import { createReplyOperation } from "./reply-run-registry.js";
vi.mock("../../agents/auth-profiles/session-override.js", () => ({
@@ -514,6 +515,56 @@ describe("runPreparedReply media-only handling", () => {
);
});
it("keeps addressed message-tool delivery hints out of persisted transcript rows", async () => {
vi.mocked(buildInboundUserContextPrefix).mockReturnValueOnce(
"Current message:\nchat_id=-100123\ninbound_event_kind: user_request",
);
await runPreparedReply(
baseParams({
opts: { sourceReplyDeliveryMode: "message_tool_only" },
ctx: {
Body: "@bot please answer here",
RawBody: "@bot please answer here",
CommandBody: "please answer here",
OriginatingChannel: "telegram",
OriginatingTo: "-100123",
ChatType: "group",
},
sessionCtx: {
Body: "@bot please answer here",
BodyStripped: "please answer here",
Provider: "telegram",
OriginatingChannel: "telegram",
OriginatingTo: "-100123",
ChatType: "group",
InboundEventKind: "user_request",
},
}),
);
const call = requireLastRunReplyAgentCall();
expect(call.commandBody).toBe("please answer here");
expect(call.transcriptCommandBody).toBe("please answer here");
expect(call.followupRun.prompt).toBe("please answer here");
expect(call.followupRun.transcriptPrompt).toBe("please answer here");
expect(call.followupRun.currentInboundContext?.text).toBe(
[
"Current message:\nchat_id=-100123\ninbound_event_kind: user_request",
MESSAGE_TOOL_ONLY_DELIVERY_HINT,
].join("\n\n"),
);
const persistedUserMessage = call.followupRun.userTurnTranscriptRecorder?.message;
if (!persistedUserMessage) {
throw new Error("persisted user turn message missing");
}
expect(persistedUserMessage).toMatchObject({
role: "user",
content: "please answer here",
});
expect(persistedUserMessage.content).not.toContain(MESSAGE_TOOL_ONLY_DELIVERY_HINT);
});
it.each(["direct", "dm"] as const)(
"does not propagate empty-assistant silence for %s runs",
async (chatType) => {

View File

@@ -1,8 +1,13 @@
// Tests prompt prelude construction for sender, routing, and context metadata.
import { describe, expect, it } from "vitest";
import { MESSAGE_TOOL_ONLY_DELIVERY_HINT } from "../../plugin-sdk/message-tool-delivery-hints.js";
import { finalizeInboundContext } from "./inbound-context.js";
import { buildReplyPromptEnvelope } from "./prompt-prelude.js";
function countOccurrences(text: string | undefined, needle: string): number {
return (text?.split(needle).length ?? 1) - 1;
}
describe("buildReplyPromptEnvelope", () => {
it("keeps bare reset runtime context in the model prompt and out of transcript/current-turn context", () => {
const sessionCtx = finalizeInboundContext({
@@ -58,6 +63,64 @@ describe("buildReplyPromptEnvelope", () => {
});
});
it("adds one message-tool delivery hint to user-request runtime context only", () => {
const sessionCtx = finalizeInboundContext({
Body: "@bot what changed?",
BodyStripped: "what changed?",
Provider: "telegram",
ChatType: "group",
InboundEventKind: "user_request",
});
const envelope = buildReplyPromptEnvelope({
ctx: sessionCtx,
sessionCtx,
baseBody: "what changed?",
prefixedBody: "what changed?",
hasUserBody: true,
inboundUserContext: "Current message:\nchat_id=-100123",
isBareSessionReset: false,
startupAction: "new",
inboundEventKind: "user_request",
sourceReplyDeliveryMode: "message_tool_only",
});
expect(
countOccurrences(envelope.currentInboundContext?.text, MESSAGE_TOOL_ONLY_DELIVERY_HINT),
).toBe(1);
expect(envelope.prefixedCommandBody).toBe("what changed?");
expect(envelope.transcriptCommandBody).toBe("what changed?");
expect(envelope.transcriptCommandBody).not.toContain(MESSAGE_TOOL_ONLY_DELIVERY_HINT);
});
it.each([undefined, "automatic"] as const)(
"omits user-request delivery hints for %s delivery",
(sourceReplyDeliveryMode) => {
const sessionCtx = finalizeInboundContext({
Body: "@bot what changed?",
BodyStripped: "what changed?",
Provider: "telegram",
ChatType: "group",
InboundEventKind: "user_request",
});
const envelope = buildReplyPromptEnvelope({
ctx: sessionCtx,
sessionCtx,
baseBody: "what changed?",
prefixedBody: "what changed?",
hasUserBody: true,
inboundUserContext: "Current message:\nchat_id=-100123",
isBareSessionReset: false,
startupAction: "new",
inboundEventKind: "user_request",
sourceReplyDeliveryMode,
});
expect(envelope.currentInboundContext?.text).not.toContain(MESSAGE_TOOL_ONLY_DELIVERY_HINT);
},
);
it("projects room events as context instead of user requests", () => {
const sessionCtx = finalizeInboundContext({
Body: "No wtf",

View File

@@ -2,6 +2,7 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { CurrentInboundPromptContext } from "../../agents/embedded-agent-runner/run/params.js";
import type { InboundEventKind } from "../../channels/inbound-event/kind.js";
import { MESSAGE_TOOL_ONLY_DELIVERY_HINT } from "../../plugin-sdk/message-tool-delivery-hints.js";
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js";
import { HEARTBEAT_TRANSCRIPT_PROMPT } from "../heartbeat.js";
@@ -147,13 +148,28 @@ function resolveRoomEventTranscriptBody(params: ReplyPromptEnvelopeBaseParams):
);
}
function resolvePerTurnDeliveryDirective(params: {
inboundEventKind?: InboundEventKind;
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
}): string | undefined {
if (params.inboundEventKind === "room_event") {
return params.sourceReplyDeliveryMode === "message_tool_only"
? "Treat this as observed room activity. Default: no reply; most room events need no response from you. Send a visible reply via message(action=send) only when you are directly addressed or have concrete value to add; your final text here stays private either way."
: "Treat this as observed room activity. Default: no reply; most room events need no response from you. Reply only when you are directly addressed or have concrete value to add.";
}
if (
params.inboundEventKind === "user_request" &&
params.sourceReplyDeliveryMode === "message_tool_only"
) {
return MESSAGE_TOOL_ONLY_DELIVERY_HINT;
}
return undefined;
}
function buildRoomEventContext(params: ReplyPromptEnvelopeBaseParams, roomContext: string): string {
const roomEventBody = resolveRoomEventTranscriptBody(params);
const roomContextBlock = roomContext.trim() ? `Room context:\n${roomContext.trim()}` : "";
const deliveryDirective =
params.sourceReplyDeliveryMode === "message_tool_only"
? "Treat this as observed room activity. Default: no reply; most room events need no response from you. Send a visible reply via message(action=send) only when you are directly addressed or have concrete value to add; your final text here stays private either way."
: "Treat this as observed room activity. Default: no reply; most room events need no response from you. Reply only when you are directly addressed or have concrete value to add.";
const deliveryDirective = resolvePerTurnDeliveryDirective(params);
return [
"[OpenClaw room event]",
"inbound_event_kind: room_event",
@@ -186,7 +202,13 @@ export function buildReplyPromptEnvelopeBase(
const resumableRoomEventContext = isRoomEvent
? buildRoomEventContext(params, buildResumableRoomContext(inboundUserContext))
: undefined;
const currentInboundContextText = isRoomEvent ? roomEventContext : inboundUserContext;
const userRequestDeliveryDirective = resolvePerTurnDeliveryDirective({
inboundEventKind: params.inboundEventKind,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
});
const currentInboundContextText = isRoomEvent
? roomEventContext
: [inboundUserContext, userRequestDeliveryDirective].filter(Boolean).join("\n\n");
const resetModelBody = params.isBareSessionReset
? [
params.inboundUserContext,