diff --git a/CHANGELOG.md b/CHANGELOG.md index 982200a0af..4edeaa0ab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai - Gateway/Daemon: forward `TMPDIR` into installed service environments so macOS LaunchAgent gateway runs can open SQLite temp/journal files reliably instead of failing with `SQLITE_CANTOPEN`. (#20512) Thanks @Clawborn. - Agents/Billing: include the active model that produced a billing error in user-facing billing messages (for example, `OpenAI (gpt-5.3)`) across payload, failover, and lifecycle error paths, so users can identify exactly which key needs credits. (#20510) Thanks @echoVic. - Gateway/TUI: honor `agents.defaults.blockStreamingDefault` for `chat.send` by removing the hardcoded block-streaming disable override, so replies can use configured block-mode delivery. (#19693) Thanks @neipor. +- Auto-reply/Prompt caching: restore prefix-cache stability by keeping inbound system metadata session-stable and moving per-message IDs (`message_id`, `message_id_full`, `reply_to_id`, `sender_id`) into untrusted conversation context. (#20597) Thanks @anisoptera. - UI/Sessions: accept the canonical main session-key alias in Chat UI flows so main-session routing stays consistent. (#20311) Thanks @mbelinky. - OpenClawKit/Protocol: preserve JSON boolean literals (`true`/`false`) when bridging through `AnyCodable` so Apple client RPC params no longer re-encode booleans as `1`/`0`. Thanks @mbelinky. - Commands/Doctor: skip embedding-provider warnings when `memory.backend` is `qmd`, because QMD manages embeddings internally and does not require `memorySearch` providers. (#17263) Thanks @miloudbelarebia. diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts index 1f463834a4..915c4800e6 100644 --- a/src/auto-reply/reply/inbound-meta.test.ts +++ b/src/auto-reply/reply/inbound-meta.test.ts @@ -19,7 +19,7 @@ function parseConversationInfoPayload(text: string): Record { } describe("buildInboundMetaSystemPrompt", () => { - it("includes trusted message and routing ids for tool actions", () => { + it("includes session-stable routing fields", () => { const prompt = buildInboundMetaSystemPrompt({ MessageSid: "123", MessageSidFull: "123", @@ -33,41 +33,28 @@ describe("buildInboundMetaSystemPrompt", () => { const payload = parseInboundMetaPayload(prompt); expect(payload["schema"]).toBe("openclaw.inbound_meta.v1"); - expect(payload["message_id"]).toBe("123"); - expect(payload["message_id_full"]).toBeUndefined(); - expect(payload["reply_to_id"]).toBe("99"); expect(payload["chat_id"]).toBe("telegram:5494292670"); expect(payload["channel"]).toBe("telegram"); }); - it("includes sender_id when provided", () => { + it("does not include per-turn message identifiers (cache stability)", () => { const prompt = buildInboundMetaSystemPrompt({ - MessageSid: "456", + MessageSid: "123", + MessageSidFull: "123", + ReplyToId: "99", SenderId: "289522496", - OriginatingTo: "telegram:-1001249586642", + OriginatingTo: "telegram:5494292670", OriginatingChannel: "telegram", Provider: "telegram", Surface: "telegram", - ChatType: "group", + ChatType: "direct", } as TemplateContext); const payload = parseInboundMetaPayload(prompt); - expect(payload["sender_id"]).toBe("289522496"); - }); - - it("trims sender_id before storing", () => { - const prompt = buildInboundMetaSystemPrompt({ - MessageSid: "457", - SenderId: " 289522496 ", - OriginatingTo: "telegram:-1001249586642", - OriginatingChannel: "telegram", - Provider: "telegram", - Surface: "telegram", - ChatType: "group", - } as TemplateContext); - - const payload = parseInboundMetaPayload(prompt); - expect(payload["sender_id"]).toBe("289522496"); + expect(payload["message_id"]).toBeUndefined(); + expect(payload["message_id_full"]).toBeUndefined(); + expect(payload["reply_to_id"]).toBeUndefined(); + expect(payload["sender_id"]).toBeUndefined(); }); it("omits sender_id when blank", () => { @@ -84,36 +71,6 @@ describe("buildInboundMetaSystemPrompt", () => { const payload = parseInboundMetaPayload(prompt); expect(payload["sender_id"]).toBeUndefined(); }); - - it("omits sender_id when not provided", () => { - const prompt = buildInboundMetaSystemPrompt({ - MessageSid: "789", - OriginatingTo: "telegram:5494292670", - OriginatingChannel: "telegram", - Provider: "telegram", - Surface: "telegram", - ChatType: "direct", - } as TemplateContext); - - const payload = parseInboundMetaPayload(prompt); - expect(payload["sender_id"]).toBeUndefined(); - }); - - it("keeps message_id_full only when it differs from message_id", () => { - const prompt = buildInboundMetaSystemPrompt({ - MessageSid: "short-id", - MessageSidFull: "full-provider-message-id", - OriginatingTo: "channel:C1", - OriginatingChannel: "slack", - Provider: "slack", - Surface: "slack", - ChatType: "group", - } as TemplateContext); - - const payload = parseInboundMetaPayload(prompt); - expect(payload["message_id"]).toBe("short-id"); - expect(payload["message_id_full"]).toBe("full-provider-message-id"); - }); }); describe("buildInboundUserContextPrefix", () => { @@ -156,6 +113,63 @@ describe("buildInboundUserContextPrefix", () => { expect(conversationInfo["message_id"]).toBe("msg-123"); }); + it("includes message_id_full when it differs from message_id", () => { + const text = buildInboundUserContextPrefix({ + ChatType: "group", + MessageSid: "short-id", + MessageSidFull: "full-provider-message-id", + } as TemplateContext); + + const conversationInfo = parseConversationInfoPayload(text); + expect(conversationInfo["message_id"]).toBe("short-id"); + expect(conversationInfo["message_id_full"]).toBe("full-provider-message-id"); + }); + + it("omits message_id_full when it matches message_id", () => { + const text = buildInboundUserContextPrefix({ + ChatType: "direct", + MessageSid: "same-id", + MessageSidFull: "same-id", + } as TemplateContext); + + const conversationInfo = parseConversationInfoPayload(text); + expect(conversationInfo["message_id"]).toBe("same-id"); + expect(conversationInfo["message_id_full"]).toBeUndefined(); + }); + + it("includes reply_to_id in conversation info", () => { + const text = buildInboundUserContextPrefix({ + ChatType: "direct", + MessageSid: "msg-200", + ReplyToId: "msg-199", + } as TemplateContext); + + const conversationInfo = parseConversationInfoPayload(text); + expect(conversationInfo["reply_to_id"]).toBe("msg-199"); + }); + + it("includes sender_id in conversation info", () => { + const text = buildInboundUserContextPrefix({ + ChatType: "group", + MessageSid: "msg-456", + SenderId: "289522496", + } as TemplateContext); + + const conversationInfo = parseConversationInfoPayload(text); + expect(conversationInfo["sender_id"]).toBe("289522496"); + }); + + it("trims sender_id in conversation info", () => { + const text = buildInboundUserContextPrefix({ + ChatType: "direct", + MessageSid: "msg-457", + SenderId: " 289522496 ", + } as TemplateContext); + + const conversationInfo = parseConversationInfoPayload(text); + expect(conversationInfo["sender_id"]).toBe("289522496"); + }); + it("falls back to SenderId when sender phone is missing", () => { const text = buildInboundUserContextPrefix({ ChatType: "direct", diff --git a/src/auto-reply/reply/inbound-meta.ts b/src/auto-reply/reply/inbound-meta.ts index 17338d0612..1304f7d54f 100644 --- a/src/auto-reply/reply/inbound-meta.ts +++ b/src/auto-reply/reply/inbound-meta.ts @@ -13,20 +13,15 @@ function safeTrim(value: unknown): string | undefined { export function buildInboundMetaSystemPrompt(ctx: TemplateContext): string { const chatType = normalizeChatType(ctx.ChatType); const isDirect = !chatType || chatType === "direct"; - const messageId = safeTrim(ctx.MessageSid); - const messageIdFull = safeTrim(ctx.MessageSidFull); - const replyToId = safeTrim(ctx.ReplyToId); - const chatId = safeTrim(ctx.OriginatingTo); // Keep system metadata strictly free of attacker-controlled strings (sender names, group subjects, etc.). // Those belong in the user-role "untrusted context" blocks. + // Per-message identifiers (message_id, reply_to_id, sender_id) are also excluded here: they change + // on every turn and would bust prefix-based prompt caches on local model providers. They are + // included in the user-role conversation info block via buildInboundUserContextPrefix() instead. const payload = { schema: "openclaw.inbound_meta.v1", - message_id: messageId, - message_id_full: messageIdFull && messageIdFull !== messageId ? messageIdFull : undefined, - sender_id: safeTrim(ctx.SenderId), - chat_id: chatId, - reply_to_id: replyToId, + chat_id: safeTrim(ctx.OriginatingTo), channel: safeTrim(ctx.OriginatingChannel) ?? safeTrim(ctx.Surface) ?? safeTrim(ctx.Provider), provider: safeTrim(ctx.Provider), surface: safeTrim(ctx.Surface), @@ -60,8 +55,13 @@ export function buildInboundUserContextPrefix(ctx: TemplateContext): string { const chatType = normalizeChatType(ctx.ChatType); const isDirect = !chatType || chatType === "direct"; + const messageId = safeTrim(ctx.MessageSid); + const messageIdFull = safeTrim(ctx.MessageSidFull); const conversationInfo = { - message_id: safeTrim(ctx.MessageSid), + message_id: messageId, + message_id_full: messageIdFull && messageIdFull !== messageId ? messageIdFull : undefined, + reply_to_id: safeTrim(ctx.ReplyToId), + sender_id: safeTrim(ctx.SenderId), conversation_label: isDirect ? undefined : safeTrim(ctx.ConversationLabel), sender: safeTrim(ctx.SenderE164) ?? safeTrim(ctx.SenderId) ?? safeTrim(ctx.SenderUsername), group_subject: safeTrim(ctx.GroupSubject),