Skip to content

regression on #121 fix: SDK normalizes stripped-tool_call finish_reason to 'stop' — recovery gate too narrow #124

@PowerCreek

Description

@PowerCreek

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions