From 5cc4c252e998ab5018140cdfd8a1ea707f092c72 Mon Sep 17 00:00:00 2001 From: hw0rld Date: Fri, 28 Aug 2026 05:04:41 -0700 Subject: [PATCH] fix(providers): handle empty assistant replies and missing tool results for strict OpenAI endpoints DeepSeek rejects two message shapes that Wallbreaker could produce, crashing engagements with HTTP 400: 1. Empty assistant messages: when the target returns a reasoning-only reply (no content), query_target/continue_target persisted an assistant message with empty content. DeepSeek rejects this with 'Invalid assistant message: content or tool_calls must be set'. Fix: never persist an empty assistant message in the target thread; when a reply is empty, fold the recovered CoT into the message or use a marker. Also skip empty assistant messages in the OpenAI wire serializer as a defense-in-depth guard. 2. Unfinished tool-call rounds on resume: a crashed session can end with an assistant message carrying tool_calls but no recorded tool results. DeepSeek rejects the next request ('An assistant message with tool_calls must be followed by tool messages responding to each tool_call_id'). Fix: the OpenAI wire serializer inserts placeholder tool results for any missing tool_call_id so resumed sessions stay valid. --- wallbreaker/providers/openai_provider.py | 43 ++++++++++++++++++++++++ wallbreaker/tools/target.py | 15 ++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/wallbreaker/providers/openai_provider.py b/wallbreaker/providers/openai_provider.py index fb80f82..a0c2774 100644 --- a/wallbreaker/providers/openai_provider.py +++ b/wallbreaker/providers/openai_provider.py @@ -113,6 +113,42 @@ def _fold_trailing_assistant_prefill(wire: list[dict]) -> list[dict]: return wire +def _complete_missing_tool_results(wire: list[dict]) -> list[dict]: + """DeepSeek requires every assistant ``tool_calls`` entry to be immediately + followed by a ``tool`` response for each ``tool_call_id``. A session resumed from + autosave can end with an unfinished tool-call round (crash before the results were + recorded), which would otherwise 400 with "insufficient tool messages following + tool_calls message". Insert placeholder tool results so the wire stays valid. + """ + out: list[dict] = [] + for i, entry in enumerate(wire): + out.append(entry) + if entry.get("role") != "assistant": + continue + tool_calls = entry.get("tool_calls") or [] + if not tool_calls: + continue + expected = {tc["id"] for tc in tool_calls} + found: set[str] = set() + j = i + 1 + while j < len(wire) and wire[j].get("role") == "tool": + found.add(wire[j].get("tool_call_id")) + j += 1 + missing = expected - found + for mid in sorted(missing): + out.append( + { + "role": "tool", + "tool_call_id": mid, + "content": ( + "[recovered session] tool result not recorded (crash before " + "completion); treat as an error and continue the engagement." + ), + } + ) + return out + + def _messages_to_wire( messages: list[Message], system: str | None, system_mode: str = "default" ) -> list[dict]: @@ -147,6 +183,12 @@ def _messages_to_wire( for b in msg.content if isinstance(b, ToolUseBlock) ] + if not text.strip() and not tool_calls: + # Strict OpenAI-compatible endpoints (DeepSeek) reject assistant + # messages with no content and no tool calls ("content or + # tool_calls must be set"). A reasoning-only / empty target turn + # must not poison the wire request (also covers resumed sessions). + continue entry: dict = {"role": "assistant", "content": text or None} if tool_calls: entry["tool_calls"] = tool_calls @@ -176,6 +218,7 @@ def _messages_to_wire( wire.append({"role": "user", "content": text}) if pending_system: # merge requested but no user turn existed - add one wire.append({"role": "user", "content": pending_system}) + wire = _complete_missing_tool_results(wire) return _fold_trailing_assistant_prefill(wire) diff --git a/wallbreaker/tools/target.py b/wallbreaker/tools/target.py index 3a4f133..2e8a4aa 100644 --- a/wallbreaker/tools/target.py +++ b/wallbreaker/tools/target.py @@ -136,6 +136,11 @@ def _persist_thread(messages, reply): if messages and messages[-1].role == "assistant": prefill = messages[-1].text() return messages[:-1] + [assistant((prefill + body) if body else prefill)] + if not body: + # DeepSeek (and other strict OpenAI-compatible APIs) reject an assistant + # message whose content is empty: "content or tool_calls must be set". + # A reasoning-only target turn must not poison the persisted thread. + return list(messages) return messages + [assistant(body)] @@ -381,7 +386,15 @@ async def _continue_target(args: dict, ctx: ToolContext) -> str: ctx.emit(recover_note) enc_note += f" | {recover_note}" - asst_msg = assistant(reply or "") + body = reply or "" + if not body: + # Never persist an empty assistant message: DeepSeek 400s on + # "content or tool_calls must be set". Fold reasoning in when the model + # only produced thinking, otherwise keep the thread valid with a marker. + body = (cot or reasoning or "").strip() + if not body: + body = "[target returned no output for this turn]" + asst_msg = assistant(body) if details: asst_msg.reasoning_details = details asst_msg.reasoning = cot or reasoning or None