mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-05 04:13:33 +00:00
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:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user