Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions wallbreaker/providers/openai_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand Down
15 changes: 14 additions & 1 deletion wallbreaker/tools/target.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]


Expand Down Expand Up @@ -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
Expand Down