Skip to content

Commit 0abe73b

Browse files
committed
Fall back to the reasoning block when a stall strips empty
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.
1 parent a916f6a commit 0abe73b

2 files changed

Lines changed: 68 additions & 0 deletions

File tree

studio/backend/core/inference/studio_tool_loop.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
reprompt_to_act_message,
6565
strip_tool_markup,
6666
)
67+
from core.tool_healing import _think_spans_outside_tool_markup
6768
from core.inference.tool_loop_controller import (
6869
ToolLoopController,
6970
awaiting_approval_status,
@@ -1055,6 +1056,17 @@ async def stream_with_studio_tools(
10551056
final = True,
10561057
enabled_tool_names = allowed_tool_names,
10571058
)
1059+
if not replayable_answer.strip():
1060+
# A reasoning-only stall announces the plan inside the think block
1061+
# and nowhere else. _reprompt_intent_text falls back to that block
1062+
# rather than dropping the turn, so match it, and replay what was
1063+
# classified. Ordering matters: the fallback runs on the stripped
1064+
# text, so a turn that is only an unpromotable call block has no
1065+
# think span, stays empty, and still does not get nudged.
1066+
replayable_answer = "\n".join(
1067+
visible_answer[start:end]
1068+
for start, end in _think_spans_outside_tool_markup(visible_answer)
1069+
).strip()
10581070
if (
10591071
tools_available
10601072
and nudge_enabled(policy.nudge_tool_calls)

studio/backend/tests/test_studio_tool_loop.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,62 @@ def test_a_stalled_model_is_nudged_to_act(executed):
780780
assert second[-2]["content"] == "I'll search for that now."
781781

782782

783+
def test_a_reasoning_only_stall_is_nudged_and_replayed(executed):
784+
"""Magistral-style stalls put the whole promise inside the think block.
785+
786+
Stripping tool markup empties such a turn, so classifying the stripped text
787+
alone would drop the nudge the local loops still give. The replayed turn must
788+
be the text that was classified, or the retry is user -> user again.
789+
"""
790+
stall = "[THINK]I will search now.[/THINK]"
791+
transport = FakeTransport(
792+
[
793+
[_sse({"content": stall}), _sse(finish = "stop"), _DONE],
794+
[
795+
_sse(
796+
{
797+
"tool_calls": [
798+
{
799+
"index": 0,
800+
"id": "c1",
801+
"function": {"name": "web_search", "arguments": "{}"},
802+
}
803+
]
804+
}
805+
),
806+
_sse(finish = "tool_calls"),
807+
_DONE,
808+
],
809+
[_sse({"content": "answer"}), _sse(finish = "stop"), _DONE],
810+
]
811+
)
812+
_run(transport, nudge_tool_calls = True)
813+
814+
assert [c["name"] for c in executed] == ["web_search"]
815+
second = transport.requests[1]["messages"]
816+
assert [message["role"] for message in second] == ["user", "assistant", "user"]
817+
assert second[-2]["content"] == stall
818+
819+
820+
def test_a_markup_only_stall_is_still_not_nudged(executed):
821+
"""The reasoning fallback runs on the stripped text, so it must not revive Case G."""
822+
transport = FakeTransport(
823+
[
824+
[
825+
_sse({"content": '<tool_call>{"name": "not_enabled"}</tool_call>'}),
826+
_sse(finish = "stop"),
827+
_DONE,
828+
],
829+
[_sse({"content": "SHOULD NOT APPEAR"}), _sse(finish = "stop"), _DONE],
830+
],
831+
heals = False,
832+
)
833+
_run(transport, auto_heal = False, nudge_tool_calls = True)
834+
835+
assert executed == []
836+
assert len(transport.requests) == 1
837+
838+
783839
def test_a_stalled_model_is_not_nudged_by_default(executed, monkeypatch):
784840
"""An API caller that omits the opt-in must not get a hidden retry."""
785841
from core.inference import passthrough_healing

0 commit comments

Comments
 (0)