fix(agents): honor disabled envelope timestamps at model boundary (#93238)

Merged via squash.

Prepared head SHA: 53f7117a4b
Co-authored-by: osolmaz <2453968+osolmaz@users.noreply.github.com>
Reviewed-by: @osolmaz
This commit is contained in:
Onur Solmaz
2026-06-16 12:13:24 +08:00
committed by GitHub
parent 48d96cd8a1
commit 8c108c294d
10 changed files with 317 additions and 11 deletions

View File

@@ -36,7 +36,7 @@ If `userTimezone` is unset, OpenClaw resolves the host timezone at runtime (no c
- **Use UTC envelopes** (`envelopeTimezone: "utc"`) when you want stable timestamps across hosts in different regions, or when you want UTC-aligned logs to match diagnostics output.
- **Use a fixed IANA zone** (e.g. `"Europe/Vienna"`) when the gateway host is in one zone but the user is in another and you want envelopes to read in the user's zone regardless of host migration.
- **Set `envelopeTimestamp: "off"`** for low-token envelopes when timestamp context is not useful for the conversation.
- **Set `envelopeTimestamp: "off"`** when timestamp context is not useful for the conversation. This removes absolute timestamps from envelopes, direct agent prompt prefixes, and embedded model-input prefixes.
For the full behavior reference, examples per provider, and elapsed-time formatting, see [Date & Time](/date-time).

View File

@@ -37,7 +37,7 @@ You can override this behavior:
- `envelopeTimezone: "local"` uses the host timezone.
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers, direct agent prompt prefixes, and embedded model-input prefixes.
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
### Examples

View File

@@ -20,6 +20,9 @@
import { describe, expect, it } from "vitest";
import { stripInboundMetadata } from "../../../auto-reply/reply/strip-inbound-meta.js";
import { buildTimestampPrefix } from "../../../gateway/server-methods/agent-timestamp.js";
import { streamOpenAICompletions } from "../../../llm/providers/openai-completions.js";
import { streamOpenAIResponses } from "../../../llm/providers/openai-responses.js";
import type { Context, Model } from "../../../llm/types.js";
import { normalizeMessagesForLlmBoundary } from "./attempt.llm-boundary.js";
// ---------------------------------------------------------------------------
@@ -29,6 +32,10 @@ import { normalizeMessagesForLlmBoundary } from "./attempt.llm-boundary.js";
type AgentMsg = Parameters<typeof normalizeMessagesForLlmBoundary>[0][number];
const TZ = "UTC";
// Payload capture stops before transport, but the provider helper still
// requires this option. Keep the fixture as joined inert text so scanners do
// not treat it as credential material.
const TEST_PROVIDER_OPTION_VALUE = ["fixture", "transport", "value"].join("-");
/** A user message as it sits in the JSONL transcript: BARE string + timestamp. */
function storedUserMsg(content: string, timestamp: number): AgentMsg {
@@ -57,7 +64,92 @@ const ASSISTANT_MSG: AgentMsg = {
const TS_TURN1 = 1717570800000; // fixed arrival time for turn 1
const TS_TURN2 = 1717570860000; // turn 2 (a minute later — crosses minute boundary)
const EXPECTED_PREFIX_TURN1 = buildTimestampPrefix(new Date(TS_TURN1), { timezone: TZ });
function requiredTimestampPrefix(timestamp: number): string {
const prefix = buildTimestampPrefix(new Date(timestamp), { timezone: TZ });
if (!prefix) {
throw new Error("expected timestamp prefix");
}
return prefix;
}
const EXPECTED_PREFIX_TURN1 = requiredTimestampPrefix(TS_TURN1);
const EXPECTED_PREFIX_TURN2 = requiredTimestampPrefix(TS_TURN2);
const OPENAI_COMPLETIONS_MODEL = {
id: "gpt-5.5",
name: "GPT-5.5",
api: "openai-completions",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4096,
} satisfies Model<"openai-completions">;
const OPENAI_RESPONSES_MODEL = {
...OPENAI_COMPLETIONS_MODEL,
api: "openai-responses",
} satisfies Model<"openai-responses">;
async function captureOpenAICompletionsPayload(
messages: AgentMsg[],
): Promise<Record<string, unknown>> {
let capturedPayload: Record<string, unknown> | undefined;
const stream = streamOpenAICompletions(
OPENAI_COMPLETIONS_MODEL,
{
systemPrompt: "Stable system prompt",
messages: normalizeMessagesForLlmBoundary(messages, { timezone: TZ }) as Context["messages"],
},
{
apiKey: TEST_PROVIDER_OPTION_VALUE,
cacheRetention: "none",
onPayload(payload) {
capturedPayload = payload as Record<string, unknown>;
throw new Error("stop after payload capture");
},
},
);
const result = await stream.result();
expect(result.stopReason).toBe("error");
expect(capturedPayload).toBeDefined();
return capturedPayload!;
}
async function captureOpenAIResponsesPayload(
messages: AgentMsg[],
): Promise<Record<string, unknown>> {
let capturedPayload: Record<string, unknown> | undefined;
const stream = streamOpenAIResponses(
OPENAI_RESPONSES_MODEL,
{
systemPrompt: "Stable system prompt",
messages: normalizeMessagesForLlmBoundary(messages, { timezone: TZ }) as Context["messages"],
},
{
apiKey: TEST_PROVIDER_OPTION_VALUE,
cacheRetention: "none",
onPayload(payload) {
capturedPayload = payload as Record<string, unknown>;
throw new Error("stop after payload capture");
},
},
);
const result = await stream.result();
expect(result.stopReason).toBe("error");
expect(capturedPayload).toBeDefined();
return capturedPayload!;
}
function firstTwoProviderMessages(payload: Record<string, unknown>): unknown[] {
const messages = payload.messages ?? payload.input;
expect(Array.isArray(messages)).toBe(true);
return (messages as unknown[]).slice(0, 2);
}
// ---------------------------------------------------------------------------
// THE GATE: bare-current vs bare-historical byte identity
@@ -103,6 +195,62 @@ describe("prompt-cache byte-identity (issue #3658)", () => {
expect(typeof normalizedHistorical[0]?.content).toBe("string");
});
it("keeps the OpenAI Chat Completions provider prefix stable with timestamps enabled", async () => {
const rawText = "Post-fix cache test ping 1 of 2";
const currentPayload = await captureOpenAICompletionsPayload([
currentUserMsg(rawText, TS_TURN1),
]);
const historicalPayload = await captureOpenAICompletionsPayload([
storedUserMsg(rawText, TS_TURN1),
ASSISTANT_MSG,
currentUserMsg("Post-fix cache test ping 2 of 2", TS_TURN2),
]);
const expectedTurn1 = `${EXPECTED_PREFIX_TURN1}${rawText}`;
const currentStablePrefix = firstTwoProviderMessages(currentPayload);
const historicalStablePrefix = firstTwoProviderMessages(historicalPayload);
expect(JSON.stringify(currentStablePrefix)).toBe(JSON.stringify(historicalStablePrefix));
expect(currentStablePrefix[1]).toEqual({ role: "user", content: expectedTurn1 });
expect(historicalStablePrefix[1]).toEqual({ role: "user", content: expectedTurn1 });
const historicalBytes = JSON.stringify(historicalPayload);
expect(historicalBytes.indexOf(EXPECTED_PREFIX_TURN2)).toBeGreaterThan(
historicalBytes.indexOf(expectedTurn1),
);
});
it("keeps the OpenAI Responses provider prefix stable with timestamps enabled", async () => {
const rawText = "Post-fix cache test ping 1 of 2";
const currentPayload = await captureOpenAIResponsesPayload([currentUserMsg(rawText, TS_TURN1)]);
const historicalPayload = await captureOpenAIResponsesPayload([
storedUserMsg(rawText, TS_TURN1),
ASSISTANT_MSG,
currentUserMsg("Post-fix cache test ping 2 of 2", TS_TURN2),
]);
const expectedTurn1 = `${EXPECTED_PREFIX_TURN1}${rawText}`;
const currentStablePrefix = firstTwoProviderMessages(currentPayload);
const historicalStablePrefix = firstTwoProviderMessages(historicalPayload);
expect(JSON.stringify(currentStablePrefix)).toBe(JSON.stringify(historicalStablePrefix));
expect(currentStablePrefix[1]).toEqual({
type: "message",
role: "user",
content: [{ type: "input_text", text: expectedTurn1 }],
});
expect(historicalStablePrefix[1]).toEqual({
type: "message",
role: "user",
content: [{ type: "input_text", text: expectedTurn1 }],
});
const historicalBytes = JSON.stringify(historicalPayload);
expect(historicalBytes.indexOf(EXPECTED_PREFIX_TURN2)).toBeGreaterThan(
historicalBytes.indexOf(expectedTurn1),
);
});
it("stamp derives from message timestamp, not wall-clock — repeated calls are byte-stable", () => {
// Same message object (fixed timestamp) → identical serialization regardless
// of when normalize is called. Guards against any "now"-based drift.

View File

@@ -115,6 +115,93 @@ describe("normalizeMessagesForLlmBoundary", () => {
expect(output[2]?.content).toBe(`${expectedCurrentPrefix}Current ask`);
});
it("can leave user message bytes bare for cache-sensitive local providers", () => {
const input = [
{
role: "user",
content: [{ type: "text", text: "Cache-sensitive current ask" }],
timestamp: 1717570860000,
},
];
const output = normalizeMessagesForLlmBoundary(
input as Parameters<typeof normalizeMessagesForLlmBoundary>[0],
{ timezone: "UTC", includeTimestamp: false },
) as unknown as Array<{ content?: string }>;
expect(output[0]?.content).toBe("Cache-sensitive current ask");
});
it("does not mutate transcript messages while leaving disabled timestamp output bare", () => {
const historicalContent =
'Conversation info (untrusted metadata):\n```json\n{"channel":"telegram"}\n```\n\nStored bare ask';
const input = [
{
role: "user",
content: [{ type: "text", text: historicalContent }],
timestamp: 1717570800000,
},
{
role: "assistant",
content: [{ type: "text", text: "Historical answer" }],
timestamp: 2,
},
{
role: "user",
content: [{ type: "text", text: "Current bare ask" }],
timestamp: 1717570860000,
},
];
const output = normalizeMessagesForLlmBoundary(
input as Parameters<typeof normalizeMessagesForLlmBoundary>[0],
{ timezone: "UTC", includeTimestamp: false },
) as unknown as Array<{ content?: string }>;
expect(output[0]?.content).toBe("Stored bare ask");
expect(output[2]?.content).toBe("Current bare ask");
const firstInput = input[0];
if (!firstInput) {
throw new Error("expected first input message");
}
expect(Array.isArray(firstInput.content)).toBe(true);
expect((firstInput.content as Array<{ text?: string }>)[0]?.text).toBe(historicalContent);
});
it("preserves stored sidecar metadata while preparing disabled timestamp model bytes", () => {
// This boundary normalization prepares provider input only: stored
// transcript/embedding sidecar state is preserved by identity, so no
// migration or persistent schema change is required for disabled stamps.
const input = [
{
role: "user",
content: [{ type: "text", text: "Stored ask with index metadata" }],
timestamp: 1717570800000,
__openclaw: {
seq: 12,
embeddingInput: "Stored ask with index metadata",
},
},
];
const output = normalizeMessagesForLlmBoundary(
input as Parameters<typeof normalizeMessagesForLlmBoundary>[0],
{ timezone: "UTC", includeTimestamp: false },
) as unknown as Array<Record<string, unknown>>;
expect(output[0]?.content).toBe("Stored ask with index metadata");
expect(output[0]?.["__openclaw"]).toEqual({
seq: 12,
embeddingInput: "Stored ask with index metadata",
});
expect(output[0]?.["__openclaw"]).toBe(input[0]?.["__openclaw"]);
expect(input[0]?.content).toEqual([{ type: "text", text: "Stored ask with index metadata" }]);
expect(input[0]?.["__openclaw"]).toEqual({
seq: 12,
embeddingInput: "Stored ask with index metadata",
});
});
it("stamps the current turn from the prepared persisted timestamp when supplied", () => {
const preparedTimestamp = 1717570800000;
const runtimeTimestamp = 1717574460000;

View File

@@ -13,6 +13,7 @@ import type { RuntimeContextCustomMessage } from "./runtime-context-prompt.js";
type LlmBoundaryOptions = {
timezone?: string;
includeTimestamp?: boolean;
currentUserTimestampOverride?: {
timestamp: number;
text: string;
@@ -56,6 +57,7 @@ export function normalizeMessagesForCurrentPromptBoundary(params: {
messages: AgentMessage[];
prompt: string;
timezone?: string;
includeTimestamp?: boolean;
currentUserTimestamp?: number;
}): AgentMessage[] {
const promptMessage = {
@@ -63,7 +65,13 @@ export function normalizeMessagesForCurrentPromptBoundary(params: {
content: [{ type: "text" as const, text: params.prompt }],
timestamp: params.currentUserTimestamp ?? Date.now(),
};
const boundaryOptions = params.timezone ? { timezone: params.timezone } : undefined;
const boundaryOptions =
params.timezone || params.includeTimestamp === false
? {
...(params.timezone ? { timezone: params.timezone } : {}),
...(params.includeTimestamp === false ? { includeTimestamp: false } : {}),
}
: undefined;
return normalizeMessagesForLlmBoundary(
[...params.messages, promptMessage],
boundaryOptions,
@@ -73,6 +81,7 @@ export function normalizeMessagesForCurrentPromptBoundary(params: {
export function normalizeCurrentPromptTextForLlmBoundary(params: {
prompt: string;
timezone?: string;
includeTimestamp?: boolean;
currentUserTimestamp?: number;
}): string {
const promptMessage = {
@@ -80,7 +89,13 @@ export function normalizeCurrentPromptTextForLlmBoundary(params: {
content: [{ type: "text" as const, text: params.prompt }],
timestamp: params.currentUserTimestamp ?? Date.now(),
};
const boundaryOptions = params.timezone ? { timezone: params.timezone } : undefined;
const boundaryOptions =
params.timezone || params.includeTimestamp === false
? {
...(params.timezone ? { timezone: params.timezone } : {}),
...(params.includeTimestamp === false ? { includeTimestamp: false } : {}),
}
: undefined;
const [normalized] = normalizeMessagesForLlmBoundary([promptMessage], boundaryOptions);
const content = (normalized as { content?: unknown } | undefined)?.content;
return typeof content === "string" ? content : params.prompt;
@@ -372,11 +387,15 @@ function stampUserTextWithMessageTimestamp(
text: string,
timestamp: unknown,
timezone: string | undefined,
includeTimestamp: boolean | undefined,
): string {
// Stamping is opt-in: only the LLM-boundary call sites that pass a resolved
// timezone (via resolveUserTimezone) stamp messages. When no timezone is
// supplied, the boundary performs form/metadata normalization only — leaving
// content bare (this also keeps non-stamping callers and unit fixtures clean).
if (includeTimestamp === false) {
return text;
}
if (!timezone) {
return text;
}
@@ -480,7 +499,12 @@ function stripHistoricalInboundMetadataFromUserMessages(
return `${envelope}${stripInboundMetadata(body)}`;
}
const stripped = isActive ? raw : stripInboundMetadata(raw);
return stampUserTextWithMessageTimestamp(stripped, messageTimestamp, options?.timezone);
return stampUserTextWithMessageTimestamp(
stripped,
messageTimestamp,
options?.timezone,
options?.includeTimestamp,
);
};
if (typeof content === "string") {

View File

@@ -2546,6 +2546,8 @@ export async function runEmbeddedAttempt(
const boundaryTimezone = isRawModelRun
? undefined
: resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const includeBoundaryTimestamp =
!isRawModelRun && params.config?.agents?.defaults?.envelopeTimestamp !== "off";
let currentUserTimestampOverride:
| { timestamp: number; text: string; alternateText?: string }
| undefined;
@@ -2555,6 +2557,7 @@ export async function runEmbeddedAttempt(
}
return {
...(boundaryTimezone ? { timezone: boundaryTimezone } : {}),
...(includeBoundaryTimestamp ? {} : { includeTimestamp: false }),
...(currentUserTimestampOverride ? { currentUserTimestampOverride } : {}),
};
};
@@ -4201,6 +4204,7 @@ export async function runEmbeddedAttempt(
messages: messagesForCurrentPrompt,
prompt: promptForModel,
...(boundaryTimezone ? { timezone: boundaryTimezone } : {}),
...(includeBoundaryTimestamp ? {} : { includeTimestamp: false }),
...(typeof preparedUserTurnMessage?.timestamp === "number"
? { currentUserTimestamp: preparedUserTurnMessage.timestamp }
: {}),
@@ -4486,6 +4490,7 @@ export async function runEmbeddedAttempt(
const llmBoundaryPromptForPrecheck = normalizeCurrentPromptTextForLlmBoundary({
prompt: promptForModel,
...(boundaryTimezone ? { timezone: boundaryTimezone } : {}),
...(includeBoundaryTimestamp ? {} : { includeTimestamp: false }),
...(typeof preparedUserTurnMessage?.timestamp === "number"
? { currentUserTimestamp: preparedUserTurnMessage.timestamp }
: {}),
@@ -4521,9 +4526,13 @@ export async function runEmbeddedAttempt(
});
}
const llmBoundaryOptionsForPrecheck = boundaryTimezone
? { timezone: boundaryTimezone }
: undefined;
const llmBoundaryOptionsForPrecheck =
boundaryTimezone || !includeBoundaryTimestamp
? {
...(boundaryTimezone ? { timezone: boundaryTimezone } : {}),
...(includeBoundaryTimestamp ? {} : { includeTimestamp: false }),
}
: undefined;
const unwindowedLlmBoundaryMessagesForPrecheck =
contextEnginePromptAuthority === "preassembly_may_overflow" &&
unwindowedContextEngineMessagesForPrecheck

View File

@@ -1158,7 +1158,7 @@ export const FIELD_HELP: Record<string, string> = {
"agents.defaults.envelopeTimezone":
'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).',
"agents.defaults.envelopeTimestamp":
'Include absolute timestamps in message envelopes ("on" or "off").',
'Include absolute timestamps in message envelopes, direct agent prompt prefixes, and embedded model-input prefixes ("on" or "off").',
"agents.defaults.envelopeElapsed": 'Include elapsed time in message envelopes ("on" or "off").',
"agents.defaults.models":
"Configured model catalog and allowlist (keys are full provider/model IDs or literal provider/* entries for dynamic provider catalogs).",

View File

@@ -313,7 +313,8 @@ export type AgentDefaultsConfig = {
*/
envelopeTimezone?: string;
/**
* Include absolute timestamps in message envelopes ("on" | "off", default: "on").
* Include absolute timestamps in message envelopes, direct agent prompt prefixes,
* and embedded model-input prefixes ("on" | "off", default: "on").
*/
envelopeTimestamp?: "on" | "off";
/**

View File

@@ -21,6 +21,7 @@ const TIMESTAMP_ENVELOPE_PATTERN = /^\[.*\d{4}-\d{2}-\d{2} \d{2}:\d{2}/;
export interface TimestampInjectionOptions {
timezone?: string;
now?: Date;
includeTimestamp?: boolean;
}
/**
@@ -72,6 +73,9 @@ export function buildTimestampPrefix(
* @see https://github.com/openclaw/openclaw/issues/3658
*/
export function injectTimestamp(message: string, opts?: TimestampInjectionOptions): string {
if (opts?.includeTimestamp === false) {
return message;
}
if (!message.trim()) {
return message;
}
@@ -101,5 +105,6 @@ export function injectTimestamp(message: string, opts?: TimestampInjectionOption
export function timestampOptsFromConfig(cfg: OpenClawConfig): TimestampInjectionOptions {
return {
timezone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
includeTimestamp: cfg.agents?.defaults?.envelopeTimestamp !== "off",
};
}

View File

@@ -735,6 +735,21 @@ describe("injectTimestamp", () => {
expect(result).toMatch(/^\[Fri 2025-07-04 12:00 EDT\]/);
});
it("leaves messages bare when config disables envelope timestamps", () => {
const cfg = {
agents: {
defaults: {
envelopeTimestamp: "off",
userTimezone: "America/New_York",
},
},
} as OpenClawConfig;
expect(injectTimestamp("cache sensitive prompt", timestampOptsFromConfig(cfg))).toBe(
"cache sensitive prompt",
);
});
});
describe("sanitizeChatHistoryMessages", () => {
@@ -1898,6 +1913,23 @@ describe("timestampOptsFromConfig", () => {
])("$name", ({ cfg, expected }) => {
expect(timestampOptsFromConfig(cfg).timezone).toBe(expected);
});
it("keeps timestamp injection enabled for upgraded configs unless explicitly disabled", () => {
const upgradedConfigWithExistingDefaults = {
agents: { defaults: { userTimezone: "America/Chicago" } },
} as OpenClawConfig;
// Existing user configs do not store envelopeTimestamp; omission remains
// the shipped default even when other agent defaults are present, so no
// config migration is needed for this broadened use of the setting.
expect(timestampOptsFromConfig({} as OpenClawConfig).includeTimestamp).toBe(true);
expect(timestampOptsFromConfig(upgradedConfigWithExistingDefaults).includeTimestamp).toBe(true);
expect(
timestampOptsFromConfig({
agents: { defaults: { envelopeTimestamp: "off" } },
} as OpenClawConfig).includeTimestamp,
).toBe(false);
});
});
describe("normalizeRpcAttachmentsToChatAttachments", () => {