Symptom
After installing v0.18.3 (PR #122 / #121 fix) on the duplex sandbox container, mistral-shaped tool_calls are STILL rejected as "empty". User-visible recovery message:
Your previous response was empty — the model chose to return no content despite the MCP tools being attached (finish_reason=stop, tool_calls=0)
Devagentic-side cascade-entry log confirms the wire shape:
content_len=0
tool_calls_count=1
finish_reason=tool_calls
But hermes triggers the structural-empty recovery path (PR #69 / #67) — which is gated on finish_reason == "stop".
Root cause
PR #122's fix in ChatCompletionsTransport.normalize_response gated raw-response fallback on finish_reason in {"tool_calls", "function_call"}:
if not tool_calls and finish_reason in {"tool_calls", "function_call"}:
raw_tcs = _raw_tool_calls_from_response(response)
...
But the OpenAI Python SDK's strict Pydantic validation appears to do MORE than just drop the tool_call entry — when it strips the type-less entry, the resulting parsed ChatCompletion's finish_reason normalizes to stop (not preserving the wire-level tool_calls value). Devagentic's log shows the WIRE response said tool_calls; hermes sees stop after SDK parsing.
So the v0.18.3 recovery NEVER FIRED on mistral's shape — the gate excluded the case it was supposed to handle.
The structural-empty recovery (PR #69 / #67) then fires because its conditions all match: finish_reason=stop, tool_calls=[], content="", tools attached, not prior_was_tool. User sees the noisy recovery message.
Fix
Drop the finish_reason gate from PR #122's recovery. Whenever the SDK gives us empty tool_calls but the raw response shape carries tool_calls, recover them — the wire-level evidence is authoritative. Additionally, normalize the recovered response's finish_reason to tool_calls so downstream consumers (conversation_loop.py:3180 tool branch, structural-empty guard) see a consistent state.
if not tool_calls: # no finish_reason gate
raw_tcs = _raw_tool_calls_from_response(response)
if raw_tcs:
recovered: list[ToolCall] = []
for raw_tc in raw_tcs:
tc_norm = _normalize_raw_tool_call(raw_tc)
if tc_norm is not None:
recovered.append(tc_norm)
if recovered:
tool_calls = recovered
finish_reason = "tool_calls" # rewrite for consistency
Acceptance
Composition
Symptom
After installing v0.18.3 (PR #122 / #121 fix) on the duplex sandbox container, mistral-shaped tool_calls are STILL rejected as "empty". User-visible recovery message:
Devagentic-side cascade-entry log confirms the wire shape:
content_len=0tool_calls_count=1finish_reason=tool_callsBut hermes triggers the structural-empty recovery path (PR #69 / #67) — which is gated on
finish_reason == "stop".Root cause
PR #122's fix in
ChatCompletionsTransport.normalize_responsegated raw-response fallback onfinish_reason in {"tool_calls", "function_call"}:But the OpenAI Python SDK's strict Pydantic validation appears to do MORE than just drop the tool_call entry — when it strips the type-less entry, the resulting parsed ChatCompletion's finish_reason normalizes to
stop(not preserving the wire-leveltool_callsvalue). Devagentic's log shows the WIRE response saidtool_calls; hermes seesstopafter SDK parsing.So the v0.18.3 recovery NEVER FIRED on mistral's shape — the gate excluded the case it was supposed to handle.
The structural-empty recovery (PR #69 / #67) then fires because its conditions all match:
finish_reason=stop,tool_calls=[],content="",tools attached,not prior_was_tool. User sees the noisy recovery message.Fix
Drop the finish_reason gate from PR #122's recovery. Whenever the SDK gives us empty tool_calls but the raw response shape carries tool_calls, recover them — the wire-level evidence is authoritative. Additionally, normalize the recovered response's
finish_reasontotool_callsso downstream consumers (conversation_loop.py:3180tool branch, structural-empty guard) see a consistent state.Acceptance
if assistant_message.tool_callsfires → tool actually executes.finish_reason=stopAND raw has tool_calls (the specific case PR fix(tool_calls): recover SDK-dropped mistral-shaped tool_calls (closes #121) #122 missed).Composition