fix(auto-reply): restore prompt cache stability by moving per-turn ids to user context

Commit bed8e7abe added message_id, message_id_full, reply_to_id, and sender_id
to buildInboundMetaSystemPrompt(), injecting them into the system prompt on every
turn. Since message_id is unique per message, this caused the system prompt to
differ on every turn, busting prefix-based prompt caches on local model providers
(llama-server, LM Studio/MLX) and causing full cache rebuilds from ~token 9212.

Move these per-turn volatile fields out of the system prompt and into the user-role
conversationInfo block in buildInboundUserContextPrefix(), where message_id was
already partially present. The system prompt now contains only session-stable fields
(chat_id, channel, provider, surface, chat_type, flags), restoring cache stability
for the duration of a session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Isis Anisoptera
2026-02-18 17:14:58 -08:00
committed by Mariano Belinky
parent ff3a7e5635
commit 1d6e3b4561
2 changed files with 78 additions and 64 deletions

View File

@@ -19,7 +19,7 @@ function parseConversationInfoPayload(text: string): Record<string, unknown> {
}
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",

View File

@@ -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),