fix: guard blank prompt submissions (#74168)

Fixes #74137.\n\nThanks @yelog.
This commit is contained in:
Logan Ye
2026-04-30 23:48:46 +08:00
committed by GitHub
parent 46888f5afb
commit adc20fed0d
5 changed files with 104 additions and 31 deletions

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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 =

View File

@@ -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[] }) => ({

View File

@@ -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,