Skip to content

fix(studio): gate external tool nudges and preserve retry context - #9125

Open
Biotrioo wants to merge 12 commits into
unslothai:mainfrom
Biotrioo:fix/8907-external-tool-nudge
Open

fix(studio): gate external tool nudges and preserve retry context#9125
Biotrioo wants to merge 12 commits into
unslothai:mainfrom
Biotrioo:fix/8907-external-tool-nudge

Conversation

@Biotrioo

Copy link
Copy Markdown
Contributor

Summary

Complementary follow-up to #8946 / #8907.

#8946 fixes the false-positive classifier that nudges valid clarification turns. While tracing the later CPU-only reproducer in #8907, I found two separate bugs in the external Studio tool loop:

  • the existing nudgeToolCalls setting was not forwarded on the external local-tool path, so that loop could re-prompt independently of the request setting;
  • when a no-tool nudge was triggered after plain assistant prose, the assistant turn was not added to conversation unless a provider-hosted tool had run. The retry therefore saw user -> user instead of user -> assistant -> user and could answer the original request from scratch.

This PR intentionally does not change the current Studio default (nudgeToolCalls: true) or the plan/clarification classifier; #8946 remains the classifier fix for #8907.

Changes

  • thread nudge_tool_calls from the frontend external local-tool request through the route into ToolLoopPolicy;
  • gate external no-tool re-prompts with the existing nudge policy and auto-heal gate;
  • preserve the visible assistant turn before appending the nudge, including reasoning metadata;
  • add focused behavioral and source-wiring regressions.

Validation

  • python -m compileall ...
  • git diff --check
  • python -m pytest studio/backend/tests/test_studio_tool_loop.py studio/backend/tests/test_external_tool_nudge_wiring.py -q --tb=short
    • 34 passed
  • cd studio/frontend && npm ci && npx tsc -b --pretty false
    • clean

Refs #8907.
Complementary to #8946.

@Biotrioo
Biotrioo marked this pull request as ready for review August 17, 2026 19:45
chatgpt-codex-connector[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

@danielhanchen

Copy link
Copy Markdown
Member

Confirmed the external loop in studio/backend/core/inference/studio_tool_loop.py re-prompts without consulting the nudge setting the frontend never sends, and that the stalled assistant turn is dropped unless a hosted tool ran. Will get this reviewed.

@mahiatlinux mahiatlinux self-assigned this Aug 19, 2026
@mahiatlinux
mahiatlinux self-requested a review August 19, 2026 09:46

@mahiatlinux mahiatlinux left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

studio/backend/tests/test_hosted_result_replay.py:546, :575, :939 fail on this branch

nudge_enabled defaults to off (passthrough_healing.py:58, UNSLOTH_TOOL_CALL_NUDGE unset), so the new gate at studio_tool_loop.py:1005 short-circuits for every policy built without the flag. Three tests build theirs inline and never opt in:

tests/test_hosted_result_replay.py   merge base: 27 passed
                                     this branch: 3 failed, 24 passed

test_a_stalled_turn_keeps_its_hosted_result        AssertionError: the stall reprompt never happened
test_a_stalled_continuation_stays_one_assistant_turn  IndexError: list index out of range
test_a_stalled_turn_keeps_its_thought_signature       IndexError: list index out of range

All three pass again with UNSLOTH_TOOL_CALL_NUDGE=1. .github/workflows/studio-backend-ci.yml:240 runs pytest tests/ on any studio/** change with no such variable set, so Backend CI reds on merge. The stated validation covered two files and could not have seen this.

studio/backend/core/inference/studio_tool_loop.py:1004 disables nudging for every ChatGPT subscription user

openai_codex_tool_loop.py:119 hardcodes auto_heal = False because Codex never writes tool calls as text. The new policy.auto_heal is not False condition turns that healer opt-out into a nudge kill switch, and CodexToolPolicy has no nudge_tool_calls field, so the user's Settings toggle cannot reach it either.

With the exact policy and transport shape that file builds:

merge base (auto_heal=False):                        provider requests = 2
this branch (auto_heal=False, nudge_tool_calls=True): provider requests = 1

The same gate covers the post-tool nudge (max_reprompts = _MAX_POST_TOOL_REPROMPTS, :1328), where the user loses the answer. web_search runs, the model stalls with "Now I will summarise the result.":

merge base:  3 turns, visible text "Now I will summarise the result.The capital of France is Paris."
this branch: 2 turns, visible text "Now I will summarise the result."

Text-form healing and the plan-without-action reprompt are unrelated mechanisms: the reprompt fires on prose with no markup to heal. Drop the condition, or add nudge_tool_calls to CodexToolPolicy and forward payload.nudge_tool_calls where the policy is built.

studio/backend/core/inference/studio_tool_loop.py:1005 gives the same request field a third meaning

nudge_tool_calls omitted, same /v1/chat/completions endpoint, three answers:

  • llama_cpp.py:21842 (nudge_tool_calls is None or nudge_tool_calls), on
  • safetensors_agentic.py:1012 and nudge_tool_calls, off
  • studio_tool_loop.py:1005 nudge_enabled(...), whatever UNSLOTH_TOOL_CALL_NUDGE says

Neither local agentic loop imports passthrough_healing, so that variable has never influenced a plan-without-action reprompt anywhere; this is the first place it does. Two consequences. unsloth run --disable-tool-call-nudging (unsloth_cli/commands/studio.py:2316) states "No effect on streaming requests or the server-side agentic loop", and unsloth start (unsloth_cli/commands/start.py:230) scopes it to "a non-streaming passthrough tool call"; both are now false. And unsloth studio, the callback the desktop app runs, never writes that variable, so the process default in the Studio backend is off and the external loop's stall recovery is gone for every caller that omits the field.

policy.nudge_tool_calls is not False, matching llama_cpp.py:21842, gets the Settings toggle honoured without inventing a new default.

studio/backend/core/inference/studio_tool_loop.py:1032 breaks role alternation on an assistant prefill

_continue_final_message (routes/inference.py:308) returns False whenever the payload flag is absent, including for a history that already ends with an assistant turn. The new append is unconditional, so the retry carries two assistant messages in a row:

messages = [user "hi", assistant "Sure, "], model stalls

merge base:  ['user', 'assistant', 'user']
this branch: ['user', 'assistant', 'assistant', 'user']

_append_user_turn at :655 merges into a trailing user turn for exactly this reason, and its docstring gives it: "this conversation is rendered by the provider, and a strict server rejects that." The assistant side needs the same treatment.

studio/backend/core/inference/studio_tool_loop.py:1032 destroys the resumed partial's extra_content

append_assistant_turn replaces conversation[-1] wholesale (chat_template_helpers.py:2414), so every key on the trailing message other than content is dropped. With continue_final_message and a partial carrying a thought signature:

messages = [user "hi",
            assistant "The answer is" + extra_content={"google": {"thought_signature": "SIG-PARTIAL"}}]

merge base:  assistant "The answer is"                          extra_content preserved
this branch: assistant "The answer isI'll search for that now."  extra_content None

The comment added at :1027 says a turn replayed without that field is rejected, and this path is what removes it. Before this change the prose stall appended nothing, so the partial survived. Carry the trailing message's other keys through the merge.

studio/backend/core/inference/studio_tool_loop.py:1023 replays tool markup to the provider

visible_answer = "".join(turn.text) goes into the conversation raw. The tool-call path at :1291 runs the same value through strip_tool_markup(..., final = True, enabled_tool_names = allowed_tool_names) under the comment "Markup never replays". A turn that finished at length re-releases promoted spans into turn.text (:961) and then falls into this branch, because calls is emptied for a truncated turn:

{"role": "assistant",
 "content": "I'll search now. <tool_call>{\"name\": \"web_search\", \"arguments\": {\"query\": \"x\"}}</tool_call>"}

The retry that exists to obtain a structured call now shows the model its own text-form markup as accepted assistant output. Strip the replayed copy the way :1291 does, keeping last_reprompt_text raw so the repeat check is unchanged.

studio/backend/tests/test_external_tool_nudge_wiring.py:20 asserts against the wrong function

src = inspect.getsource(routes_inference.openai_chat_completions)
assert src.count("nudge_tool_calls = payload.nudge_tool_calls") >= 2

openai_chat_completions starts at routes/inference.py:13152. The line this PR adds is at :12797, inside _proxy_to_external_provider, a module-level async def at :12244. It is not in that source. The two matches are the pre-existing local forwards at :13989 and :15508, and the count is 2 at the merge base as well.

Deleting the route change entirely leaves all three tests in the file green. The comment above the assert describes the opposite of what it measures.

and policy.auto_heal is not False has no test at all: removing it changes no result anywhere in the backend suite.

Use inspect.getsource(routes_inference._proxy_to_external_provider), or drive the route and observe the policy.

studio/backend/tests/test_studio_tool_loop.py:732 depends on an ambient environment variable

_NUDGE_DEFAULT is read at import and nothing under studio/backend/tests/ pins it.

UNSLOTH_TOOL_CALL_NUDGE=1 pytest tests/test_studio_tool_loop.py
  1 failed, 30 passed
  test_a_stalled_model_is_not_nudged_by_default  AssertionError: assert 2 == 1

The same gate also removed the classifier's coverage in this file. Mutating is_short_intent_without_action to return True:

merge base:  1 failed  (test_a_finished_answer_is_not_nudged, assert 4 == 1)
this branch: 31 passed (mutant survives)

test_a_finished_answer_is_not_nudged at :768 calls _run(transport) with no flag, so the gate short-circuits before the classifier ever runs. Add nudge_tool_calls = True there, and pin _NUDGE_DEFAULT in the default test rather than reading the process environment.

studio/frontend/src/features/chat/chat-settings-sheet.tsx:1800 describes a different feature

When a tool call cannot be repaired, re-ask the model once so the intended tool still runs.

Neither half holds for the path this PR puts behind the toggle. The trigger is prose with no tool call to repair, and the cap is MAX_ACT_REPROMPTS = 3 (tool_call_parser.py:209), not once. studio/backend/models/inference.py:1560 still advertises the field as "Opt-in, non-streaming client-tool passthrough only", which is what generated OpenAPI documentation hands external callers.

studio/backend/tests/test_studio_tool_loop.py:734 attributes the wrong fix to #8907

"""Issue #8907: ordinary prose must not trigger a hidden retry by default."""

The test then asserts that "I'll search for that now." must not be nudged. #8946 is merged (657aea945) and deliberately keeps that string nudgeable; what it fixed was the clarification turn "Let me know what you're after and I'll dig in.". #8907's own log line, model responded without calling tools, is emitted only by llama_cpp.py:21855, the local GGUF loop, which this PR does not touch.

657aea945 is not an ancestor of this branch. The gate has never run against a tree containing the classifier fix it is layered on top of.

studio/backend/core/inference/studio_tool_loop.py:1019 is dead

is_short_intent_without_action requires 0 < len(text.strip()) (tool_call_parser.py:216), so visible_answer is always non-empty when line 1019 is reached and stalled_content is always truthy. The if stalled_hosted: it replaced was meaningful; this guard and its comment never fire.

The summary describes the previous behaviour incorrectly

The retry therefore saw user -> user

It did not. _append_user_turn merges into a trailing user turn, so at the merge base the model's stall text was dropped and the nudge was concatenated onto the user's own prompt:

[{"role": "user",
  "content": "hi\n\nYou have access to enabled tools. If a tool is needed ..."}]

Two stalls compound it, rewriting message one twice and invalidating the prefix cache. The :1014 hunk is the right fix for that, and it is worth describing accurately.

@Imagineer99

Copy link
Copy Markdown
Collaborator

@codex review

@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: 80d01cb0cf

ℹ️ 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 on lines +2413 to +2415
merged_msg = {**conversation[-1], **assistant_msg}
merged_msg["content"] = f"{prev_text}{assistant_msg['content']}"
conversation[-1] = merged_msg

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the merged message's object identity

When continue_final_message is enabled, this assigns a new dictionary to conversation[-1] while callers retain and subsequently mutate assistant_msg. Both the GGUF loop (llama_cpp.py) and safetensors loop append additional tool calls to that original object after the first call, so a continued turn containing multiple tool calls records only the first call in the conversation while appending results for all of them; the next provider request then contains orphan tool results and can be rejected. Merge the prior metadata into assistant_msg itself, or otherwise ensure later mutations update the stored conversation entry.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not reproducible on 80d01cb: the merged dict and assistant_msg share the same tool_calls list, and both GGUF and safetensors append to that list in place. A two-call probe leaves both calls in conversation[-1], so no orphan results are created.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: 80d01cb0cf

ℹ️ 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".

# Conflicts:
#	studio/backend/models/inference.py
#	studio/backend/tests/test_external_confirm_gate_and_saved_keys.py
#	studio/backend/tests/test_hosted_result_replay.py
#	studio/frontend/src/features/chat/api/chat-adapter.ts
append_assistant_turn dropping a resumed turn's extra_content is independent of
tool-call nudging: six of its eight call sites are tool-result replay, and the
merge path is reached by the continue-a-partial-response feature. It is now
unslothai#9684, so it is dropped here rather than reviewed twice.
The nudge decided on the raw stream but replayed a markup-stripped copy, so the
two could disagree. A turn whose whole visible content is an unpromotable call
block still reads as a stall to the classifier, since an intent phrase inside
the arguments is enough, while stripping leaves nothing to replay. The assistant
turn was then skipped and the nudge merged into the user's own message, which is
the failure this branch exists to prevent, on the one transport class where it
is easiest to reach: heals_text_tool_calls = False, i.e. Codex.

Strip first, then classify and replay the same text. A stall with nothing left
to continue from is no longer a stall. This also stops unparsed markup inflating
the length past REPROMPT_MAX_CHARS and defeating the repeat guard through markup
churn alone, and it matches the local loops, which classify prepared text via
_reprompt_intent_text.
@danielhanchen

Copy link
Copy Markdown
Member

Pushed to this branch to unblock it. Summary of what changed and why, so nothing here is a surprise.

Why it was conflicting

#9126 landed first and included byte-identical copies of several hunks from this PR: the nudge_enabled(policy.nudge_tool_calls) gate line, the ToolLoopPolicy and CodexToolPolicy fields, both routes/inference.py forwards, and the chat-adapter.ts line. I merged main in and resolved those in favour of what is already on main. Two more files were pre-commit bot reformatting of unrelated code (test_lan_access_settings.py, and the two under repo tests/studio/); those are gone with the merge.

Your unique assertion on the Codex policy is kept on top of main's version:

assert entered["policy"].nudge_tool_calls is False
assert entered["policy"].auto_heal is False

Split out to its own PR

The append_assistant_turn fix is now #9684. Six of that function's eight call sites are tool-result replay, and the merge path is reached by the continue-a-partial-response feature from #8064, so it is live independently of nudging and should not wait on this review. It also affects anthropic and openai_codex_reasoning, not just google.

One more fix on top

The strip introduced a mismatch: the classifier read the raw stream while the replay used the stripped copy. A turn whose whole visible content is an unpromotable call block classifies as a stall (an intent phrase inside the arguments is enough) but strips to "", so the assistant turn was skipped and the nudge merged into the user's message again. Reproduced on both main and on this branch before the change, easiest to hit on heals_text_tool_calls = False, which is the Codex shape.

Fixed by stripping first and then classifying and replaying the same text, which is also what the local loops do via _reprompt_intent_text. New guard: test_a_markup_only_stall_is_not_nudged.

Measurement worth having in the record

I A/B'd the two conversation shapes on a real model (Qwen3-0.6B via llama-server, 108 generations per arm, same prompts and seeds, replaying turns where the nudge had produced a tool call):

BROKEN shape (user -> user)               tool calls: 85/108  (79%)
FIXED  shape (user -> assistant -> user)  tool calls: 39/108  (36%)

The broken shape extracts more tool calls, not fewer. It works by hiding the model's own previous answer from it, so the model does not dig in on what it already said. That is the same amnesia that makes it re-answer from scratch and stack up to four answers into one message, so the compliance and the bug are one mechanism and cannot be separated.

Caveat on that number: the replayed turns are 75% confident hallucinated answers, so what it measures is anchoring. For a genuine plan-shaped stall, replaying the assistant turn is the whole point, since the nudge says "complete the action you described". I could not test that case, having found no plan-shaped stall in 924 captured turns.

So this PR should be read as a correctness fix (the conversation sent to the provider stops being false, the user's own message stops being rewritten, the prefix cache survives) and not as a recovery improvement. I have not changed the PR description; worth updating it on that point.

Verification

818 passed   external loop, wiring, confirm gate, hosted replay, hosted passthrough,
             GGUF loop, safetensors loop, passthrough healing, edge cases,
             refusal gates, control-frame spoofing
ruff check and scripts/run_ruff_format.py --check: clean

Guards run against unfixed main: test_a_stalled_model_is_nudged_to_act, test_truncated_tool_markup_is_not_replayed_during_a_nudge and test_a_markup_only_stall_is_not_nudged all fail there and pass here, with 32 others passing on both.

Thanks for finding this one. The user -> user replay was not obvious from reading the diff of #8665, and the old assertion second[-1]["role"] == "user" passed happily on the broken conversation.

danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Aug 25, 2026
chatgpt-codex-connector[bot]

This comment was marked as resolved.

The name was bound twice in one scope, so the later copy won silently and the
first tested nothing. The surviving copy pins _NUDGE_DEFAULT rather than reading
the ambient one, which matters because both CLI launchers export
UNSLOTH_TOOL_CALL_NUDGE=1: under that env the deleted copy failed.
danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Aug 25, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 25, 2026
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@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: a916f6a6ab

ℹ️ 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 on lines +1053 to +1056
replayable_answer = leading_whitespace + strip_tool_markup(
visible_answer,
final = True,
enabled_tool_names = allowed_tool_names,

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 Preserve reasoning-only intent when classifying stalls

For external models that emit a reasoning-only stall using Magistral-style [THINK]...[/THINK] markup, strip_tool_markup(..., final=True) removes the entire block, so replayable_answer is empty and is_short_intent_without_action never triggers the enabled nudge. This differs from the referenced _reprompt_intent_text, which deliberately falls back to reasoning when there is no visible answer, and causes these models to stop after planning to use a tool instead of receiving the retry; classify with the reasoning-aware fallback while keeping the separately stripped text for replay.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Confirmed and fixed. My first probe made this look like a non-issue, because the string I tried classified False before stripping as well. Shorter phrasings do diverge:

raw    stripped   text
True   False      '[THINK]I will search now.[/THINK]'
True   False      '[THINK]Let me look that up.[/THINK]'
True   True       '<think>I will search now.</think>'

So the nudge really was being dropped for reasoning-only stalls that main still nudges.

I matched _reprompt_intent_text rather than only classifying differently: it classifies and replays the same string, and for a reasoning-only stall that string is the think block itself. Classifying the reasoning while replaying the stripped text would have put back the empty-replay case this PR exists to fix.

Ordering carries the correctness here. The fallback runs after strip_tool_markup, so a turn that is only an unpromotable call block has no think span, stays empty, and is still not nudged:

reasoning-only stall     nudge=True   replay='[THINK]I will search now.[/THINK]'
reasoning + answer       nudge=True   replay='I will search now.'
markup-only              nudge=False  replay=''
real answer              nudge=False

Both cases are now pinned by tests. The reasoning-only test fails without the change and passes with it (1 failed, 2 passed then 3 passed); the suite is 54 passed.

Fixed in 0abe73b.

A reasoning-only stall keeps its whole promise inside the think block, so
stripping tool markup empties it and the nudge never fired, while the local
loops still nudge via _reprompt_intent_text. The fallback runs after stripping,
so a turn that is only an unpromotable call block still has no think span and
is still not nudged, and the replayed turn is the text that was classified.
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 0abe73b360

ℹ️ 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".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants