Skip to content

fix: recover no-tools wrap-up calls rejected by strict providers (v0.5.1) - #30

Merged
chiruu12 merged 2 commits into
mainfrom
fix/groq-tool-use-failed
May 31, 2026
Merged

fix: recover no-tools wrap-up calls rejected by strict providers (v0.5.1)#30
chiruu12 merged 2 commits into
mainfrom
fix/groq-tool-use-failed

Conversation

@chiruu12

Copy link
Copy Markdown
Owner

Summary

When Agent.run_once ends its tool loop it makes a final wrap-up call with tools=None (src/hive/runtime/agent.py). If the model still emits a tool call on that call -- common on multi-action requests where it isn't "done" -- strict OpenAI-compatible providers (notably Groq) reject it with a tool_use_failed 400:

openai.BadRequestError: 400 - {'error': {'message': 'Tool choice is none, but model called a tool', 'code': 'tool_use_failed', ...}}

OpenAI tolerates this (coerces to text); Groq 400s, and the error reaches the caller as a non-retryable 400 -- failing the whole turn even though the tools that ran during the loop already persisted. (Real repro: a Nudge voice command "make three notes" on Groq saved the notes, but the wrap-up call threw.)

Changes

  • src/hive/models/openai.py -- primary, provider-agnostic fix:
    • _is_tool_use_failed(error) detects the rejection by error code, body["error"]["code"], or a "model called a tool" message -- no "groq" hardcoding.
    • _recover_no_tools(...) does a single bounded retry (through the existing _retry_with_backoff) with a strong text-only system instruction; on a second failure returns clean empty-text instead of raising -- the tools already ran.
    • generate_with_metadata wraps only the create call; the happy path is unchanged. Non-matching errors re-raise.
    • generate_stream factors its accumulation loop into _consume_stream(...), reused for both the initial stream and a streaming recovery retry; falls back to a clean DONE.
  • src/hive/runtime/agent.py -- belt-and-suspenders: run_once appends a "tool budget exhausted, reply in plain text" system message before the final tools=None call.
  • src/hive/models/base.py -- no change needed: _retry_with_backoff already re-raises the 400 unchanged, so the adapter catches the original error.
  • Version -> 0.5.1; CHANGELOG.md, docs/changelog.md, RELEASE_NOTES.md.

Tests

New tests/models/test_tool_use_recovery.py (9 tests): detector unit tests; generate_with_metadata recovers via text-only retry; streaming variant recovers; second-failure falls back to empty text/DONE; unrelated errors still propagate.

Verification

  • New tests: 9 passed · Full suite: 922 passed · ruff + mypy src/: clean · import hive -> 0.5.1
  • End-to-end through the public Agent.run_once API with a fake client mimicking Groq: a "make three notes" turn ran 3 tool rounds (all notes persisted), the model still emitted a tool call on the forced wrap-up (worst case -> 400), and the adapter recovered to clean text. No 400 surfaced.

Acceptance

A multi-action request that makes several tool calls then needs a wrap-up (e.g. "make three notes") completes on Groq without surfacing a 400 -- run_once returns a sensible final text after the tools run.

Copilot AI review requested due to automatic review settings May 31, 2026 11:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@greptile-apps

greptile-apps Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces provider-agnostic recovery for the tool_use_failed 400 that strict providers (notably Groq) return when the model emits a tool call on a no-tools wrap-up request. The OpenAI adapter detects the error by code or message, retries once with a text-only instruction, and falls back to clean empty text rather than surfacing the 400 to the caller.

  • Adapter recovery (openai.py): _is_tool_use_failed detects the rejection; _recover_no_tools retries non-streaming calls with _TEXT_ONLY_NUDGE; generate_stream tracks yielded_text to prevent duplicate output and falls back to a DONE-with-empty-content event on double failure. Recovery is guarded to fire only when tools was not offered.
  • Agent belt-and-suspenders (agent.py): run_once now builds a transient wrap_up_messages list with a user-role nudge before the final no-tools call — the nudge is not written into the logged conversation.
  • Tests (test_tool_use_recovery.py): 9 new tests covering the detector, non-streaming recovery, streaming recovery, mid-stream error propagation, and the guard that surfaces real schema errors unchanged.

Confidence Score: 5/5

The changes are narrowly scoped to the no-tools wrap-up failure path; the happy path is completely unchanged.

Recovery is well-guarded — fires only when tools were not offered and the error matches the known pattern, and tracks yielded_text to prevent streaming corruption. Tests cover all key combinations for both streaming and non-streaming paths.

src/hive/models/openai.py — the bare except in both recovery blocks is worth revisiting.

Important Files Changed

Filename Overview
src/hive/models/openai.py Core recovery logic added: _is_tool_use_failed detector, _recover_no_tools non-streaming retry, generate_stream updated with yielded_text guard and _consume_stream factored out. Bare except in both recovery paths swallows all exceptions, which could hide unexpected errors silently.
src/hive/runtime/agent.py Adds transient wrap_up_messages with a user-role nudge before the final no-tools call; not appended to the logged conversation. Clean implementation.
tests/models/test_tool_use_recovery.py 9 tests covering detector, non-streaming and streaming recovery, mid-stream error propagation, and with-tools propagation. Good coverage.
src/hive/models/base.py No functional changes; _retry_with_backoff treats 400 as NON_RETRYABLE and re-raises, so the adapter receives the original error unmodified.

Sequence Diagram

sequenceDiagram
    participant Agent as Agent.run_once
    participant Adapter as OpenAI adapter
    participant Provider as Strict Provider (Groq)

    Note over Agent: Tool loop exhausted
    Agent->>Agent: Build wrap_up_messages
    Agent->>Adapter: "generate_with_metadata(wrap_up_messages, tools=None)"
    Adapter->>Provider: POST /chat/completions (no tools)
    Provider-->>Adapter: 400 tool_use_failed
    Adapter->>Adapter: _is_tool_use_failed true, tools None
    Adapter->>Adapter: _recover_no_tools(messages)
    Adapter->>Provider: POST /chat/completions (nudge, no tools)
    alt Recovery succeeds
        Provider-->>Adapter: 200 text response
        Adapter-->>Agent: "GenerateResult(message=text)"
    else Recovery also fails
        Provider-->>Adapter: error
        Adapter-->>Agent: "GenerateResult(message=empty)"
    end
    Agent->>Agent: log conversation, return content
Loading

Reviews (3): Last reviewed commit: "fix: address Greptile review on no-tools..." | Re-trigger Greptile

Comment thread src/hive/models/openai.py
Comment thread src/hive/runtime/agent.py Outdated
Comment thread src/hive/models/openai.py
chiruu12 added 2 commits June 1, 2026 00:32
…5.1)

When Agent.run_once ends its tool loop it makes a final wrap-up call with
tools=None. If the model still emits a tool call there -- common on
multi-action requests -- strict OpenAI-compatible providers (Groq) reject it
with a tool_use_failed 400, failing the whole turn even though the tools that
ran during the loop already persisted.

The OpenAI-compatible adapter now detects this rejection provider-agnostically
(by error code/message, not by hardcoding "groq") and recovers with a single
bounded text-only retry, falling back to clean text rather than raising. Covers
both generate_with_metadata and generate_stream. Agent.run_once also nudges the
final wrap-up call toward plain text as a belt-and-suspenders measure.

- src/hive/models/openai.py: _is_tool_use_failed detector, _recover_no_tools,
  _consume_stream refactor + recovery in both generate paths
- src/hive/runtime/agent.py: text-only nudge before the final tools=None call
- tests/models/test_tool_use_recovery.py: detector, both paths, fallback, and
  non-matching-error propagation
- version 0.5.1; CHANGELOG.md, docs/changelog.md, RELEASE_NOTES.md
- openai.py (P1): scope streaming recovery to failures before any text is
  yielded -- track yielded_text and re-raise once content has streamed, so a
  mid-stream error never produces duplicated/corrupted output.
- openai.py (P2): only recover when no tools were offered (`tools or ...`) in
  both generate_with_metadata and generate_stream, so a tool_use_failed on a
  request that did include tools (e.g. malformed schema) surfaces instead of
  being swallowed.
- openai.py: inject the adapter text-only nudge as a user-role message (not a
  mid-thread system message) in _recover_no_tools and stream recovery.
- agent.py (P1): the run_once wrap-up nudge is now a user-role message sent only
  for that call (no longer a mid-thread system message, no longer mutates the
  logged conversation).
- tests: cover tools-offered propagation (both paths) and mid-stream error after
  text propagating without a recovery retry.
@chiruu12
chiruu12 force-pushed the fix/groq-tool-use-failed branch from 1f9fbc4 to eff5232 Compare May 31, 2026 19:03
@chiruu12
chiruu12 merged commit b5485f7 into main May 31, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants