Skip to content

fix(llm): recover empty tool-loop responses instead of returning null#929

Open
leo922oel wants to merge 1 commit into
plastic-labs:mainfrom
leo922oel:fix/dialectic-minimal-empty-recovery
Open

fix(llm): recover empty tool-loop responses instead of returning null#929
leo922oel wants to merge 1 commit into
plastic-labs:mainfrom
leo922oel:fix/dialectic-minimal-empty-recovery

Conversation

@leo922oel

@leo922oel leo922oel commented Jul 23, 2026

Copy link
Copy Markdown

Description

peer.chat(..., reasoning_level="minimal") returned null on broad queries the model answered without a tool call. The tool loop's empty-response retry is gated on iteration < max_tool_iterations - 1, which is 0 < 0 == False for the minimal tier (MAX_TOOL_ITERATIONS=1), so the retry never fired and the tool-less branch returned empty content without reaching the post-loop synthesis call. The empty string then surfaced as null (the chat endpoint maps a falsy answer to None). low and medium were unaffected because they allow >= 2 iterations.

Motivation

Broad minimal queries silently returned null even though the same query answered correctly at low/medium, so callers relying on minimal received no memory and no error signal.

Changes

  • src/llm/tool_loop.py: decouple empty-response recovery from the iteration budget. Keep the nudge-and-loop when iterations remain, otherwise break to the forced synthesis call so a final answer is always produced. This is a general tool-loop correctness fix; minimal is simply where it is guaranteed to surface. The synthesis prompt and log now distinguish empty-recovery from genuine max-iteration exhaustion, and the streaming path is left unchanged (not stream_final).
  • tests/llm/test_dialectic_minimal_empty_recovery.py: deterministic regression test (mocked inner LLM) covering the single-iteration recovery path and the multi-iteration control.

Testing

  • tests/llm/ full suite: 242 passed (new regression test RED to GREEN).
  • ruff check / ruff format / basedpyright clean.

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of empty model responses at the end of the tool loop, ensuring the system recovers reliably even when no in-loop retry is possible.
    • Adds clearer recovery behavior to choose between synthesis-based recovery and retry-based recovery depending on remaining tool iterations.
  • Tests

    • Added new isolation tests covering empty-string and None final responses, including single-iteration forced synthesis and multi-iteration nudge/retry behavior.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

execute_tool_loop now recovers empty responses through forced synthesis when no retry iteration remains. New isolation tests verify synthesis at one iteration and retry behavior with a larger iteration budget.

Changes

Empty response recovery

Layer / File(s) Summary
Recovery control flow
src/llm/tool_loop.py
Tracks exhausted empty responses, exits the loop, and selects a dedicated synthesis prompt.
Recovery behavior tests
tests/llm/test_dialectic_minimal_empty_recovery.py
Verifies synthesis recovery with one iteration and nudge-and-retry recovery with two iterations.

Estimated code review effort: 2 (Simple) | ~10 minutes

Sequence Diagram(s)

sequenceDiagram
  participant execute_tool_loop
  participant honcho_llm_call_inner
  participant synthesis
  execute_tool_loop->>honcho_llm_call_inner: request tool-enabled response
  honcho_llm_call_inner-->>execute_tool_loop: empty tool-less response
  execute_tool_loop->>synthesis: select empty-response recovery prompt
  synthesis-->>execute_tool_loop: recovered final response
Loading

Possibly related issues

  • Issue 928 — Directly covers the minimal single-iteration empty-response behavior addressed here.

Suggested reviewers: akattelu

Poem

I nudge the empty answer, bright,
Then synthesize it right.
One hop left? I won’t despair,
Two hops? I retry there.
— A cheerful rabbit 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main fix: empty tool-loop responses are recovered instead of returning null.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/llm/tool_loop.py`:
- Around line 499-526: Update the empty-response handling in
src/llm/tool_loop.py lines 499-526 to treat None and blank strings as empty,
route every non-retriable empty response to forced synthesis regardless of
empty_response_retries, and increment iteration before breaking so the synthesis
call is counted. Add deterministic coverage in
tests/llm/test_dialectic_minimal_empty_recovery.py lines 61-83 for None and two
consecutive empty responses, asserting synthesis recovery and result.iterations
== 2.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3f86c1f1-8f8a-4632-bcd0-2552cca4f0e5

📥 Commits

Reviewing files that changed from the base of the PR and between 4f9a413 and 0b31359.

📒 Files selected for processing (2)
  • src/llm/tool_loop.py
  • tests/llm/test_dialectic_minimal_empty_recovery.py

Comment thread src/llm/tool_loop.py Outdated
When the model returns an empty response with no tool calls, the tool
loop's empty-response retry only fires when iterations remain
(`iteration < max_tool_iterations - 1`). At the dialectic `minimal` tier
(MAX_TOOL_ITERATIONS=1) that guard is `0 < 0 == False`, so the retry can
never fire and the tool-less branch returns empty content without
reaching the post-loop synthesis call. The empty string then surfaces as
a null dialectic response (the chat endpoint maps a falsy answer to
None), so broad `minimal` queries returned null even though the same
query answered correctly at `low`/`medium`.

Decouple empty-response recovery from the iteration budget: try one
in-loop nudge while a retry and an iteration remain, otherwise break to
the forced synthesis call so a final answer is always produced. This
applies whenever a response cannot be retried, including a repeat empty
after the nudge; a None completion is treated as empty; and the
synthesis call is counted in the reported iteration total. General
tool-loop correctness fix; `minimal` is where it is guaranteed to
surface. The synthesis prompt and log distinguish empty-recovery from
genuine max-iteration exhaustion, and the streaming path is unchanged.

Adds deterministic regression tests covering empty-string and None
recovery at a single iteration, a repeat empty routed to synthesis, and
the in-budget nudge control.
@leo922oel
leo922oel force-pushed the fix/dialectic-minimal-empty-recovery branch from 0b31359 to dd989ab Compare July 23, 2026 11:10

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/llm/tool_loop.py (1)

518-518: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Wrap the nudge text to the configured line limit.

Line 518 exceeds 88 characters including indentation.

Proposed fix
-                                    "Your last response was empty. Provide a concise answer "
+                                    "Your last response was empty. "
+                                    "Provide a concise answer "

As per coding guidelines, “keep lines within 88 characters to match Black-compatible formatting.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/llm/tool_loop.py` at line 518, Wrap the nudge text string in the
tool-loop response handling near the empty-response message so every source
line, including indentation, stays within the configured 88-character limit
while preserving the exact message content.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/llm/tool_loop.py`:
- Line 518: Wrap the nudge text string in the tool-loop response handling near
the empty-response message so every source line, including indentation, stays
within the configured 88-character limit while preserving the exact message
content.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: eab7da91-5459-4206-b9f0-30cfd837ddde

📥 Commits

Reviewing files that changed from the base of the PR and between 0b31359 and dd989ab.

📒 Files selected for processing (2)
  • src/llm/tool_loop.py
  • tests/llm/test_dialectic_minimal_empty_recovery.py

@karaokedjodua

Copy link
Copy Markdown

Finn-loop review of dd989ab

CI: not configured (only CodeRabbit runs; no required lint/test/typecheck checks on this branch)
Mergeability: UNKNOWN (mergeStateStatus=UNKNOWN)

Review

PR #929 fixes a bug where peer.chat(reasoning_level="minimal") returned null on broad queries. The empty-response retry was gated on iteration < max_tool_iterations - 1, which is always false for the minimal tier (MAX_TOOL_ITERATIONS=1). The fix decouples empty-response recovery from the iteration budget — when no in-loop retry is possible, the loop breaks to the forced synthesis call instead of returning empty.

1. Must fix before merge

  • [CI] No required CI checks are configured on this branch. Only CodeRabbit runs. There are no required lint (ruff check), typecheck (basedpyright), or test (pytest) checks blocking merge. The repository's CLAUDE.md documents uv run ruff check src/, uv run basedpyright, and uv run pytest tests/ as required quality gates, but none are enforced via branch protection.

2. Should fix soon

None. The code changes are well-scoped:

  • The empty-response check correctly expands to catch response.content is None (not just empty string), preventing a None pass-through.
  • The empty_recovery_synthesis flag cleanly separates recovery vs. exhaustion in the post-loop synthesis, with distinct prompts and log messages.
  • The streaming path (stream_final) is correctly gated — empty_recovery_synthesis is only set when not stream_final, leaving the empty tail to the stream itself.
  • Tests cover empty string, None, repeat empty, and the in-budget nudge control path with a deterministic _ScriptedLLM mock.

3. Safe to merge

No — CI gate is missing. Automated review of the code itself finds no defects, but the lack of required CI checks means a human must verify that linting, type-checking, and tests pass before merging.

Note

This reviewer's account does not have admin rights to this repository, so labels (needs-human-review) could not be applied. A repo admin should apply the needs-human-review label manually.

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