fix(agents): keep runtime wakeups out of chat transcript

This commit is contained in:
Peter Steinberger
2026-04-26 06:20:51 +01:00
parent f0ea901a0d
commit 58a31b12f7
8 changed files with 162 additions and 23 deletions

View File

@@ -70,6 +70,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/runtime: submit heartbeat, cron, and exec wakeups as transient runtime context instead of visible user prompts, keeping synthetic system work out of chat transcripts. Fixes #66496 and #66814. Thanks @jeades and @mandomaker.
- Telegram: preserve exact selected quote text when sending native quote replies, and retry with legacy replies if Telegram rejects quote parameters. (#71952) Thanks @rubencu.
- Plugins/CLI: preserve manifest name, description, format, and source metadata in cold `openclaw plugins list` output without importing plugin runtime. Thanks @shakkernerd.
- Security/audit: read channel exposure and plugin allowlist ownership from read-only plugin index metadata so cold audits do not depend on loaded channel runtime. Thanks @shakkernerd.

View File

@@ -194,6 +194,47 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
}
});
it("submits runtime-only context through system prompt without visible prompt", async () => {
let seenPrompt: string | undefined;
const result = await createContextEngineAttemptRunner({
contextEngine: createContextEngineBootstrapAndAssemble(),
sessionKey,
tempPaths,
attemptOverrides: {
prompt: "internal heartbeat event",
transcriptPrompt: "",
},
sessionPrompt: async (session, prompt) => {
seenPrompt = prompt;
session.messages = [
...session.messages,
{ role: "assistant", content: "done", timestamp: 2 },
];
},
});
expect(seenPrompt).toBe("");
expect(result.finalPromptText).toBe("");
expect(result.messagesSnapshot).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "user",
content: expect.stringContaining("internal heartbeat event"),
}),
]),
);
const trajectoryEvents = (
await fs.readFile(path.join(tempPaths[0] ?? "", "session.trajectory.jsonl"), "utf8")
)
.trim()
.split("\n")
.map((line) => JSON.parse(line) as TrajectoryEvent);
const contextCompiled = trajectoryEvents.find((event) => event.type === "context.compiled");
expect(contextCompiled?.data?.prompt).toBe("");
expect(contextCompiled?.data?.systemPrompt).toContain("internal heartbeat event");
});
it("forwards sessionKey to bootstrap, assemble, and afterTurn", async () => {
const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble();
const afterTurn = vi.fn(async (_params: { sessionKey?: string }) => {});

View File

@@ -812,6 +812,14 @@ export function createDefaultEmbeddedSession(params?: {
sendCustomMessage: async (message, options) => {
if (options?.deliverAs === "nextTurn") {
session.messages = [...session.messages, { role: "custom", timestamp: 1, ...message }];
return;
}
if (options?.triggerTurn) {
session.messages = [
...session.messages,
{ role: "custom", timestamp: 1, ...message },
{ role: "assistant", content: "done", timestamp: 2 },
];
}
},
abort: async () => {},

View File

@@ -2389,6 +2389,17 @@ export async function runEmbeddedAttempt(
effectivePrompt,
transcriptPrompt: params.transcriptPrompt,
});
const runtimeSystemContext = promptSubmission.runtimeSystemContext?.trim();
if (promptSubmission.runtimeOnly && runtimeSystemContext) {
const runtimeSystemPrompt = composeSystemPromptWithHookContext({
baseSystemPrompt: systemPromptText,
appendSystemContext: runtimeSystemContext,
});
if (runtimeSystemPrompt) {
applySystemPromptOverrideToSession(activeSession, runtimeSystemPrompt);
systemPromptText = runtimeSystemPrompt;
}
}
// Detect and load images referenced in the visible prompt for vision-capable models.
// Images are prompt-local only (pi-like behavior).
@@ -2426,6 +2437,7 @@ export async function runEmbeddedAttempt(
if (
!skipPromptSubmission &&
!promptSubmission.runtimeOnly &&
!hasPromptSubmissionContent({
prompt: promptSubmission.prompt,
messages: activeSession.messages,
@@ -2619,19 +2631,23 @@ export async function runEmbeddedAttempt(
messages: btwSnapshotMessages,
inFlightPrompt: promptSubmission.prompt,
});
await queueRuntimeContextForNextTurn({
session: activeSession,
runtimeContext: promptSubmission.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 {
if (promptSubmission.runtimeOnly) {
await abortable(activeSession.prompt(promptSubmission.prompt));
} else {
await queueRuntimeContextForNextTurn({
session: activeSession,
runtimeContext: promptSubmission.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));
}
}
}
} catch (err) {

View File

@@ -54,8 +54,10 @@ describe("runtime context prompt submission", () => {
transcriptPrompt: "",
}),
).toEqual({
prompt: "[OpenClaw runtime event]",
prompt: "",
runtimeContext: "internal event",
runtimeOnly: true,
runtimeSystemContext: expect.stringContaining("internal event"),
});
});
@@ -76,4 +78,11 @@ describe("runtime context prompt submission", () => {
{ deliverAs: "nextTurn" },
);
});
it("labels runtime-only events as system context", async () => {
const { buildRuntimeEventSystemContext } = await import("./runtime-context-prompt.js");
expect(buildRuntimeEventSystemContext("internal event")).toContain("OpenClaw runtime event.");
expect(buildRuntimeEventSystemContext("internal event")).toContain("not user-authored");
});
});

View File

@@ -1,5 +1,4 @@
const OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE = "openclaw.runtime-context";
const EMPTY_RUNTIME_EVENT_PROMPT = "[OpenClaw runtime event]";
type RuntimeContextSession = {
sendCustomMessage: (
@@ -13,6 +12,13 @@ type RuntimeContextSession = {
) => Promise<void>;
};
type RuntimeContextPromptParts = {
prompt: string;
runtimeContext?: string;
runtimeOnly?: boolean;
runtimeSystemContext?: string;
};
function removeLastPromptOccurrence(text: string, prompt: string): string | null {
const index = text.lastIndexOf(prompt);
if (index === -1) {
@@ -29,20 +35,48 @@ function removeLastPromptOccurrence(text: string, prompt: string): string | null
export function resolveRuntimeContextPromptParts(params: {
effectivePrompt: string;
transcriptPrompt?: string;
}): { prompt: string; runtimeContext?: string } {
}): RuntimeContextPromptParts {
const transcriptPrompt = params.transcriptPrompt;
if (transcriptPrompt === undefined || transcriptPrompt === params.effectivePrompt) {
return { prompt: params.effectivePrompt };
}
const prompt = transcriptPrompt.trim() || EMPTY_RUNTIME_EVENT_PROMPT;
const prompt = transcriptPrompt.trim();
const runtimeContext =
removeLastPromptOccurrence(params.effectivePrompt, transcriptPrompt)?.trim() ||
params.effectivePrompt.trim();
if (!prompt) {
return runtimeContext
? {
prompt: "",
runtimeContext,
runtimeOnly: true,
runtimeSystemContext: buildRuntimeEventSystemContext(runtimeContext),
}
: { prompt: "" };
}
return runtimeContext ? { prompt, runtimeContext } : { prompt };
}
function buildRuntimeContextMessageContent(params: {
runtimeContext: string;
kind: "next-turn" | "runtime-event";
}): 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.",
"",
params.runtimeContext,
].join("\n");
}
export function buildRuntimeEventSystemContext(runtimeContext: string): string {
return buildRuntimeContextMessageContent({ runtimeContext, kind: "runtime-event" });
}
export async function queueRuntimeContextForNextTurn(params: {
session: RuntimeContextSession;
runtimeContext?: string;
@@ -54,12 +88,7 @@ export async function queueRuntimeContextForNextTurn(params: {
await params.session.sendCustomMessage(
{
customType: OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE,
content: [
"OpenClaw runtime context for the immediately preceding user message.",
"This context is runtime-generated, not user-authored. Keep internal details private.",
"",
runtimeContext,
].join("\n"),
content: buildRuntimeContextMessageContent({ runtimeContext, kind: "next-turn" }),
display: false,
details: { source: "openclaw-runtime-context" },
},

View File

@@ -987,6 +987,37 @@ describe("runPreparedReply media-only handling", () => {
expect(call?.followupRun.prompt).not.toContain("System: [t] Post-compaction context.");
expect(call?.followupRun.transcriptPrompt).not.toContain("System: [t] Initial event.");
});
it("keeps heartbeat prompts out of visible transcript prompt", async () => {
const heartbeatPrompt = "Read HEARTBEAT.md and run any due maintenance.";
await runPreparedReply(
baseParams({
opts: { isHeartbeat: true },
ctx: {
Body: heartbeatPrompt,
RawBody: heartbeatPrompt,
CommandBody: heartbeatPrompt,
Provider: "heartbeat",
Surface: "heartbeat",
ChatType: "direct",
},
sessionCtx: {
Body: heartbeatPrompt,
BodyStripped: heartbeatPrompt,
Provider: "heartbeat",
Surface: "heartbeat",
ChatType: "direct",
},
}),
);
const call = vi.mocked(runReplyAgent).mock.calls.at(-1)?.[0];
expect(call?.commandBody).toContain(heartbeatPrompt);
expect(call?.followupRun.prompt).toContain(heartbeatPrompt);
expect(call?.transcriptCommandBody).toBe("");
expect(call?.followupRun.transcriptPrompt).toBe("");
});
it("uses inbound origin channel for run messageProvider", async () => {
await runPreparedReply(
baseParams({

View File

@@ -498,7 +498,11 @@ export async function runPreparedReply(
const effectiveBaseBody = hasUserBody
? baseBodyForPrompt
: [inboundUserContext, "[User sent media without caption]"].filter(Boolean).join("\n\n");
const transcriptBodyBase = hasUserBody ? baseBodyFinal : "[User sent media without caption]";
const transcriptBodyBase = isHeartbeat
? ""
: hasUserBody
? baseBodyFinal
: "[User sent media without caption]";
let prefixedBodyBase = await applySessionHints({
baseBody: effectiveBaseBody,
abortedLastRun,