fix: recover no-tools wrap-up calls rejected by strict providers (v0.5.1) - #30
Conversation
Greptile SummaryThis PR introduces provider-agnostic recovery for the
Confidence Score: 5/5The 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
Sequence DiagramsequenceDiagram
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
Reviews (3): Last reviewed commit: "fix: address Greptile review on no-tools..." | Re-trigger Greptile |
…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.
1f9fbc4 to
eff5232
Compare
Summary
When
Agent.run_onceends its tool loop it makes a final wrap-up call withtools=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 atool_use_failed400: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 errorcode,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_metadatawraps only thecreatecall; the happy path is unchanged. Non-matching errors re-raise.generate_streamfactors its accumulation loop into_consume_stream(...), reused for both the initial stream and a streaming recovery retry; falls back to a cleanDONE.src/hive/runtime/agent.py-- belt-and-suspenders:run_onceappends a "tool budget exhausted, reply in plain text" system message before the finaltools=Nonecall.src/hive/models/base.py-- no change needed:_retry_with_backoffalready re-raises the 400 unchanged, so the adapter catches the original error.CHANGELOG.md,docs/changelog.md,RELEASE_NOTES.md.Tests
New
tests/models/test_tool_use_recovery.py(9 tests): detector unit tests;generate_with_metadatarecovers via text-only retry; streaming variant recovers; second-failure falls back to empty text/DONE; unrelated errors still propagate.Verification
ruff+mypy src/: clean ·import hive->0.5.1Agent.run_onceAPI 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_oncereturns a sensible final text after the tools run.