fix: keep runtime context out of user turns

This commit is contained in:
Peter Steinberger
2026-04-27 21:24:56 +01:00
parent b9fd13e8d7
commit 11e6928b3e
12 changed files with 264 additions and 43 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
- Channels/sessions: prevent guarded inbound session recording from creating route-only phantom sessions while still allowing last-route updates for sessions that already exist. Carries forward #73009. Thanks @jzakirov.
- Plugins/runtime deps: stage bundled plugin dependencies imported by mirrored root dist chunks, so packaged memory and status commands do not miss `chokidar` or similar root-chunk dependencies after update. Fixes #72882 and #72970; carries forward #72992. Thanks @shrimpy8, @colin-chang, and @Schnup03.
- Agents/runtime context: deliver hidden runtime context through prompt-local system context while keeping the transcript-only custom entry out of provider user turns, and strip stale copied runtime-context prefaces from user-facing replies. Fixes #72386; carries forward #72969. Thanks @jhsmith409.
- Channels/Telegram: skip the optional webhook-info API call during polling-mode status checks and startup bot-label probes so long-polling setups avoid an unnecessary Telegram round trip. Carries forward #72990. Thanks @danielgruneberg.
- CLI/message: resolve targeted `openclaw message` channels to their owning plugin before loading the registry, and fall back to configured channel plugins when the channel must be inferred, so scripted sends avoid full bundled plugin registry scans without assuming channel ids match plugin ids. Fixes #73006. Thanks @jasonftl.
- CLI/models: keep route-first `models status --json` stdout reserved for the JSON payload by routing auth-profile and startup diagnostics to stderr. Fixes #72962. Thanks @vishutdhar.

View File

@@ -82,6 +82,38 @@ describe("compaction toolResult details stripping", () => {
expect(serialized).not.toContain('"details"');
});
it("does not pass runtime-context custom messages into generateSummary", async () => {
const messages = [
{ role: "user", content: "visible ask", timestamp: 1 },
{
role: "custom",
customType: "openclaw.runtime-context",
content: "secret runtime context",
display: false,
timestamp: 2,
},
{ role: "assistant", content: "visible answer", timestamp: 3 },
] as unknown as AgentMessage[];
await summarizeWithFallback({
messages,
model: { id: "mock", name: "mock", contextWindow: 10000, maxTokens: 1000 } as never,
apiKey: "test", // pragma: allowlist secret
signal: new AbortController().signal,
reserveTokens: 100,
maxChunkTokens: 5000,
contextWindow: 10000,
});
const chunk = (
piCodingAgentMocks.generateSummary.mock.calls as unknown as Array<[unknown]>
)[0]?.[0];
const serialized = JSON.stringify(chunk);
expect(serialized).toContain("visible ask");
expect(serialized).not.toContain("openclaw.runtime-context");
expect(serialized).not.toContain("secret runtime context");
});
it("ignores toolResult.details when evaluating oversized messages", () => {
piCodingAgentMocks.estimateTokens.mockImplementation((message: unknown) => {
const record = message as { details?: unknown };

View File

@@ -11,6 +11,7 @@ import { isAbortError } from "../infra/unhandled-rejections.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js";
import { isTimeoutError } from "./failover-error.js";
import { stripRuntimeContextCustomMessages } from "./internal-runtime-context.js";
import { repairToolUseResultPairing, stripToolResultDetails } from "./session-transcript-repair.js";
import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-id.js";
@@ -101,8 +102,8 @@ export function buildCompactionSummarizationInstructions(
}
export function estimateMessagesTokens(messages: AgentMessage[]): number {
// SECURITY: toolResult.details can contain untrusted/verbose payloads; never include in LLM-facing compaction.
const safe = stripToolResultDetails(messages);
// SECURITY: toolResult.details and runtime-context transcript entries must never enter LLM-facing compaction.
const safe = stripToolResultDetails(stripRuntimeContextCustomMessages(messages));
return safe.reduce((sum, message) => sum + estimateTokens(message), 0);
}
@@ -305,8 +306,8 @@ async function summarizeChunks(params: {
return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
}
// SECURITY: never feed toolResult.details into summarization prompts.
const safeMessages = stripToolResultDetails(params.messages);
// SECURITY: never feed toolResult.details or runtime-context transcript entries into summarization prompts.
const safeMessages = stripToolResultDetails(stripRuntimeContextCustomMessages(params.messages));
const chunks = chunkMessagesByMaxTokens(safeMessages, params.maxChunkTokens);
let summary = params.previousSummary;
const effectiveInstructions = buildCompactionSummarizationInstructions(

View File

@@ -4,12 +4,15 @@ export const INTERNAL_RUNTIME_CONTEXT_END = "<<<END_OPENCLAW_INTERNAL_CONTEXT>>>
const ESCAPED_INTERNAL_RUNTIME_CONTEXT_BEGIN = "[[OPENCLAW_INTERNAL_CONTEXT_BEGIN]]";
const ESCAPED_INTERNAL_RUNTIME_CONTEXT_END = "[[OPENCLAW_INTERNAL_CONTEXT_END]]";
export const OPENCLAW_RUNTIME_CONTEXT_NOTICE =
"This context is runtime-generated, not user-authored. Keep internal details private.";
export const OPENCLAW_NEXT_TURN_RUNTIME_CONTEXT_HEADER =
"OpenClaw runtime context for the immediately preceding user message.";
export const OPENCLAW_RUNTIME_EVENT_HEADER = "OpenClaw runtime event.";
export const OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE = "openclaw.runtime-context";
const LEGACY_INTERNAL_CONTEXT_HEADER =
[
"OpenClaw runtime context (internal):",
"This context is runtime-generated, not user-authored. Keep internal details private.",
"",
].join("\n") + "\n";
["OpenClaw runtime context (internal):", OPENCLAW_RUNTIME_CONTEXT_NOTICE, ""].join("\n") + "\n";
const LEGACY_INTERNAL_EVENT_MARKER = "[Internal task completion event]";
const LEGACY_INTERNAL_EVENT_SEPARATOR = "\n\n---\n\n";
@@ -154,6 +157,42 @@ function stripLegacyInternalRuntimeContext(text: string): string {
}
}
function isRuntimeContextPromptHeader(line: string): boolean {
return (
line === OPENCLAW_NEXT_TURN_RUNTIME_CONTEXT_HEADER || line === OPENCLAW_RUNTIME_EVENT_HEADER
);
}
function stripRuntimeContextPromptPreface(text: string): string {
const lines = text.split(/\r?\n/);
let changed = false;
const output: string[] = [];
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
const nextLine = lines[index + 1] ?? "";
if (
isRuntimeContextPromptHeader(line.trim()) &&
nextLine.trim() === OPENCLAW_RUNTIME_CONTEXT_NOTICE
) {
changed = true;
index += 1;
while (index + 1 < lines.length && (lines[index + 1] ?? "").trim() === "") {
index += 1;
}
continue;
}
output.push(line);
}
return changed
? output
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim()
: text;
}
export function stripInternalRuntimeContext(text: string): string {
if (!text) {
return text;
@@ -163,7 +202,9 @@ export function stripInternalRuntimeContext(text: string): string {
INTERNAL_RUNTIME_CONTEXT_BEGIN,
INTERNAL_RUNTIME_CONTEXT_END,
);
return stripLegacyInternalRuntimeContext(withoutDelimitedBlocks);
return stripRuntimeContextPromptPreface(
stripLegacyInternalRuntimeContext(withoutDelimitedBlocks),
);
}
export function hasInternalRuntimeContext(text: string): boolean {
@@ -172,6 +213,27 @@ export function hasInternalRuntimeContext(text: string): boolean {
}
return (
findDelimitedTokenIndex(text, INTERNAL_RUNTIME_CONTEXT_BEGIN, 0) !== -1 ||
text.includes(LEGACY_INTERNAL_CONTEXT_HEADER)
text.includes(LEGACY_INTERNAL_CONTEXT_HEADER) ||
text.includes(
`${OPENCLAW_NEXT_TURN_RUNTIME_CONTEXT_HEADER}\n${OPENCLAW_RUNTIME_CONTEXT_NOTICE}`,
) ||
text.includes(`${OPENCLAW_RUNTIME_EVENT_HEADER}\n${OPENCLAW_RUNTIME_CONTEXT_NOTICE}`)
);
}
export function isOpenClawRuntimeContextCustomMessage(message: unknown): boolean {
if (!message || typeof message !== "object") {
return false;
}
const candidate = message as { role?: unknown; customType?: unknown };
return (
candidate.role === "custom" && candidate.customType === OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE
);
}
export function stripRuntimeContextCustomMessages<T>(messages: T[]): T[] {
if (!messages.some(isOpenClawRuntimeContextCustomMessage)) {
return messages;
}
return messages.filter((message) => !isOpenClawRuntimeContextCustomMessage(message));
}

View File

@@ -325,6 +325,30 @@ describe("sanitizeUserFacingText", () => {
expect(sanitizeUserFacingText(input)).toBe("Visible intro.\n\nVisible outro.");
});
it("strips copied next-turn runtime context prefaces from user-facing text", () => {
const input = [
"OpenClaw runtime context for the immediately preceding user message.",
"This context is runtime-generated, not user-authored. Keep internal details private.",
"",
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"secret runtime context",
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
"",
"Visible reply.",
].join("\n");
expect(sanitizeUserFacingText(input)).toBe("Visible reply.");
});
it("strips copied runtime event prefaces when no visible text remains", () => {
const input = [
"OpenClaw runtime event.",
"This context is runtime-generated, not user-authored. Keep internal details private.",
].join("\n");
expect(sanitizeUserFacingText(input)).toBe("");
});
it("does not strip ordinary text that merely mentions internal marker strings", () => {
const input = [
"The literal header `OpenClaw runtime context (internal):` appears in this note.",

View File

@@ -164,10 +164,15 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
role: "custom",
customType: "openclaw.runtime-context",
display: false,
content: expect.stringContaining("secret runtime context"),
content:
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nsecret runtime context\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
}),
]),
);
expect(JSON.stringify(seen.messages)).not.toContain(
"OpenClaw runtime context for the immediately preceding user message.",
);
expect(JSON.stringify(seen.messages)).not.toContain("not user-authored");
const trajectoryEvents = (
await fs.readFile(path.join(tempPaths[0] ?? "", "session.trajectory.jsonl"), "utf8")
)

View File

@@ -96,6 +96,42 @@ describe("normalizeMessagesForLlmBoundary", () => {
expect(output[0]?.content).toEqual([{ type: "text", text: "visible output" }]);
expect(input[0]).toHaveProperty("details");
});
it("keeps runtime-context transcript entries out of the LLM boundary", () => {
const input = [
{
role: "user",
content: [{ type: "text", text: "visible ask" }],
timestamp: 1,
},
{
role: "custom",
customType: "openclaw.runtime-context",
content: "secret runtime context",
display: false,
timestamp: 2,
},
{
role: "custom",
customType: "other-extension-context",
content: "normal custom context",
display: false,
timestamp: 3,
},
];
const output = normalizeMessagesForLlmBoundary(
input as Parameters<typeof normalizeMessagesForLlmBoundary>[0],
) as Array<Record<string, unknown>>;
expect(output).toHaveLength(2);
expect(output).not.toEqual(
expect.arrayContaining([expect.objectContaining({ customType: "openclaw.runtime-context" })]),
);
expect(output).toEqual(
expect.arrayContaining([expect.objectContaining({ customType: "other-extension-context" })]),
);
});
});
describe("shouldCreateBundleMcpRuntimeForAttempt", () => {

View File

@@ -78,6 +78,7 @@ import { resolveOpenClawReferencePaths } from "../../docs-path.js";
import { isTimeoutError } from "../../failover-error.js";
import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js";
import { resolveImageSanitizationLimits } from "../../image-sanitization.js";
import { stripRuntimeContextCustomMessages } from "../../internal-runtime-context.js";
import { buildModelAliasLines } from "../../model-alias-lines.js";
import { resolveModelAuthMode } from "../../model-auth.js";
import { resolveDefaultModelForAgent } from "../../model-selection.js";
@@ -314,6 +315,7 @@ import {
shouldPreemptivelyCompactBeforePrompt,
} from "./preemptive-compaction.js";
import {
buildRuntimeContextSystemContext,
queueRuntimeContextForNextTurn,
resolveRuntimeContextPromptParts,
} from "./runtime-context-prompt.js";
@@ -480,7 +482,8 @@ export function applyEmbeddedAttemptToolsAllow<T extends { name: string }>(
}
export function normalizeMessagesForLlmBoundary(messages: AgentMessage[]): AgentMessage[] {
return stripToolResultDetails(normalizeAssistantReplayContent(messages));
const normalized = stripToolResultDetails(normalizeAssistantReplayContent(messages));
return stripRuntimeContextCustomMessages(normalized);
}
export function shouldCreateBundleMcpRuntimeForAttempt(params: {
@@ -2657,19 +2660,35 @@ export async function runEmbeddedAttempt(
if (promptSubmission.runtimeOnly) {
await abortable(activeSession.prompt(promptSubmission.prompt));
} else {
await queueRuntimeContextForNextTurn({
session: activeSession,
runtimeContext: promptSubmission.runtimeContext,
});
const runtimeContext = promptSubmission.runtimeContext?.trim();
const runtimeSystemPrompt = runtimeContext
? composeSystemPromptWithHookContext({
baseSystemPrompt: systemPromptText,
appendSystemContext: buildRuntimeContextSystemContext(runtimeContext),
})
: undefined;
if (runtimeSystemPrompt) {
applySystemPromptOverrideToSession(activeSession, runtimeSystemPrompt);
}
try {
await queueRuntimeContextForNextTurn({
session: activeSession,
runtimeContext,
});
// Only pass images option if there are actually images to pass
// This avoids potential issues with models that don't expect the images parameter
if (imageResult.images.length > 0) {
await abortable(
activeSession.prompt(promptSubmission.prompt, { images: imageResult.images }),
);
} else {
await abortable(activeSession.prompt(promptSubmission.prompt));
// Only pass images option if there are actually images to pass
// This avoids potential issues with models that don't expect the images parameter
if (imageResult.images.length > 0) {
await abortable(
activeSession.prompt(promptSubmission.prompt, { images: imageResult.images }),
);
} else {
await abortable(activeSession.prompt(promptSubmission.prompt));
}
} finally {
if (runtimeSystemPrompt) {
applySystemPromptOverrideToSession(activeSession, systemPromptText);
}
}
}
}

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
buildRuntimeContextSystemContext,
queueRuntimeContextForNextTurn,
resolveRuntimeContextPromptParts,
} from "./runtime-context-prompt.js";
@@ -62,7 +63,10 @@ describe("runtime context prompt submission", () => {
});
it("queues runtime context as a hidden next-turn custom message", async () => {
const sendCustomMessage = vi.fn(async () => {});
const sentMessages: Array<{ content: string }> = [];
const sendCustomMessage = vi.fn(async (message: { content: string }) => {
sentMessages.push(message);
});
await queueRuntimeContextForNextTurn({
session: { sendCustomMessage },
@@ -72,11 +76,25 @@ describe("runtime context prompt submission", () => {
expect(sendCustomMessage).toHaveBeenCalledWith(
expect.objectContaining({
customType: "openclaw.runtime-context",
content: expect.stringContaining("secret runtime context"),
content: "secret runtime context",
display: false,
}),
{ deliverAs: "nextTurn" },
);
expect(sentMessages[0]?.content).not.toContain(
"OpenClaw runtime context for the immediately preceding user message.",
);
expect(sentMessages[0]?.content).not.toContain("not user-authored");
});
it("labels next-turn runtime context only when used as prompt-local system context", () => {
const systemContext = buildRuntimeContextSystemContext("secret runtime context");
expect(systemContext).toContain(
"OpenClaw runtime context for the immediately preceding user message.",
);
expect(systemContext).toContain("not user-authored");
expect(systemContext).toContain("secret runtime context");
});
it("labels runtime-only events as system context", async () => {

View File

@@ -1,4 +1,10 @@
const OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE = "openclaw.runtime-context";
import {
OPENCLAW_NEXT_TURN_RUNTIME_CONTEXT_HEADER,
OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE,
OPENCLAW_RUNTIME_CONTEXT_NOTICE,
OPENCLAW_RUNTIME_EVENT_HEADER,
} from "../../internal-runtime-context.js";
export { OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE };
type RuntimeContextSession = {
sendCustomMessage: (
@@ -65,14 +71,18 @@ function buildRuntimeContextMessageContent(params: {
}): string {
return [
params.kind === "runtime-event"
? "OpenClaw runtime event."
: "OpenClaw runtime context for the immediately preceding user message.",
"This context is runtime-generated, not user-authored. Keep internal details private.",
? OPENCLAW_RUNTIME_EVENT_HEADER
: OPENCLAW_NEXT_TURN_RUNTIME_CONTEXT_HEADER,
OPENCLAW_RUNTIME_CONTEXT_NOTICE,
"",
params.runtimeContext,
].join("\n");
}
export function buildRuntimeContextSystemContext(runtimeContext: string): string {
return buildRuntimeContextMessageContent({ runtimeContext, kind: "next-turn" });
}
export function buildRuntimeEventSystemContext(runtimeContext: string): string {
return buildRuntimeContextMessageContent({ runtimeContext, kind: "runtime-event" });
}
@@ -88,7 +98,7 @@ export async function queueRuntimeContextForNextTurn(params: {
await params.session.sendCustomMessage(
{
customType: OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE,
content: buildRuntimeContextMessageContent({ runtimeContext, kind: "next-turn" }),
content: runtimeContext,
display: false,
details: { source: "openclaw-runtime-context" },
},

View File

@@ -1433,6 +1433,13 @@ describe("compaction-safeguard recent-turn preservation", () => {
preparation: {
messagesToSummarize: [
{ role: "user", content: "older context", timestamp: 1 },
{
role: "custom",
customType: "openclaw.runtime-context",
content: "secret runtime context",
display: false,
timestamp: 1.5,
} as unknown as AgentMessage,
{ role: "assistant", content: "older reply", timestamp: 2 } as unknown as AgentMessage,
{ role: "user", content: "latest ask status", timestamp: 3 },
{
@@ -1831,6 +1838,9 @@ describe("compaction-safeguard recent-turn preservation", () => {
},
}),
);
const providerMessages = providerSummarize.mock.calls[0]?.[0]?.messages ?? [];
expect(JSON.stringify(providerMessages)).not.toContain("openclaw.runtime-context");
expect(JSON.stringify(providerMessages)).not.toContain("secret runtime context");
expect(compaction.summary).toContain("provider summary body");
expect(compaction.summary).toContain("**Turn Context (split turn):**");
expect(compaction.summary).toContain("prefix request that was split out");

View File

@@ -30,6 +30,7 @@ import {
import { collectTextContentBlocks } from "../content-blocks.js";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "../copilot-dynamic-headers.js";
import { isTimeoutError } from "../failover-error.js";
import { stripRuntimeContextCustomMessages } from "../internal-runtime-context.js";
import { repairToolUseResultPairing } from "../session-transcript-repair.js";
import { extractToolCallsFromAssistant, extractToolResultId } from "../tool-call-id.js";
import {
@@ -776,10 +777,15 @@ async function readWorkspaceContextForSummary(): Promise<string> {
export default function compactionSafeguardExtension(api: ExtensionAPI): void {
api.on("session_before_compact", async (event, ctx) => {
const { preparation, customInstructions: eventInstructions, signal } = event;
const hasRealSummarizable = preparation.messagesToSummarize.some((message, index, messages) =>
const rawTurnPrefixMessages = preparation.turnPrefixMessages ?? [];
const baseMessagesToSummarize = stripRuntimeContextCustomMessages(
preparation.messagesToSummarize,
);
const baseTurnPrefixMessages = stripRuntimeContextCustomMessages(rawTurnPrefixMessages);
const hasRealSummarizable = baseMessagesToSummarize.some((message, index, messages) =>
isRealConversationMessage(message, messages, index),
);
const hasRealTurnPrefix = preparation.turnPrefixMessages.some((message, index, messages) =>
const hasRealTurnPrefix = baseTurnPrefixMessages.some((message, index, messages) =>
isRealConversationMessage(message, messages, index),
);
setCompactionSafeguardCancelReason(ctx.sessionManager, undefined);
@@ -811,8 +817,8 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps);
const fileOpsSummary = formatFileOperations(readFiles, modifiedFiles);
const toolFailures = collectToolFailures([
...preparation.messagesToSummarize,
...preparation.turnPrefixMessages,
...baseMessagesToSummarize,
...baseTurnPrefixMessages,
]);
const toolFailureSection = formatToolFailuresSection(toolFailures);
@@ -829,10 +835,10 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
};
const identifierPolicy = runtime?.identifierPolicy ?? "strict";
const providerId = runtime?.provider;
const turnPrefixMessages = preparation.turnPrefixMessages ?? [];
const turnPrefixMessages = baseTurnPrefixMessages;
const recentTurnsPreserve = resolveRecentTurnsPreserve(runtime?.recentTurnsPreserve);
const { preservedMessages: providerPreservedMessages } = splitPreservedRecentTurns({
messages: preparation.messagesToSummarize,
messages: baseMessagesToSummarize,
recentTurnsPreserve,
});
const preservedTurnsSection = formatPreservedTurnsSection(providerPreservedMessages);
@@ -854,10 +860,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
try {
// Give the provider ALL messages — no pruning, no chunking, no split-turn splitting.
// The provider handles its own context management.
const allMessages = [
...preparation.messagesToSummarize,
...(preparation.turnPrefixMessages ?? []),
];
const allMessages = [...baseMessagesToSummarize, ...turnPrefixMessages];
const providerResult = await tryProviderSummarize(compactionProvider, {
messages: allMessages,
signal,
@@ -937,7 +940,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
try {
const modelContextWindow = resolveContextWindowTokens(model);
const contextWindowTokens = runtime?.contextWindowTokens ?? modelContextWindow;
let messagesToSummarize = preparation.messagesToSummarize;
let messagesToSummarize = baseMessagesToSummarize;
const headers = buildCompactionSummaryHeaders({
model,
messages: messagesToSummarize,