diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 818d11a2dfe..2d6194ef290 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -1104,7 +1104,7 @@ describe("buildAgentSystemPrompt", () => { ); expect(prompt).not.toContain("Attach media: `MEDIA:`"); 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"); }); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index faa2bc03648..95d92faaf4c 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -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 diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 27f80889363..5659b8d0e4a 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -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) => { diff --git a/src/auto-reply/reply/prompt-prelude.test.ts b/src/auto-reply/reply/prompt-prelude.test.ts index ee69ec8987a..cf7c0c81e67 100644 --- a/src/auto-reply/reply/prompt-prelude.test.ts +++ b/src/auto-reply/reply/prompt-prelude.test.ts @@ -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", diff --git a/src/auto-reply/reply/prompt-prelude.ts b/src/auto-reply/reply/prompt-prelude.ts index cc74f7bc839..c05c223dbd9 100644 --- a/src/auto-reply/reply/prompt-prelude.ts +++ b/src/auto-reply/reply/prompt-prelude.ts @@ -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,