diff --git a/CHANGELOG.md b/CHANGELOG.md index 64e5055ef69..c1271d45ec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts index 434d76f4618..53e899b1b33 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts @@ -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 }) => {}); diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts index 3ddfaaacfea..7a06cc4f8e1 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts @@ -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 () => {}, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 572ff820070..c048f031a03 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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) { diff --git a/src/agents/pi-embedded-runner/run/runtime-context-prompt.test.ts b/src/agents/pi-embedded-runner/run/runtime-context-prompt.test.ts index 65b1cc20360..ab044e8c816 100644 --- a/src/agents/pi-embedded-runner/run/runtime-context-prompt.test.ts +++ b/src/agents/pi-embedded-runner/run/runtime-context-prompt.test.ts @@ -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"); + }); }); diff --git a/src/agents/pi-embedded-runner/run/runtime-context-prompt.ts b/src/agents/pi-embedded-runner/run/runtime-context-prompt.ts index f705b1e0b03..dd219df4ac6 100644 --- a/src/agents/pi-embedded-runner/run/runtime-context-prompt.ts +++ b/src/agents/pi-embedded-runner/run/runtime-context-prompt.ts @@ -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; }; +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" }, }, diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 8af6b85d29b..57e0e0f4d5b 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -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({ diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 39405ef2852..aa47ed34315 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -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,