diff --git a/CHANGELOG.md b/CHANGELOG.md index 407c1e72b18..f295b169088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Agents/tool-result guard: use the resolved runtime context token budget for non-context-engine tool-result overflow checks, so long tool-heavy sessions no longer compact early when `contextTokens` is larger than native `contextWindow`. Fixes #74917. Thanks @kAIborg24. - Gateway/systemd: exit with sysexits 78 for supervised lock and `EADDRINUSE` conflicts so `RestartPreventExitStatus=78` stops `Restart=always` restart loops instead of repeatedly reloading plugins against an occupied port. Fixes #75115. Thanks @yhyatt. +- Agents/runtime: skip blank visible user prompts at the embedded-runner boundary before provider submission while still allowing internal runtime-only turns and media-only prompts, so Telegram/group sessions no longer leak raw empty-input provider errors when replay history exists. Fixes #74137. Thanks @yelog, @Gracker, and @nhaener. - Plugins/runtime-deps: replace stale symlinked mirror target roots before writing runtime-mirror temp files and skip rewriting already materialized hardlinks, so cross-version container upgrades no longer crash-loop on read-only image-layer paths while warm mirrors do less churn. Fixes #75108; refs #75069. Thanks @coletebou and @xiaohuaxi. - Auto-reply/group chats: fall back to automatic source delivery when a channel precomputes message-tool-only replies but the `message` tool is unavailable, so Discord/Slack-style group turns do not silently complete without a visible reply. Fixes #74868. Thanks @kagura-agent. - Browser/gateway: share one browser control runtime across the HTTP control server and `browser.request`, and refresh browser profile config from the source snapshot, so CLI status/start honors configured `browser.executablePath`, `headless`, and `noSandbox` instead of falling back to stale auto-detection. Fixes #75087; repairs #73617. Thanks @civiltox and @martingarramon. diff --git a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts index 73f9a7c36ca..6a6c5241eee 100644 --- a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts @@ -18,7 +18,7 @@ vi.mock("../../../plugins/host-hook-state.js", () => hostHookStateMocks); import { forgetPromptBuildDrainCacheForRun, - hasPromptSubmissionContent, + resolvePromptSubmissionSkipReason, resolveAttemptPrependSystemContext, resolvePromptBuildHookResult, } from "./attempt.prompt-helpers.js"; @@ -73,42 +73,64 @@ describe("resolveAttemptPrependSystemContext", () => { }); }); -describe("hasPromptSubmissionContent", () => { - it("rejects empty prompt submissions without history or images", () => { +describe("resolvePromptSubmissionSkipReason", () => { + it("skips empty prompt submissions without history or images", () => { expect( - hasPromptSubmissionContent({ + resolvePromptSubmissionSkipReason({ prompt: " ", messages: [], imageCount: 0, }), - ).toBe(false); + ).toBe("empty_prompt_history_images"); }); - it("allows blank prompt submissions when replay history has content", () => { + it("skips blank visible user prompt submissions even when replay history exists", () => { expect( - hasPromptSubmissionContent({ + resolvePromptSubmissionSkipReason({ prompt: " ", messages: [{ role: "user", content: "previous turn", timestamp: 1 }], imageCount: 0, }), - ).toBe(true); + ).toBe("blank_user_prompt"); }); it("allows text or image prompt submissions", () => { expect( - hasPromptSubmissionContent({ + resolvePromptSubmissionSkipReason({ prompt: "hello", messages: [], imageCount: 0, }), - ).toBe(true); + ).toBeNull(); expect( - hasPromptSubmissionContent({ + resolvePromptSubmissionSkipReason({ prompt: " ", messages: [], imageCount: 1, }), - ).toBe(true); + ).toBeNull(); + }); + + it("allows blank prompt on runtimeOnly turns", () => { + expect( + resolvePromptSubmissionSkipReason({ + prompt: "", + messages: [], + runtimeOnly: true, + imageCount: 0, + }), + ).toBeNull(); + }); + + it("treats undefined runtimeOnly as a visible user submission", () => { + expect( + resolvePromptSubmissionSkipReason({ + prompt: "", + messages: [], + runtimeOnly: undefined, + imageCount: 0, + }), + ).toBe("empty_prompt_history_images"); }); }); diff --git a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts index 5c34a99d013..ef04a02d180 100644 --- a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts +++ b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts @@ -236,12 +236,21 @@ export function shouldWarnOnOrphanedUserRepair( return trigger === "user" || trigger === "manual"; } -export function hasPromptSubmissionContent(params: { +export type PromptSubmissionSkipReason = "blank_user_prompt" | "empty_prompt_history_images"; + +export function resolvePromptSubmissionSkipReason(params: { prompt: string; messages: readonly unknown[]; imageCount: number; -}): boolean { - return params.prompt.trim().length > 0 || params.messages.length > 0 || params.imageCount > 0; + runtimeOnly?: boolean; +}): PromptSubmissionSkipReason | null { + if (params.runtimeOnly) { + return null; + } + if (params.prompt.trim().length > 0 || params.imageCount > 0) { + return null; + } + return params.messages.length > 0 ? "blank_user_prompt" : "empty_prompt_history_images"; } const QUEUED_USER_MESSAGE_MARKER = 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 710a7aae4f7..c0f43a1a752 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 @@ -284,6 +284,44 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(contextCompiled?.data?.systemPrompt).toContain("internal heartbeat event"); }); + it("skips blank visible prompts with replay history before provider submission", async () => { + const sessionPrompt = vi.fn(async () => { + throw new Error("blank prompt should not be submitted"); + }); + + const result = await createContextEngineAttemptRunner({ + contextEngine: createContextEngineBootstrapAndAssemble(), + sessionKey, + tempPaths, + attemptOverrides: { + prompt: " \n\t ", + }, + sessionPrompt, + }); + + expect(sessionPrompt).not.toHaveBeenCalled(); + expect(result.finalPromptText).toBeUndefined(); + expect(result.promptError).toBeFalsy(); + expect(result.messagesSnapshot).toEqual([ + expect.objectContaining({ role: "user", content: "seed" }), + ]); + const trajectoryEvents = ( + await fs.readFile(path.join(tempPaths[0] ?? "", "session.trajectory.jsonl"), "utf8") + ) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as TrajectoryEvent); + expect(trajectoryEvents.some((event) => event.type === "prompt.submitted")).toBe(false); + expect(trajectoryEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "prompt.skipped", + data: expect.objectContaining({ reason: "blank_user_prompt" }), + }), + ]), + ); + }); + it("keeps gateway model runs independent from agent context and session history", async () => { const bootstrap = vi.fn(async () => ({ bootstrapped: true })); const assemble = vi.fn(async ({ messages }: { messages: AgentMessage[] }) => ({ diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index b203e901e14..64503cb1eb4 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -268,7 +268,7 @@ import { resolveAttemptPrependSystemContext, resolvePromptBuildHookResult, resolvePromptModeForSession, - hasPromptSubmissionContent, + resolvePromptSubmissionSkipReason, shouldWarnOnOrphanedUserRepair, shouldInjectHeartbeatPrompt, } from "./attempt.prompt-helpers.js"; @@ -2677,23 +2677,26 @@ export async function runEmbeddedAttempt( transcriptLeafId, }); - if ( - !skipPromptSubmission && - !promptSubmission.runtimeOnly && - !hasPromptSubmissionContent({ - prompt: promptSubmission.prompt, - messages: activeSession.messages, - imageCount: imageResult.images.length, - }) - ) { + const promptSkipReason = skipPromptSubmission + ? null + : resolvePromptSubmissionSkipReason({ + prompt: promptSubmission.prompt, + messages: activeSession.messages, + runtimeOnly: promptSubmission.runtimeOnly, + imageCount: imageResult.images.length, + }); + if (promptSkipReason) { skipPromptSubmission = true; - log.info( - `embedded run prompt skipped: empty prompt/history/images ` + - `runId=${params.runId} sessionId=${params.sessionId} trigger=${params.trigger} ` + - `provider=${params.provider}/${params.modelId}`, - ); + const skipContext = + `runId=${params.runId} sessionId=${params.sessionId} trigger=${params.trigger} ` + + `provider=${params.provider}/${params.modelId}`; + if (promptSkipReason === "blank_user_prompt") { + log.warn(`embedded run prompt skipped: blank user prompt ${skipContext}`); + } else { + log.info(`embedded run prompt skipped: empty prompt/history/images ${skipContext}`); + } trajectoryRecorder?.recordEvent("prompt.skipped", { - reason: "empty_prompt_history_images", + reason: promptSkipReason, prompt: promptSubmission.prompt, messages: activeSession.messages, imagesCount: imageResult.images.length,