mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
fix: guard blank prompt submissions (#74168)
Fixes #74137.\n\nThanks @yelog.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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[] }) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user