fix(tool_calls): recover SDK-dropped mistral-shaped tool_calls (closes #121)#122
Merged
Conversation
…#121) OpenAI-spec ``tool_call.type`` is required with exactly one valid value (``"function"``). Mistral-large (and likely others) emit tool_calls without the ``type`` field. The OpenAI Python SDK's strict Pydantic validation drops these entries from ``response.choices[0].message.tool_calls`` — hermes then sees an empty list + ``finish_reason=tool_calls`` and falls into PR #108's synthetic-recovery path, where the tool the model wanted to call is lost entirely. ## Fix Add raw-response fallback in ``ChatCompletionsTransport.normalize_response``: - When the SDK-parsed ``msg.tool_calls`` is empty AND ``finish_reason in {"tool_calls", "function_call"}``, inspect the raw response via ``response.model_dump()`` (Pydantic v2), attribute walk, or dict shape. - Iterate the raw tool_calls list, default missing ``type`` to ``"function"`` per spec semantics (the field has only ONE valid value), and synthesize ``ToolCall`` entries. - ``ToolCall``'s existing ``type`` property already returns ``"function"`` unconditionally, so downstream consumers see the recovered call as identical to a normally-parsed one. Strictly additive: only fires when (a) SDK gave us nothing AND (b) the finish_reason signals tools. The SDK-happy-path is untouched. ## Implementation detail Three helpers in ``chat_completions.py``: - ``_DEFAULT_TOOL_CALL_TYPE = "function"`` constant - ``_raw_tool_calls_from_response(response)`` — extracts the raw tool_calls list via model_dump / dict; fail-soft on any errors - ``_normalize_raw_tool_call(tc_raw)`` — builds a ``ToolCall`` from a raw dict entry; returns ``None`` for unrecoverable entries (no function.name); re-serializes dict-arguments back to JSON string per OpenAI contract ## Tests - 17 new tests in tests/agent/test_tool_call_type_default.py: * 6 ``_normalize_raw_tool_call`` tests: with-type, without-type- defaults-function, no-function-block, no-name, not-dict, dict-arguments-reserialized, empty-arguments * 6 ``_raw_tool_calls_from_response`` tests: model_dump path, dict path, no-tool_calls, no-choices, model_dump-raises, non-dict-entries-filtered * 3 end-to-end normalize_response tests: mistral-shape recovery, no-fallback-on-stop-finish_reason, no-double-up-when-sdk-parsed * 2 source-level patch-landed checks - 76 total green across affected suites — no regression on the SDK-happy path or the #67/#99/#108 recovery families. ## Composition Same family of bugs as #99 / #108 / #111 — response handler being over-strict on the OpenAI spec. This is the next-uncovered case (tool_calls present in wire response but stripped by SDK before reaching us). Devagentic#NN (the companion devagentic-side normalization at the OAI-shim boundary) becomes redundant defense after this lands — still harmless to keep.
PowerCreek
added a commit
that referenced
this pull request
May 27, 2026
PowerCreek
added a commit
that referenced
this pull request
May 27, 2026
…#124) (#125) PR #122 / #121 added raw-response fallback for mistral-shaped tool_calls (type field absent → SDK strips entry → empty ``msg.tool_calls``). The fallback was gated on ``finish_reason in {"tool_calls", "function_call"}`` — assuming the SDK preserved finish_reason while dropping the tool_call entry. Field-tested on duplex sandbox container: the SDK actually normalizes finish_reason to ``stop`` when it strips the type-less tool_call. The recovery NEVER FIRED on the actual mistral shape; the structural-empty recovery (#69 / #67) fired instead and surfaced the noisy "Your previous response was empty" message. ## Fix Drop the finish_reason gate. Whenever the SDK gives empty ``msg.tool_calls`` but the raw response shape carries tool_calls, recover them — wire-level evidence is authoritative. Additionally, rewrite the normalized ``finish_reason`` to ``"tool_calls"`` so downstream consumers (``conversation_loop.py:3180`` tool branch, the structural-empty + finish_reason-tools guards) see a consistent state. ## Tests - Updated 1 test in ``tests/agent/test_tool_call_type_default.py``: ``test_normalize_recovers_when_sdk_normalized_finish_reason_to_stop`` (previously asserted recovery DIDN'T fire on stop, now asserts it DOES — matches field-observed SDK behavior + the bug symptom). Also asserts finish_reason gets rewritten to ``tool_calls`` for downstream consistency. - 49 total green across affected suites — no regression on the SDK-happy path or the #67/#99/#108/#118 recovery families. ## Composition - #121 / PR #122 — original fix, gated finish_reason too narrowly - **this PR** — drops the gate; recovers whenever raw has tool_calls
This was referenced May 27, 2026
Closed
PowerCreek
added a commit
that referenced
this pull request
May 28, 2026
…143) (#145) T3 of the #143 thin-client refactor scope. When the active provider is ``devagentic-local``, augment ``disabled_toolsets`` with ``"clarify"`` before the ``get_tool_definitions`` call. ## Rationale Per devagentic#203 §1.3 + the #143 scope: devagentic-side intent classifier knows when clarification is actually needed and can surface it via an OpenAI-shaped assistant message. Hermes' modal TUI clarify-tool was a layered opinion fighting devagentic-side classification — both rotation's debug evidence + sandbox UX showed the dual-source as confusing (clarify modal popped even with --yolo, ignoring devagentic-side intent signals). This is the smallest of the T1-T3 sequence and the cleanest revert path — purely a tool-registry adjustment for one provider. ## Behavior | Setting | Before | After | |---|---|---| | provider=devagentic-local, no --enable-toolset | clarify enabled | clarify implicitly disabled | | provider=devagentic-local, --enable-toolset clarify | clarify enabled | clarify enabled (explicit override) | | provider=other | unchanged | unchanged | A boot-line print informs operators of the implicit disable + how to re-enable for legacy workflows. Composes naturally with the existing ``HERMES_TOOLS_SUBSET`` narrowing (#75/#87) — disable happens first, then subset narrows further if set. ## Tests - 4 source-level tests in ``tests/agent/test_t3_clarify_default_out.py``: patch-landed, explicit-enable-overrides-implicit-disable, re-enable hint visible in print message, strict-equality on provider name (no prefix/alias matching to avoid surprise on related providers). - 21 total green across affected suites (T3 + diag-env-gate). ## Composition Per #143 sequencing (T3 → T1 → T2-gated → T2-default-flip): - This PR: T3 (clarify default-out) - Next: T1 (HERMES_DEFER_PERSONA default-flip for devagentic-local) - Then: T2 (empty-content recovery removal, env-gated then default) - Later: T4-T6 (tool list / iteration cap / summary fallback) ## Preserved through the refactor - PR #119 (cascade_exhausted short-circuit) — hermes correctly deferring to devagentic; NOT recovery - PR #122/#125 (raw tool_calls fallback) — pre-recovery wire parsing; belt-and-suspenders against future streaming-chunker regressions - PR #131/#136/#138/#141 diagnostics — env-gated via HERMES_DIAG_RAW_CAPTURE; no-op when off
PowerCreek
added a commit
that referenced
this pull request
May 28, 2026
…MPTY_RECOVERY (refs #143) (#147) T2 of the #143 thin-client refactor scope. Devagentic-side cascade (NousResearch#324) + runaway detector (NousResearch#345-348) + exec-terminus (NousResearch#349-354) now cover the empty-content recovery layer with full intent / role / dispatch-trace context. Hermes-side recovery layered on top caused 3×4 dispatch stacking (#118) + mode confusion + invisible swallow points (#133 debug funnel). This patch default-flips: the four hermes-side recovery paths are short-circuited unless ``HERMES_LEGACY_EMPTY_RECOVERY`` is set to a truthy value. Legacy users opt-in to keep the pre-T2 behavior. ## Gated paths (all skipped when env unset) | Path | Source | Loc | |---|---|---| | ``_finish_wants_tools`` synthetic recovery | PR #108 / #99 | line ~3618 | | ``_post_tool_empty_retried`` nudge | pre-existing NousResearch#9400-class | line ~3749 | | ``_structural_empty`` synthetic recovery | PR #69 / #67 | line ~3880 | | 3-retry empty-content loop | pre-#67 | line ~3927 | When env unset (default), empty responses fall through to either the fallback-chain provider switch (if configured) or the clean ``(empty)`` terminal with ``_empty_terminal_sentinel=True``. ## Preserved through T2 (verified by test_t2_legacy_empty_recovery_gate.py) - **PR #119 cascade_exhausted short-circuit** — hermes correctly deferring to devagentic's sentinel; NOT recovery. Source-level test asserts the ``if _cascade_err:`` block is NOT prefixed by ``_legacy_recovery_on``. - **PR #122/#125 raw tool_calls fallback** — pre-recovery wire parsing in transports/chat_completions.py. Source-level test asserts the helper name doesn't appear in conversation_loop (lives elsewhere; untouched). - **PR #131/#136/#138/#141 diagnostics** — env-gated via ``HERMES_DIAG_RAW_CAPTURE`` (#140), independent of this env. ## Operator deploy Default behavior changes: empty responses surface cleanly (no synthetic re-prompt). To preserve pre-T2 behavior: ```bash export HERMES_LEGACY_EMPTY_RECOVERY=1 ``` The legacy escape hatch is intended as a temporary safety net while operators validate the thin-client architecture. Once the devagentic-side cascade is universally deployed + observed to cover all empty-content cases, the legacy gate can be removed in a follow-up (the env var stays as a no-op for backward compat). ## Tests - 23 new tests in ``tests/agent/test_t2_legacy_empty_recovery_gate.py``: resolver default-false / empty-false / 6 truthy / 6 falsy-or- unknown; source-level gate assertions for each of the four recovery branches; preserved-path assertions for cascade_exhausted + raw tool_calls fallback location; default-off + opt-in resolver round-trip. - 163 total green across affected suites (T2 + existing empty- terminal mirror tests + finish_reason_tools_recovery + cascade_exhausted + internal_marker_stripping + tool_call_type_default + tool_use_enforcement + T3 + T1 persona + doctor persona probe). ## Composition Per #143 sequencing: - T3 / PR #145 (clarify default-out) — merged ✓ - T1 / PR #146 (persona default-flip) — merged ✓ - **T2 / this PR (empty-content recovery removal, env-gated)** - Later: T4-T6 (tool list / iteration cap / summary fallback) The legacy escape hatch design choice (vs. full removal) lets operators roll back per-deployment if a previously-recovered edge case surfaces in the field. Diagnostic from #140 (HERMES_DIAG_RAW_CAPTURE) remains the observability tool to spot any uncovered case.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #121. Same bug family as #99/#108/#111 — response handler over-strict on OpenAI spec. When mistral emits tool_call WITHOUT the required type field, the OpenAI SDK's strict validation drops the entry; hermes sees empty tool_calls + finish_reason=tool_calls and the actual tool is lost. Fix: in normalize_response, fall back to raw response (model_dump/dict) when the SDK-parsed list is empty + finish_reason signals tools; default missing type to "function". 17 new tests, 76 total green.