fix(agents): retry empty compatible turns

This commit is contained in:
Peter Steinberger
2026-04-27 11:41:31 +01:00
parent edb3e84898
commit fd9d32f022
8 changed files with 116 additions and 2 deletions

View File

@@ -26,7 +26,7 @@ Docs: https://docs.openclaw.ai
- Channels/setup: treat bundled channel plugins as already bundled during `channels add` and onboarding, enabling them without writing redundant `plugins.load.paths` entries or path install records. Fixes #72740. Thanks @iCodePoet.
- WhatsApp: honor gateway `HTTPS_PROXY` / `HTTP_PROXY` env vars for QR-login WebSocket connections, while respecting `NO_PROXY`, so proxied networks no longer fall back to direct `mmg.whatsapp.net` connections that time out with 408. Fixes #72547; supersedes #72692. Thanks @mebusw and @SymbolStar.
- Bonjour: default mDNS advertisements to the system hostname when it is DNS-safe, avoiding `openclaw.local` probing conflicts and Gateway restart loops on hosts such as `Lobster` or `ubuntu`. Fixes #72355 and #72689; supersedes #72694. Thanks @mscheuerlein-bot, @gcusms, @moyuwuhen601, @pavan987, @zml-0912, @hhq365, and @SymbolStar.
- Agents/OpenAI-compatible: retry replay-safe empty `stop` turns once for `openai-completions` endpoints, so transient empty local backend responses no longer surface as “Agent couldn't generate a response” when a continuation succeeds. Fixes #72751. Thanks @moooV252.
- Agents/OpenAI-compatible: retry replay-safe empty `stop` turns once for `openai-completions` endpoints, so transient empty local backend responses no longer surface as “Agent couldn't generate a response” when a continuation succeeds, and restore `openclaw agent --model` for one-shot CLI runs. Fixes #72751. Thanks @moooV252.
- Git hooks: skip ignored staged paths when formatting and restaging pre-commit files, so merge commits no longer abort when `.gitignore` newly ignores staged merged content. Fixes #72744. Thanks @100yenadmin.
- Memory-core/dreaming: add a supported `dreaming.model` knob for Dream Diary narrative subagents, wired through phase config and the existing plugin subagent model-override trust gate. Refs #65963. Thanks @esqandil and @mjamiv.
- Memory-core/dreaming: treat request-scoped narrative fallback as expected, skip session cleanup when no subagent run was created, and remove duplicate phase-level cleanup so fallback no longer emits warning noise. Fixes #67152. Thanks @jsompis.

View File

@@ -26,6 +26,7 @@ Related:
- `-t, --to <dest>`: recipient used to derive the session key
- `--session-id <id>`: explicit session id
- `--agent <id>`: agent id; overrides routing bindings
- `--model <id>`: model override for this run (`provider/model` or model id)
- `--thinking <level>`: agent thinking level (`off`, `minimal`, `low`, `medium`, `high`, plus provider-supported custom levels such as `xhigh`, `adaptive`, or `max`)
- `--verbose <on|off>`: persist verbose level for the session
- `--channel <channel>`: delivery channel; omit to use the main session channel
@@ -42,6 +43,7 @@ Related:
```bash
openclaw agent --to +15555550123 --message "status update" --deliver
openclaw agent --agent ops --message "Summarize logs"
openclaw agent --agent ops --model openai/gpt-5.4 --message "Summarize logs"
openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium
openclaw agent --to +15555550123 --message "Trace logs" --verbose on --json
openclaw agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"

View File

@@ -642,6 +642,62 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
expect(mockedLog.warn).toHaveBeenCalledWith(expect.stringContaining("empty response detected"));
});
it("retries empty openai-compatible stop turns even when the backend reports output tokens", async () => {
mockedClassifyFailoverReason.mockReturnValue(null);
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({
assistantTexts: [],
lastAssistant: {
role: "assistant",
api: "openai-completions",
stopReason: "stop",
provider: "llamacpp",
model: "qwen3.6-27b",
content: [],
usage: {
input: 512,
output: 103,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 615,
},
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
}),
);
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({
assistantTexts: ["Visible local answer."],
lastAssistant: {
role: "assistant",
api: "openai-completions",
stopReason: "stop",
provider: "llamacpp",
model: "qwen3.6-27b",
content: [{ type: "text", text: "Visible local answer." }],
usage: {
input: 640,
output: 5,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 645,
},
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
}),
);
await runEmbeddedPiAgent({
...overflowBaseRunParams,
provider: "llamacpp",
model: "qwen3.6-27b",
runId: "run-empty-openai-compatible-stop-continuation",
});
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
const secondCall = mockedRunEmbeddedAttempt.mock.calls[1]?.[0] as { prompt?: string };
expect(secondCall.prompt).toContain(EMPTY_RESPONSE_RETRY_INSTRUCTION);
expect(mockedLog.warn).toHaveBeenCalledWith(expect.stringContaining("empty response detected"));
});
it("surfaces an error after exhausting empty-response retries", async () => {
mockedClassifyFailoverReason.mockReturnValue(null);
mockedRunEmbeddedAttempt.mockResolvedValue(
@@ -1426,6 +1482,30 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
expect(retryInstruction).toBeNull();
});
it("detects empty openai-compatible stop turns with non-zero output usage", () => {
const retryInstruction = resolveEmptyResponseRetryInstruction({
provider: "llamacpp",
modelId: "qwen3.6-27b",
modelApi: "openai-completions",
payloadCount: 0,
aborted: false,
timedOut: false,
attempt: makeAttemptResult({
assistantTexts: [],
lastAssistant: {
role: "assistant",
stopReason: "stop",
provider: "llamacpp",
model: "qwen3.6-27b",
content: [],
usage: { input: 512, output: 103, totalTokens: 615 },
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
}),
});
expect(retryInstruction).toBe(EMPTY_RESPONSE_RETRY_INSTRUCTION);
});
it("detects generic empty GPT turns without visible text", () => {
const retryInstruction = resolveEmptyResponseRetryInstruction({
provider: "openai",

View File

@@ -615,7 +615,7 @@ function shouldApplyNonVisibleTurnRetryGuard(params: {
if (shouldApplyPlanningOnlyRetryGuard(params)) {
return true;
}
if (params.modelApi === "openai-completions") {
if (normalizeLowercaseStringOrEmpty(params.modelApi ?? "") === "openai-completions") {
return true;
}
// Non-visible final turns are narrower than planning-only turns: there is no

View File

@@ -109,6 +109,20 @@ describe("registerAgentCommands", () => {
);
});
it("accepts a model override for one-shot agent runs", async () => {
await runCli(["agent", "--message", "hi", "--agent", "ops", "--model", "openai/gpt-5.4"]);
expect(agentCliCommandMock).toHaveBeenCalledWith(
expect.objectContaining({
message: "hi",
agent: "ops",
model: "openai/gpt-5.4",
}),
runtime,
{ deps: true },
);
});
it("runs agents add and computes hasFlags based on explicit options", async () => {
await runCli(["agents", "add", "alpha"]);
expect(agentsAddCommandMock).toHaveBeenNthCalledWith(

View File

@@ -28,6 +28,7 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
.option("-t, --to <number>", "Recipient number in E.164 used to derive the session key")
.option("--session-id <id>", "Use an explicit session id")
.option("--agent <id>", "Agent id (overrides routing bindings)")
.option("--model <id>", "Model override for this run (provider/model or model id)")
.option(
"--thinking <level>",
"Thinking level: off | minimal | low | medium | high | xhigh | adaptive | max where supported",

View File

@@ -127,6 +127,21 @@ describe("agentCliCommand", () => {
});
});
it("passes model overrides through gateway requests", async () => {
await withTempStore(async () => {
mockGatewaySuccessReply();
await agentCliCommand({ message: "hi", to: "+1555", model: "ollama/qwen3.5:9b" }, runtime);
expect(callGateway).toHaveBeenCalledTimes(1);
expect(callGateway.mock.calls[0]?.[0]).toMatchObject({
params: {
model: "ollama/qwen3.5:9b",
},
});
});
});
it("routes diagnostics to stderr before JSON gateway execution", async () => {
await withTempStore(async () => {
const response = {

View File

@@ -36,6 +36,7 @@ const NO_GATEWAY_TIMEOUT_MS = 2_147_000_000;
export type AgentCliOpts = {
message: string;
agent?: string;
model?: string;
to?: string;
sessionId?: string;
thinking?: string;
@@ -140,6 +141,7 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
params: {
message: body,
agentId,
model: opts.model,
to: opts.to,
replyTo: opts.replyTo,
sessionId: opts.sessionId,