Skip to content

Commit 4e6933c

Browse files
committed
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).
1 parent 0bf66ab commit 4e6933c

5 files changed

Lines changed: 144 additions & 8 deletions

File tree

src/agents/system-prompt.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,7 +1104,7 @@ describe("buildAgentSystemPrompt", () => {
11041104
);
11051105
expect(prompt).not.toContain("Attach media: `MEDIA:<path-or-url>`");
11061106
expect(prompt).toContain(
1107-
"Group/channel etiquette: message-tool-only delivery does not require visible output",
1107+
"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.",
11081108
);
11091109
expect(prompt).toContain("The target defaults to the current source channel");
11101110
expect(prompt).toContain("do not repeat that visible content in your final answer");
@@ -1131,7 +1131,7 @@ describe("buildAgentSystemPrompt", () => {
11311131

11321132
expect(prompt).toContain("include `target` and `message`; `target` is required for this turn");
11331133
expect(prompt).toContain(
1134-
"Group/channel etiquette: message-tool-only delivery does not require visible output",
1134+
"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.",
11351135
);
11361136
expect(prompt).not.toContain("The target defaults to the current source channel");
11371137
});

src/agents/system-prompt.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,7 @@ function buildMessagingSection(params: {
538538
"### message tool",
539539
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
540540
groupMessageToolOnly
541-
? "- 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."
541+
? "- 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."
542542
: "",
543543
messageToolOnly
544544
? params.requireExplicitMessageTarget

src/auto-reply/reply/get-reply-run.media-only.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "../../agents/embedded-agent-runner/runs.js";
1111
import type { SessionEntry } from "../../config/sessions.js";
1212
import { HEARTBEAT_RUN_SCOPE } from "../../infra/heartbeat-run-scope.js";
13+
import { MESSAGE_TOOL_ONLY_DELIVERY_HINT } from "../../plugin-sdk/message-tool-delivery-hints.js";
1314
import { createReplyOperation } from "./reply-run-registry.js";
1415

1516
vi.mock("../../agents/auth-profiles/session-override.js", () => ({
@@ -514,6 +515,56 @@ describe("runPreparedReply media-only handling", () => {
514515
);
515516
});
516517

518+
it("keeps addressed message-tool delivery hints out of persisted transcript rows", async () => {
519+
vi.mocked(buildInboundUserContextPrefix).mockReturnValueOnce(
520+
"Current message:\nchat_id=-100123\ninbound_event_kind: user_request",
521+
);
522+
523+
await runPreparedReply(
524+
baseParams({
525+
opts: { sourceReplyDeliveryMode: "message_tool_only" },
526+
ctx: {
527+
Body: "@bot please answer here",
528+
RawBody: "@bot please answer here",
529+
CommandBody: "please answer here",
530+
OriginatingChannel: "telegram",
531+
OriginatingTo: "-100123",
532+
ChatType: "group",
533+
},
534+
sessionCtx: {
535+
Body: "@bot please answer here",
536+
BodyStripped: "please answer here",
537+
Provider: "telegram",
538+
OriginatingChannel: "telegram",
539+
OriginatingTo: "-100123",
540+
ChatType: "group",
541+
InboundEventKind: "user_request",
542+
},
543+
}),
544+
);
545+
546+
const call = requireLastRunReplyAgentCall();
547+
expect(call.commandBody).toBe("please answer here");
548+
expect(call.transcriptCommandBody).toBe("please answer here");
549+
expect(call.followupRun.prompt).toBe("please answer here");
550+
expect(call.followupRun.transcriptPrompt).toBe("please answer here");
551+
expect(call.followupRun.currentInboundContext?.text).toBe(
552+
[
553+
"Current message:\nchat_id=-100123\ninbound_event_kind: user_request",
554+
MESSAGE_TOOL_ONLY_DELIVERY_HINT,
555+
].join("\n\n"),
556+
);
557+
const persistedUserMessage = call.followupRun.userTurnTranscriptRecorder?.message;
558+
if (!persistedUserMessage) {
559+
throw new Error("persisted user turn message missing");
560+
}
561+
expect(persistedUserMessage).toMatchObject({
562+
role: "user",
563+
content: "please answer here",
564+
});
565+
expect(persistedUserMessage.content).not.toContain(MESSAGE_TOOL_ONLY_DELIVERY_HINT);
566+
});
567+
517568
it.each(["direct", "dm"] as const)(
518569
"does not propagate empty-assistant silence for %s runs",
519570
async (chatType) => {

src/auto-reply/reply/prompt-prelude.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
// Tests prompt prelude construction for sender, routing, and context metadata.
22
import { describe, expect, it } from "vitest";
3+
import { MESSAGE_TOOL_ONLY_DELIVERY_HINT } from "../../plugin-sdk/message-tool-delivery-hints.js";
34
import { finalizeInboundContext } from "./inbound-context.js";
45
import { buildReplyPromptEnvelope } from "./prompt-prelude.js";
56

7+
function countOccurrences(text: string | undefined, needle: string): number {
8+
return (text?.split(needle).length ?? 1) - 1;
9+
}
10+
611
describe("buildReplyPromptEnvelope", () => {
712
it("keeps bare reset runtime context in the model prompt and out of transcript/current-turn context", () => {
813
const sessionCtx = finalizeInboundContext({
@@ -58,6 +63,64 @@ describe("buildReplyPromptEnvelope", () => {
5863
});
5964
});
6065

66+
it("adds one message-tool delivery hint to user-request runtime context only", () => {
67+
const sessionCtx = finalizeInboundContext({
68+
Body: "@bot what changed?",
69+
BodyStripped: "what changed?",
70+
Provider: "telegram",
71+
ChatType: "group",
72+
InboundEventKind: "user_request",
73+
});
74+
75+
const envelope = buildReplyPromptEnvelope({
76+
ctx: sessionCtx,
77+
sessionCtx,
78+
baseBody: "what changed?",
79+
prefixedBody: "what changed?",
80+
hasUserBody: true,
81+
inboundUserContext: "Current message:\nchat_id=-100123",
82+
isBareSessionReset: false,
83+
startupAction: "new",
84+
inboundEventKind: "user_request",
85+
sourceReplyDeliveryMode: "message_tool_only",
86+
});
87+
88+
expect(
89+
countOccurrences(envelope.currentInboundContext?.text, MESSAGE_TOOL_ONLY_DELIVERY_HINT),
90+
).toBe(1);
91+
expect(envelope.prefixedCommandBody).toBe("what changed?");
92+
expect(envelope.transcriptCommandBody).toBe("what changed?");
93+
expect(envelope.transcriptCommandBody).not.toContain(MESSAGE_TOOL_ONLY_DELIVERY_HINT);
94+
});
95+
96+
it.each([undefined, "automatic"] as const)(
97+
"omits user-request delivery hints for %s delivery",
98+
(sourceReplyDeliveryMode) => {
99+
const sessionCtx = finalizeInboundContext({
100+
Body: "@bot what changed?",
101+
BodyStripped: "what changed?",
102+
Provider: "telegram",
103+
ChatType: "group",
104+
InboundEventKind: "user_request",
105+
});
106+
107+
const envelope = buildReplyPromptEnvelope({
108+
ctx: sessionCtx,
109+
sessionCtx,
110+
baseBody: "what changed?",
111+
prefixedBody: "what changed?",
112+
hasUserBody: true,
113+
inboundUserContext: "Current message:\nchat_id=-100123",
114+
isBareSessionReset: false,
115+
startupAction: "new",
116+
inboundEventKind: "user_request",
117+
sourceReplyDeliveryMode,
118+
});
119+
120+
expect(envelope.currentInboundContext?.text).not.toContain(MESSAGE_TOOL_ONLY_DELIVERY_HINT);
121+
},
122+
);
123+
61124
it("projects room events as context instead of user requests", () => {
62125
const sessionCtx = finalizeInboundContext({
63126
Body: "No wtf",

src/auto-reply/reply/prompt-prelude.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
33
import type { CurrentInboundPromptContext } from "../../agents/embedded-agent-runner/run/params.js";
44
import type { InboundEventKind } from "../../channels/inbound-event/kind.js";
5+
import { MESSAGE_TOOL_ONLY_DELIVERY_HINT } from "../../plugin-sdk/message-tool-delivery-hints.js";
56
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
67
import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js";
78
import { HEARTBEAT_TRANSCRIPT_PROMPT } from "../heartbeat.js";
@@ -147,13 +148,28 @@ function resolveRoomEventTranscriptBody(params: ReplyPromptEnvelopeBaseParams):
147148
);
148149
}
149150

151+
function resolvePerTurnDeliveryDirective(params: {
152+
inboundEventKind?: InboundEventKind;
153+
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
154+
}): string | undefined {
155+
if (params.inboundEventKind === "room_event") {
156+
return params.sourceReplyDeliveryMode === "message_tool_only"
157+
? "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."
158+
: "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.";
159+
}
160+
if (
161+
params.inboundEventKind === "user_request" &&
162+
params.sourceReplyDeliveryMode === "message_tool_only"
163+
) {
164+
return MESSAGE_TOOL_ONLY_DELIVERY_HINT;
165+
}
166+
return undefined;
167+
}
168+
150169
function buildRoomEventContext(params: ReplyPromptEnvelopeBaseParams, roomContext: string): string {
151170
const roomEventBody = resolveRoomEventTranscriptBody(params);
152171
const roomContextBlock = roomContext.trim() ? `Room context:\n${roomContext.trim()}` : "";
153-
const deliveryDirective =
154-
params.sourceReplyDeliveryMode === "message_tool_only"
155-
? "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."
156-
: "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.";
172+
const deliveryDirective = resolvePerTurnDeliveryDirective(params);
157173
return [
158174
"[OpenClaw room event]",
159175
"inbound_event_kind: room_event",
@@ -186,7 +202,13 @@ export function buildReplyPromptEnvelopeBase(
186202
const resumableRoomEventContext = isRoomEvent
187203
? buildRoomEventContext(params, buildResumableRoomContext(inboundUserContext))
188204
: undefined;
189-
const currentInboundContextText = isRoomEvent ? roomEventContext : inboundUserContext;
205+
const userRequestDeliveryDirective = resolvePerTurnDeliveryDirective({
206+
inboundEventKind: params.inboundEventKind,
207+
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
208+
});
209+
const currentInboundContextText = isRoomEvent
210+
? roomEventContext
211+
: [inboundUserContext, userRequestDeliveryDirective].filter(Boolean).join("\n\n");
190212
const resetModelBody = params.isBareSessionReset
191213
? [
192214
params.inboundUserContext,

0 commit comments

Comments
 (0)