Skip to content

fix(ai): give the tool loop a way to finish - #80

Merged
noahbclarkson merged 2 commits into
mainfrom
fix/ai-tool-loop-pacing
Sep 8, 2026
Merged

fix(ai): give the tool loop a way to finish#80
noahbclarkson merged 2 commits into
mainfrom
fix/ai-tool-loop-pacing

Conversation

@noahbclarkson

Copy link
Copy Markdown
Owner

Why

The reported symptom was the agent hitting the tool call limit: "The model kept asking for more context and never wrote a message."

MAX_TOOL_ITERATIONS = 4 was never four tool rounds — it was four total round trips with tools offered on every one of them. The model got three rounds of tool results and no round where answering was the only legal move. Three things then made exhaustion the ordinary outcome rather than the pathological one:

  1. The prompt begged for a tool call it shouldn't need. The diff is capped at 40 KB and stamped [diff truncated -- showing 40000/526889 bytes], while get_diff is advertised as "the exact changes made" and returns the same staged diff at a 100 KB cap. Its reply carries the same truncation marker, inviting a second call.
  2. Drafts were thrown away. All three loops checked for tool calls before looking for text, so a model that wrote the message and asked for one more file lost the message — and the loop reported failure with a finished one in hand.
  3. The 4th round's tool calls were executed and discardedgit spawned, files read, budget charged, progress shown in the UI — then dropped with the loop.

Shipped defaults compound it: use_tools and inject_project_context both default on, and every default model is the cheapest tier of its family.

Finishing

  • Cap raised to 8, and the last round withholds tools via tool_choice rather than by dropping the tools field — that is a 400 on Anthropic once the conversation contains tool_use/tool_result blocks. Each provider in its own dialect: "none", {"type": "none"}, and Gemini's toolConfig.functionCallingConfig.mode = "NONE" (confirmed against Google's REST reference).
  • A pacing reminder on the tool results of every round past halfway, escalating on the penultimate round — the last round that can still act on it. Shaped per API: a user turn after the role: "tool" messages; a text block inside the Anthropic tool_result turn (they must travel together, and a second consecutive user turn would break alternation); a text part alongside Gemini's function responses, which keeps the thought-signature alternation intact.
  • Drafts are salvaged instead of failing, and the final round's calls are no longer executed.
  • A 180s generation-wide deadline. The 60s timeout is per request, so nothing bounded the sequence — doubling the cap alone would have taken the worst case from ~12 to ~24 minutes of spinner.

Prompt and tool cost

  • The changed-file list is capped at 8 KB. It was the one prompt input with no bound at all while the diff and project context both had one. ~3,200 staged files put the prompt past DeepSeek's context window before the diff was reached — and this hit the no-tools path too.
  • A repeated call is answered from the first one, so two identical get_diff calls can't spend the budget on two copies of one diff.
  • Tool descriptions say what the model already has, and mark get_file_tree / get_branch_list as rarely useful for describing a change.
  • File-tree listings are capped and respect .gitignore via one git ls-files, instead of four hardcoded names that walked vendor, dist, build and venv in full and built the whole string in memory before the budget trimmed it.

Correctness

  • Only the contract fields of an assistant turn are replayed. The whole response message went back verbatim; DeepSeek is a shipped provider and rejects a request that replays its own reasoning_content.
  • Gemini keeps its text parts in history with their thought signatures — dropping them made the model re-derive its plan every round — and function-call ids are echoed so parallel calls to one tool can be paired.
  • Failed Anthropic tool results carry is_error.
  • The Anthropic cache breakpoint is taken only where it pays: where a later round could read it back, and where the prompt clears a minimum cacheable prefix. A short one-round-trip prompt was paying a 1.25× write surcharge for an entry nothing ever read, and the default claude-haiku-4-5 has a 2048-token minimum that a small commit falls under silently.

UI

  • Thinking… between rounds instead of leaving the last tool's description on screen for the length of the model's turn.
  • A rate-limited request that nothing else is covering now toasts — retrying inside the 5s cooldown clears the "AI failed" marker first, so the status line was the only sign anything happened.
  • #[allow(clippy::too_many_arguments)] replaced with a params struct, per CLAUDE.md.

Testing

cargo clippy --workspace --all-targets -- -D warnings clean; cargo test --workspace 1691 passed, 0 failed. 20 new tests, all on pure helpers so the pacing, the withheld-tool request shapes, the caps and the turn projection are covered without a live HttpClient.

The three provider loops themselves remain unreachable by the existing test style — they need a fake HttpClient, which this PR does not add.

🤖 Generated with Claude Code

The reported symptom was "the model kept asking for more context and never
wrote a message". `MAX_TOOL_ITERATIONS = 4` was never four tool rounds: it was
four total round trips with tools offered on every one, so the model got three
rounds of results and no round where answering was the only legal move.

Three things made exhaustion the ordinary outcome rather than the pathological
one. The prompt caps the diff at 40 KB and stamps a truncation marker on it,
while `get_diff` is advertised as "the exact changes made" and returns the same
staged diff at a 100 KB cap - and its own reply carries the same marker,
inviting a second call. Any prose the model wrote alongside a tool call was
discarded, so a finished message could be thrown away and reported as a
failure. And the last round's tool calls were executed in full - git spawned,
files read, budget charged - then dropped with the loop.

Finishing

- Raise the cap to 8 and withhold tools on the last round through
  `tool_choice` rather than by dropping the `tools` field, which is a 400 on
  Anthropic once the conversation contains tool calls. Each provider gets its
  own dialect: `"none"`, `{"type": "none"}`, and Gemini's
  `toolConfig.functionCallingConfig.mode`.
- Append a pacing reminder to the tool results of every round past halfway,
  escalating on the penultimate round, in the shape each API accepts: a user
  turn after the tool messages, a text block inside the Anthropic tool_result
  turn, a text part alongside Gemini's function responses.
- Salvage a draft written alongside a tool call instead of failing with the
  message in hand, and stop executing the final round's calls.
- Bound a whole generation at 180s. The 60s deadline is per request, so
  nothing bounded the sequence.

Prompt and tool cost

- Cap the changed-file list at 8 KB. It was the one prompt input with no bound
  at all while the diff and project context both had one; a `git add .` over a
  vendored tree could push the prompt past a model's context window on its own.
- Answer a repeated call from the first one rather than re-running it, so two
  identical `get_diff` calls cannot spend the budget on two copies of one diff.
- Tell the model in the tool descriptions what it already has.
- Cap a file-tree listing and skip what the repository actually ignores, rather
  than four hardcoded names that walked vendor, dist, build and venv in full.

Correctness

- Replay only the contract fields of an assistant turn. The whole response
  message went back verbatim, and DeepSeek - a shipped provider - rejects a
  request that replays its own `reasoning_content`.
- Keep Gemini's text parts in history with their thought signatures, and echo
  function-call ids so parallel calls to one tool can be paired.
- Mark a failed Anthropic tool result with `is_error`.
- Take the Anthropic cache breakpoint only where a later round could read it
  back and the prompt clears a minimum cacheable prefix; a short one-round
  prompt was paying a write surcharge for an entry nothing read.

Also: show "Thinking…" between rounds instead of leaving the last tool's
description on screen, toast a rate-limited request that nothing else is
covering, and replace the `too_many_arguments` allow with a params struct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-08T08:52:24.955995Z f59372e PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f59372e4bb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/rgitui_ai/src/lib.rs Outdated
Comment on lines +621 to +622
for iteration in 0..MAX_TOOL_ITERATIONS {
check_deadline(started)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Enforce the remaining generation deadline on each request

When a round begins just before the 180-second deadline, this check passes and send_json can still perform three 60-second attempts plus retry delays. Consequently, a slow or retrying provider can keep the spinner active for roughly three additional minutes, so the new deadline does not actually bound the whole generation as intended. Pass the remaining duration into the request/retry path or race the complete generation against a timer.

Useful? React with 👍 / 👎.

Comment thread crates/rgitui_ai/src/lib.rs Outdated
Comment on lines +760 to +761
if let Some(text) = first_text_block(&content) {
draft = Some(text);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject tool-use narration as a completed draft

When Anthropic returns a text preamble such as “I’ll inspect the changed file” alongside a tool_use block, this records that unfinished narration as a draft. If the model still requests a tool on the final withheld-tools round, the loop breaks and exhausted(draft) reports the preamble as a successful commit message, populating the commit box with non-message text instead of surfacing exhaustion. Text from a turn whose stop_reason is tool_use should not automatically qualify as a completed fallback.

Useful? React with 👍 / 👎.

Both from Codex review of the previous commit.

The generation deadline was checked only between rounds, so a round starting a
second before it could still spend three 60-second attempts plus backoff and
run minutes past. Every request timeout is now clamped to the time actually
left, and a retry delay that would outlast the deadline reports the provider's
status instead of sleeping it out to send a request that cannot answer.

The draft salvage is removed rather than narrowed. A turn that asks for no
tools already returns its text as the message, so text still in hand at
exhaustion arrived beside a tool call — narration like "I'll check that file
first". Treating that as a finished message put a preamble in the commit box
and reported success. Withholding tools on the final round already gives the
model a turn where answering is the only move, so the salvage had no case left
that was not this bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@noahbclarkson
noahbclarkson merged commit 271759b into main Sep 8, 2026
4 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.

1 participant