mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:20:43 +00:00
fix(agents): keep runtime wakeups out of chat transcript
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 }) => {});
|
||||
|
||||
@@ -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 () => {},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user