mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 20:00:42 +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
|
### 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.
|
- 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.
|
- 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.
|
- 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 () => {
|
it("forwards sessionKey to bootstrap, assemble, and afterTurn", async () => {
|
||||||
const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble();
|
const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble();
|
||||||
const afterTurn = vi.fn(async (_params: { sessionKey?: string }) => {});
|
const afterTurn = vi.fn(async (_params: { sessionKey?: string }) => {});
|
||||||
|
|||||||
@@ -812,6 +812,14 @@ export function createDefaultEmbeddedSession(params?: {
|
|||||||
sendCustomMessage: async (message, options) => {
|
sendCustomMessage: async (message, options) => {
|
||||||
if (options?.deliverAs === "nextTurn") {
|
if (options?.deliverAs === "nextTurn") {
|
||||||
session.messages = [...session.messages, { role: "custom", timestamp: 1, ...message }];
|
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 () => {},
|
abort: async () => {},
|
||||||
|
|||||||
@@ -2389,6 +2389,17 @@ export async function runEmbeddedAttempt(
|
|||||||
effectivePrompt,
|
effectivePrompt,
|
||||||
transcriptPrompt: params.transcriptPrompt,
|
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.
|
// Detect and load images referenced in the visible prompt for vision-capable models.
|
||||||
// Images are prompt-local only (pi-like behavior).
|
// Images are prompt-local only (pi-like behavior).
|
||||||
@@ -2426,6 +2437,7 @@ export async function runEmbeddedAttempt(
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
!skipPromptSubmission &&
|
!skipPromptSubmission &&
|
||||||
|
!promptSubmission.runtimeOnly &&
|
||||||
!hasPromptSubmissionContent({
|
!hasPromptSubmissionContent({
|
||||||
prompt: promptSubmission.prompt,
|
prompt: promptSubmission.prompt,
|
||||||
messages: activeSession.messages,
|
messages: activeSession.messages,
|
||||||
@@ -2619,19 +2631,23 @@ export async function runEmbeddedAttempt(
|
|||||||
messages: btwSnapshotMessages,
|
messages: btwSnapshotMessages,
|
||||||
inFlightPrompt: promptSubmission.prompt,
|
inFlightPrompt: promptSubmission.prompt,
|
||||||
});
|
});
|
||||||
await queueRuntimeContextForNextTurn({
|
if (promptSubmission.runtimeOnly) {
|
||||||
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));
|
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) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -54,8 +54,10 @@ describe("runtime context prompt submission", () => {
|
|||||||
transcriptPrompt: "",
|
transcriptPrompt: "",
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
prompt: "[OpenClaw runtime event]",
|
prompt: "",
|
||||||
runtimeContext: "internal event",
|
runtimeContext: "internal event",
|
||||||
|
runtimeOnly: true,
|
||||||
|
runtimeSystemContext: expect.stringContaining("internal event"),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,4 +78,11 @@ describe("runtime context prompt submission", () => {
|
|||||||
{ deliverAs: "nextTurn" },
|
{ 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 OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE = "openclaw.runtime-context";
|
||||||
const EMPTY_RUNTIME_EVENT_PROMPT = "[OpenClaw runtime event]";
|
|
||||||
|
|
||||||
type RuntimeContextSession = {
|
type RuntimeContextSession = {
|
||||||
sendCustomMessage: (
|
sendCustomMessage: (
|
||||||
@@ -13,6 +12,13 @@ type RuntimeContextSession = {
|
|||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RuntimeContextPromptParts = {
|
||||||
|
prompt: string;
|
||||||
|
runtimeContext?: string;
|
||||||
|
runtimeOnly?: boolean;
|
||||||
|
runtimeSystemContext?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function removeLastPromptOccurrence(text: string, prompt: string): string | null {
|
function removeLastPromptOccurrence(text: string, prompt: string): string | null {
|
||||||
const index = text.lastIndexOf(prompt);
|
const index = text.lastIndexOf(prompt);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
@@ -29,20 +35,48 @@ function removeLastPromptOccurrence(text: string, prompt: string): string | null
|
|||||||
export function resolveRuntimeContextPromptParts(params: {
|
export function resolveRuntimeContextPromptParts(params: {
|
||||||
effectivePrompt: string;
|
effectivePrompt: string;
|
||||||
transcriptPrompt?: string;
|
transcriptPrompt?: string;
|
||||||
}): { prompt: string; runtimeContext?: string } {
|
}): RuntimeContextPromptParts {
|
||||||
const transcriptPrompt = params.transcriptPrompt;
|
const transcriptPrompt = params.transcriptPrompt;
|
||||||
if (transcriptPrompt === undefined || transcriptPrompt === params.effectivePrompt) {
|
if (transcriptPrompt === undefined || transcriptPrompt === params.effectivePrompt) {
|
||||||
return { prompt: params.effectivePrompt };
|
return { prompt: params.effectivePrompt };
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = transcriptPrompt.trim() || EMPTY_RUNTIME_EVENT_PROMPT;
|
const prompt = transcriptPrompt.trim();
|
||||||
const runtimeContext =
|
const runtimeContext =
|
||||||
removeLastPromptOccurrence(params.effectivePrompt, transcriptPrompt)?.trim() ||
|
removeLastPromptOccurrence(params.effectivePrompt, transcriptPrompt)?.trim() ||
|
||||||
params.effectivePrompt.trim();
|
params.effectivePrompt.trim();
|
||||||
|
if (!prompt) {
|
||||||
|
return runtimeContext
|
||||||
|
? {
|
||||||
|
prompt: "",
|
||||||
|
runtimeContext,
|
||||||
|
runtimeOnly: true,
|
||||||
|
runtimeSystemContext: buildRuntimeEventSystemContext(runtimeContext),
|
||||||
|
}
|
||||||
|
: { prompt: "" };
|
||||||
|
}
|
||||||
|
|
||||||
return runtimeContext ? { prompt, 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: {
|
export async function queueRuntimeContextForNextTurn(params: {
|
||||||
session: RuntimeContextSession;
|
session: RuntimeContextSession;
|
||||||
runtimeContext?: string;
|
runtimeContext?: string;
|
||||||
@@ -54,12 +88,7 @@ export async function queueRuntimeContextForNextTurn(params: {
|
|||||||
await params.session.sendCustomMessage(
|
await params.session.sendCustomMessage(
|
||||||
{
|
{
|
||||||
customType: OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE,
|
customType: OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE,
|
||||||
content: [
|
content: buildRuntimeContextMessageContent({ runtimeContext, kind: "next-turn" }),
|
||||||
"OpenClaw runtime context for the immediately preceding user message.",
|
|
||||||
"This context is runtime-generated, not user-authored. Keep internal details private.",
|
|
||||||
"",
|
|
||||||
runtimeContext,
|
|
||||||
].join("\n"),
|
|
||||||
display: false,
|
display: false,
|
||||||
details: { source: "openclaw-runtime-context" },
|
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.prompt).not.toContain("System: [t] Post-compaction context.");
|
||||||
expect(call?.followupRun.transcriptPrompt).not.toContain("System: [t] Initial event.");
|
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 () => {
|
it("uses inbound origin channel for run messageProvider", async () => {
|
||||||
await runPreparedReply(
|
await runPreparedReply(
|
||||||
baseParams({
|
baseParams({
|
||||||
|
|||||||
@@ -498,7 +498,11 @@ export async function runPreparedReply(
|
|||||||
const effectiveBaseBody = hasUserBody
|
const effectiveBaseBody = hasUserBody
|
||||||
? baseBodyForPrompt
|
? baseBodyForPrompt
|
||||||
: [inboundUserContext, "[User sent media without caption]"].filter(Boolean).join("\n\n");
|
: [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({
|
let prefixedBodyBase = await applySessionHints({
|
||||||
baseBody: effectiveBaseBody,
|
baseBody: effectiveBaseBody,
|
||||||
abortedLastRun,
|
abortedLastRun,
|
||||||
|
|||||||
Reference in New Issue
Block a user