Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a1c3be8
don't re-prompt finished answers in the tool loop
NilayYadav Jul 26, 2026
2e51d87
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 27, 2026
6e7fcc0
keep a separate post-tool reprompt budget and tighten the intent regexes
NilayYadav Jul 28, 2026
4223f03
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 28, 2026
2253126
Reset the repeat guard after a tool runs and suppress 'I should call …
danielhanchen Jul 28, 2026
8f384e9
Cover 'must' in forced-retry suppression, keep appended answers, and …
danielhanchen Jul 28, 2026
21b96b2
Anchor obligation suppression to sentence starts and wire the repeat …
danielhanchen Jul 28, 2026
f161f67
Keep deletions out of restatement and nudge pronoun-free first-step p…
danielhanchen Jul 28, 2026
f67c5b1
Tighten repeat similarity, anchor subjectless plans, and restore firs…
danielhanchen Jul 28, 2026
4824e33
Keep first-person plan framing and punctuation-bearing terms out of r…
danielhanchen Jul 28, 2026
b8a86ed
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 28, 2026
2c667f7
Keep leading term punctuation, accept colon-delimited first steps, an…
danielhanchen Jul 28, 2026
7be4415
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 28, 2026
c66c6de
Tighten comments on the plan-without-action re-prompt guards
danielhanchen Jul 28, 2026
d6073ce
Compare plans by token sequence, suppress subjectless modals, and acc…
danielhanchen Jul 28, 2026
fbe165e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 28, 2026
2e3277b
studio: narrow the first-step plan match and make repeat detection co…
danielhanchen Jul 28, 2026
dcf0de4
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 28, 2026
88784c0
studio: tighten comments in the plan-without-action re-prompt path
danielhanchen Jul 28, 2026
eb59611
studio: keep a forced retry that pivots from a plan to an answer
danielhanchen Jul 28, 2026
611aa05
studio: keep articles in repeat comparison and exclude missing-answer…
danielhanchen Jul 28, 2026
4ca5e9e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 28, 2026
9bf66ee
studio: tighten the pivot and final-answer signals, drop filler-insen…
danielhanchen Jul 28, 2026
20fe689
Merge remote-tracking branch 'origin/main' into r7505
danielhanchen Jul 29, 2026
a105c44
studio: exempt offers of help, and add a measured accuracy floor
danielhanchen Jul 29, 2026
a9e3444
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions studio/backend/core/inference/llama_cpp.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
from core.inference.tool_call_parser import (
MAX_ACT_REPROMPTS as _MAX_REPROMPTS,
REPROMPT_MAX_CHARS as _REPROMPT_MAX_CHARS,
is_reprompt_repeat as _is_reprompt_repeat,
is_short_intent_without_action as _is_short_intent_without_action,
reprompt_to_act_message as _reprompt_to_act_message,
)
Expand Down Expand Up @@ -339,8 +340,13 @@ def _finalize_reasoning_only_cumulative(
# loop). Structured delta.tool_calls are grammar-bounded by llama-server; text
# parsed from content is not, so one runaway turn could fan out unbounded.
_MAX_TOOL_CALLS_PER_TURN = 8
_FORCED_REPEAT_PLAN_SIGNAL = re.compile(
r"\b(?:i\s+will|i'll|let\s+me|going\s+to|need\s+to|call|use|run|search|fetch|render)\b",
# Obligation phrasing INTENT_SIGNAL leaves alone ("I need to call ...", "I should
# call ..."), paired with an action verb: the bare topic words this replaced ate
# real answers. "should" is its own alternative -- folding it into the
# need|have|ought group would demand "I should to call".
_FORCED_PLAN_INTENT = re.compile(
r"(?:\bi\s+(?:(?:need|have|ought)\s+to|should)|^need\s+to|^going\s+to)"
r"\s+(?:\w+\s+){0,2}?(?:call|use|run|search|fetch|render|invoke|query)\b",

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 Cover must in forced retry suppression

On a forced GGUF retry, a common stall such as I must call web_search now is not matched by the shared intent regex or this obligation pattern, so _should_suppress_forced_no_tool_output returns false and the hidden retry text is emitted as the final answer. The amended alternatives now cover should, but still omit must even when it is paired with an explicit action verb; include that obligation form so these retries remain suppressed.

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 in 8f384e9. _should_suppress_forced_no_tool_output("I must call web_search now") returned False at the previous head, so that hidden retry shipped as the final answer.

must sits beside should rather than inside the need|have|ought ... to group, since the plain modals take a bare infinitive and folding them in would demand "I should to call":

r"(?:\bi\s+(?:(?:need|have|ought)\s+to|should|must)|^need\s+to|^going\s+to)"

Still False for You must call your bank about the charge. and I must admit the square is red. Positives and negatives both added to test_forced_turn_suppression_covers_obligation_phrasing.

re.I,
)
_FINAL_ANSWER_SIGNAL = re.compile(
Expand Down Expand Up @@ -443,7 +449,9 @@ def _should_suppress_forced_no_tool_output(text: str) -> bool:
return False
if _FINAL_ANSWER_SIGNAL.search(stripped):
return False
return _FORCED_REPEAT_PLAN_SIGNAL.search(stripped) is not None
if _is_short_intent_without_action(stripped):
return True
return _FORCED_PLAN_INTENT.search(stripped) is not None


# ── Pre-compiled patterns for GGUF shard detection ───────────
Expand Down Expand Up @@ -11241,14 +11249,18 @@ def _tool_succeeded(tool_name: str) -> bool:
# direct answer ("4", "Hello!") won't match. Pattern shared with the
# safetensors loop (tool_call_parser.INTENT_SIGNAL).
_reprompt_count = 0
# Budgeted apart from _reprompt_count so a pre-tool nudge can't spend it.
_post_tool_reprompts = 0
# Text that triggered the last nudge; if the retry restates it, stop.
_last_reprompt_text = ""
# Gates ``max_tool_iterations`` on real tool turns (not the enlarged range) so reserved
# re-prompt slots don't extend the budget. Mirrors the safetensors guard.
_tool_iters_done = 0
_forced_tool_call_pending = False

# Reserve extra iterations for re-prompts so they don't consume the
# caller's tool-call budget; only when tool iterations are allowed.
_extra = _MAX_REPROMPTS if max_tool_iterations > 0 else 0
_extra = _MAX_REPROMPTS + 1 if max_tool_iterations > 0 else 0
for iteration in range(max_tool_iterations + _extra):
if cancel_event is not None and cancel_event.is_set():
return
Expand Down Expand Up @@ -11876,7 +11888,7 @@ def _tool_succeeded(tool_name: str) -> bool:
# ── Re-prompt on plan-without-action ──
# If the model described its intent (forward-looking
# language) without calling a tool, nudge it to act.
# Fires at most once per request, only on short
# Fires up to _MAX_REPROMPTS times, only on short
# responses with intent signals -- "4" or "Hello!"
# won't trigger it. Use content if available, else
# fall back to reasoning text (reasoning-only stalls).
Expand All @@ -11889,18 +11901,29 @@ def _tool_succeeded(tool_name: str) -> bool:
r"(?i)\brender[_\s-]?html\b",
_stripped,
)
# A stall after a tool ran still deserves a nudge, but
# each retry re-runs tools, so allow only one.
_already_acted = any(record.executed for record in tool_controller.history)
if _already_acted:
_reprompt_used, _reprompt_cap = _post_tool_reprompts, 1
else:
_reprompt_used, _reprompt_cap = _reprompt_count, _MAX_REPROMPTS

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 Count auto-injected retrieval as prior tool execution

When rag_scope causes build_rag_autoinject to run, the knowledge-base retrieval executes before the controller is created and therefore never appears in tool_controller.history. This new _already_acted check consequently treats the following model response as pre-tool and grants the full three-reprompt budget rather than the intended single post-tool nudge, allowing document requests to repeat the expensive behavior this cap is meant to prevent. The safetensors loop already tracks the same external execution with rag_autoinjected; the GGUF phase check needs to incorporate _auto as well.

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 in 8f384e9. The ordering is exactly as described: _auto = build_rag_autoinject(...) runs well before ToolLoopController is constructed, so a RAG-scoped request that retrieved left tool_controller.history empty and _already_acted read False, granting the full three-nudge pre-tool budget.

_already_acted = bool(_auto) or any(
    record.executed for record in tool_controller.history
)

One deliberate difference from safetensors: that loop blocks the nudge outright when rag_autoinjected, whereas this grants the single post-tool nudge. That matches what this PR set the post-tool cap to, and it keeps the GGUF behaviour a strict tightening rather than a new hard block. Regression test test_rag_autoinject_counts_as_a_prior_tool_execution asserts one nudge instead of three, and it fails on the previous head.

# None keeps the default-on re-prompt; False disables it.
if (
auto_heal_tool_calls
and (nudge_tool_calls is None or nudge_tool_calls)
and active_tools
and not _render_html_already_done_intent
and _reprompt_count < _MAX_REPROMPTS
and _reprompt_used < _reprompt_cap
and not _is_reprompt_repeat(_stripped, _last_reprompt_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 Reset repeat tracking after a tool runs

When a request first hits a pre-tool plan-without-action, _last_reprompt_text is set. If the retry then calls a tool and the next model turn repeats that same short intent (for example, “I will search the web now.” before and after the search result), _already_acted selects the fresh post-tool budget but this guard rejects the nudge as a repeat. Because _forced_tool_call_pending was cleared when the tool executed, the repeated plan is streamed/returned as the final answer instead of using the one post-tool corrective retry. Fresh evidence: this revision separated _post_tool_reprompts, but this new repeat guard still shares _last_reprompt_text across the pre- and post-tool phases.

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 in 2253126.

Reproduced on the head commit: with I will search the web now. said both before and after the search, the loop stopped after 3 generations and surrendered the repeated stall as the visible answer instead of spending the post-tool nudge.

_last_reprompt_text is now cleared right after tool_controller.record_result, so a real execution opens a clean post-tool phase. Same script now runs 4 generations, logs Re-prompt 1/1, and ends on the final answer. Regression test: test_repeat_guard_resets_after_a_tool_runs.

and _is_short_intent_without_action(_stripped)
):
_reprompt_count += 1
if _already_acted:
_post_tool_reprompts += 1
_last_reprompt_text = _stripped
logger.info(
f"Re-prompt {_reprompt_count}/{_MAX_REPROMPTS}: "
f"Re-prompt {_reprompt_used + 1}/{_reprompt_cap}: "
f"model responded without calling tools "
f"({len(_stripped)} chars)"
)
Expand Down Expand Up @@ -12252,6 +12275,11 @@ def _invoke_tool(_output_callback, _decision = decision):
_kb_search_count += 1
completion = tool_controller.record_result(decision, result)
resolved_provisional_tool_call_ids.add(decision.tool_call_id)
# A real execution opens the post-tool phase. The pre-tool stall
# text must not carry over, or "I will search the web now." said
# before and after the search reads as a repeat and swallows the
# one post-tool nudge.
_last_reprompt_text = ""
# A tool ran this turn, so it counts against the caller's budget.
_turn_executed_real_tool = True
yield completion.tool_end_event()
Expand Down
36 changes: 31 additions & 5 deletions studio/backend/core/inference/tool_call_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,12 +169,15 @@
# Forward-looking intent: the model says what it *will* do, not a final answer.
INTENT_SIGNAL = re.compile(
r"(?i)("
# Direct intent ("I'll", "Let me"); lookahead drops negated forms
# ("I will not") so a refusal does not re-prompt.
r"\b(i['\u2019](ll|m going to|m gonna)|i am (going to|gonna)|i will|i shall|let me|allow me)\b(?!\s+(?:not|never)\b)"
# Direct intent ("I'll"); lookahead drops negated forms ("I will not").
r"\b(i['\u2019](ll|m going to|m gonna)|i am (going to|gonna)|i will|i shall)\b(?!\s+(?:not|never)\b)"
r"|"
# Step/plan framing: "First ...", "Step 1:", "Here's my plan"
r"\b(?:first\b|step \d+:?|here['\u2019]?s (?:my |the |a )?(?:plan|approach))"
# "let me know" hands control back rather than announcing an action.
r"\b(?:let me|allow me)\b(?!\s+(?:not|never|know)\b)"
r"|"
# Step/plan framing: "First, I ...", "Step 1:", "Here's my plan". "first"
# needs first person so "First, the answer is 42" isn't read as a stall.
r"\b(?:first,?\s+(?:i|we|let['\u2019]?s|let us)\b|step \d+:?|here['\u2019]?s (?:my |the |a )?(?:plan|approach))"

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 Continue nudging pronoun-free first-step plans

When a model emits an imperative plan such as First, search the web for the latest release notes., this new first-person requirement no longer matches it, even though the previous first branch did. Both GGUF and safetensors therefore treat that short plan-without-action as the final response and never ask the model to make the tool call. Distinguishing First, the answer is 42 with an action cue rather than requiring I, we, or let's would retain these common plan forms.

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 in f161f67. INTENT_SIGNAL did not match First, search the web for the latest release notes. after the narrowing, so that plan-without-action was surrendered as the answer on both backends.

Took the action-cue route you suggested rather than loosening back to bare first:

r"\b(?:first,?\s+(?:i|we|let['’]?s|let us)\b"
r"|first,?\s+(?:call|use|run|search|fetch|render|invoke|query|check|look|find|get)\b"
r"|step \d+:?|here['’]?s (?:my |the |a )?(?:plan|approach))"

Now matches First, search ..., First, check ..., First, look up ... alongside the existing first-person forms, and still does not match First, the answer is 42, First, the result is 3. or The first line is blank. The new cases are in the shared GGUF/safetensors parity test so both loops are covered.

r"|"
r"\b(?:now i|next i)\b"
r")"
Expand All @@ -190,6 +193,29 @@ def is_short_intent_without_action(text: str) -> bool:
return 0 < len(stripped) < REPROMPT_MAX_CHARS and INTENT_SIGNAL.search(stripped) is not None


_REPEAT_WORD_RE = re.compile(r"[^\w\s]+")
REPROMPT_REPEAT_SIMILARITY = 0.85


def _normalize_for_repeat(text: str) -> str:
return " ".join(_REPEAT_WORD_RE.sub(" ", text.lower()).split())

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 meaningful punctuation in repeat comparisons

When a retry changes a punctuation-sensitive query, _normalize_for_repeat can collapse the two plans to exactly the same string before the token-overlap safeguard runs. For example, I will search for C++. and I will search for C#. both normalize to i will search for c, so both GGUF and safetensors stop nudging after the changed retry instead of allowing the corrected tool call. Preserve punctuation that can be part of search terms, code, or operators, or compare against a less lossy representation.

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 in 4824e33. Both plans really did normalize to i will search for c, so the retry was scored as a repeat and lost its nudge.

_normalize_for_repeat now strips sentence punctuation from the token edges only, instead of deleting every non-word character everywhere:

_REPEAT_EDGE_PUNCT = ".,;:!?\"'`()[]{}<>‘’“”"
...
stripped for word in text.lower().split() if (stripped := word.strip(_REPEAT_EDGE_PUNCT))

C++. and C#. now normalize to c++ and c#, so they compare distinct, while found! and found. still collapse to found and the existing case/punctuation-variant cases keep matching. Test test_reprompt_repeat_keeps_punctuation_bearing_terms.



# A nudge that just gets the same answer back has not worked, so stop there.
def is_reprompt_repeat(text: str, previous: str) -> bool:
if not previous:
return False

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 Apply the repeat guard to the safetensors loop

When a safetensors/MLX model repeats the same pre-tool intent after a nudge, run_safetensors_tool_loop still checks only reprompt_count and never calls this helper, so it performs all three generations that this change is intended to stop. The helper is currently consumed only by the GGUF loop; wire the same previous-response tracking and repeat check into the safetensors path so behavior does not depend on the selected backend.

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.

Fair, and fixed in 21b96b2. The PR added the shared helper but wired only one consumer, so the same repeated stall cost three generations on safetensors/MLX and one on GGUF.

run_safetensors_tool_loop now tracks last_reprompt_text alongside reprompt_count and gates the nudge on not is_reprompt_repeat(intent_text, last_reprompt_text), matching the GGUF condition.

One existing test needed updating: TestPlanWithoutActionReprompt::test_reprompt_fires_up_to_the_cap fed the identical stall MAX_ACT_REPROMPTS times, so the new guard stopped it after the first nudge. It now uses distinct stalls, which is what it meant to exercise, and test_reprompt_stops_when_the_retry_restates_the_stall covers the repeat path with two generations instead of four.

a, b = _normalize_for_repeat(text), _normalize_for_repeat(previous)
if not a or not b:
return False
if a == b:
return True
wa, wb = set(a.split()), set(b.split())
if len(wa) < 4 or len(wb) < 4:
return False # too short for overlap to mean anything
return len(wa & wb) / len(wa | wb) >= REPROMPT_REPEAT_SIMILARITY

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 short facts appended to repeated preambles

When a forced retry keeps the prior planning sentence but appends a concise answer—for example, I will now summarize the search results for you: Tokyo. after the same sentence without Tokyo—the set-based Jaccard score exceeds 0.85, so this reports a repeat. In the GGUF loop that bypasses the retry branch, and because the response still matches the intent signal, _should_suppress_forced_no_tool_output drops the entire response; the user receives the original plan rather than the newly added fact. Repeat detection should not treat added answer content as equivalent merely because a long preamble is unchanged.

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 in 8f384e9. Reproduced the exact example: for previous = "I will now summarize the search results for you" and that sentence plus : Tokyo., the word sets are 9 and 10 with an intersection of 9, so Jaccard is 0.9 and is_reprompt_repeat returns True. _should_suppress_forced_no_tool_output then returned True as well and the whole turn was dropped, so the appended fact was lost.

Fixed at the suppression site rather than by lowering the threshold. Near-repeat is the right test for "stop nudging, it is not working", but far too loose for "throw the turn away". tool_call_parser.py gains a stricter sibling:

def is_reprompt_restatement(text: str, previous: str) -> bool:
    ...
    return set(a.split()) <= set(b.split())    # added nothing at all

_should_suppress_forced_no_tool_output now takes the nudged text as previous. An obligation-plan match still suppresses unconditionally; a bare INTENT_SIGNAL match only counts as a stall when the retry added nothing. The default previous="" keeps the standalone contract for the existing callers and tests.

This also covers the wider case in the same path: INTENT_SIGNAL fires on lead-ins that introduce a real answer ("Now I have the results. The capital is Tokyo."), which the pre-PR _FORCED_REPEAT_PLAN_SIGNAL let through. Three tests cover it, two of them end to end through the loop.

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 meaningful token changes in repeat detection

When a retry changes one important term in an otherwise long plan, this set-based Jaccard score can still exceed 0.85 and prematurely stop the tool nudge. For example, changing only CUDA version 12.4 to CUDA version 12.5 in a 15-word search plan scores about 0.89, so both tool loops treat the revised plan as a repeat and return without giving the model another chance to make the corrected call. Compare normalized token sequences, or otherwise require changed query/value tokens to make the responses distinct.

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 in f67c5b1. Your example scores 0.867 on the old bar, so a single corrected token in a 15-word plan did read as a repeat.

Kept the fuzzy comparison rather than switching to sequence equality, because that is what is_reprompt_restatement already is, and the two helpers answer different questions: "stop nudging, it is not working" tolerates rewording, "throw the turn away" does not. Raised the bar instead:

REPROMPT_REPEAT_SIMILARITY = 0.95

At 0.95 a 15-word plan needs zero differing tokens, so only filler drift and reordering survive. Verified: CUDA version 12.4 to 12.5 is no longer a repeat, while identical text, case and punctuation variants, and pure reorderings still are. Test test_reprompt_repeat_keeps_a_changed_query_token.

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 word order when identifying repeated plans

When a retry rearranges the same words in a way that changes the requested query, this set-based comparison still returns 1.0; for example, I will search for cats not dogs and I will search for dogs not cats are classified as repeats. Both tool loops then skip the remaining corrective nudge and accept the reordered no-tool plan as the final response. Fresh evidence in this revision is that increasing the threshold to 0.95 cannot distinguish any such reordering because token order is still discarded.

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.

Right, and fixed in d6073ce. Set overlap cannot see order, so no threshold helps.

is_reprompt_repeat now compares token sequences instead of token sets:

return difflib.SequenceMatcher(None, ta, tb).ratio() >= REPROMPT_REPEAT_SIMILARITY

cats not dogs vs dogs not cats is no longer a repeat, and this subsumes the earlier cases from the same family: the CUDA 12.4 to 12.5 change and the .NET / C++ / C# terms all stay distinct, while identical text, case and punctuation variants, and a single added filler word in a long plan still compare equal. Test test_reprompt_repeat_respects_word_order.

One deliberate consequence: a pure reordering that means the same thing ("Now I will summarize the results" after "I will summarize the results now") is no longer a repeat and costs one more nudge. That is the safe direction to be wrong in.



def reprompt_to_act_message(tool_hint: str) -> str:
"""The user message appended when re-prompting a plan-without-action turn."""
return (
Expand Down
224 changes: 223 additions & 1 deletion studio/backend/tests/test_llama_cpp_tool_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -1486,7 +1486,229 @@ def fake_execute_tool(name, arguments, **_kwargs):

content_texts = [event.get("text", "") for event in events if event.get("type") == "content"]
assert content_texts == ["I will use render_html now."]
assert len(payloads) == _MAX_REPROMPTS + 1
# Each retry restates the last, so the loop gives up: initial + 2 re-prompts.
assert len(payloads) == 3 < _MAX_REPROMPTS + 1


def test_post_tool_stall_still_nudged_after_a_pre_tool_reprompt(monkeypatch):
"""The post-tool nudge has its own budget, so an earlier stall can't spend it."""

streams = [
[_sse({"content": "I will search the web now."}), _done()],
[
_sse(
{
"tool_calls": [
{
"index": 0,
"id": "call_first",
"type": "function",
"function": {
"name": "web_search",
"arguments": json.dumps({"query": "red square"}),
},
}
]
}
),
_done(),
],
[_sse({"content": "Let me summarize the results."}), _done()],
[_sse({"content": "Final answer: the square is red."}), _done()],
]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, streams, payloads)

calls: list[tuple[str, dict]] = []

def fake_execute_tool(name, arguments, **_kwargs):
calls.append((name, arguments))
return "Search results: red is #f00."

monkeypatch.setattr("core.inference.tools.execute_tool", fake_execute_tool)

tools = [
{
"type": "function",
"function": {
"name": "web_search",
"description": "Search the web.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
}
]

events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "Make a red square."}],
tools = tools,
max_tool_iterations = 2,
)
)

assert len(payloads) == 4
assert len(calls) == 1
nudges = [
message
for message in payloads[-1]["messages"]
if message.get("role") == "user" and "call web_search now" in message.get("content", "")
]
assert len(nudges) == 2
content_texts = [event.get("text", "") for event in events if event.get("type") == "content"]
assert content_texts[-1] == "Final answer: the square is red."


def test_post_tool_reprompt_budget_is_one(monkeypatch):
"""The post-tool nudge fires once; a second stall is surrendered as the answer."""

streams = [
[
_sse(
{
"tool_calls": [
{
"index": 0,
"id": "call_first",
"type": "function",
"function": {
"name": "web_search",
"arguments": json.dumps({"query": "red square"}),
},
}
]
}
),
_done(),
],
[_sse({"content": "Let me summarize the results."}), _done()],
[_sse({"content": "Now I will check the sources."}), _done()],
]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, streams, payloads)

monkeypatch.setattr(
"core.inference.tools.execute_tool",
lambda *_a, **_k: "Search results: red is #f00.",
)

tools = [
{
"type": "function",
"function": {
"name": "web_search",
"description": "Search the web.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
}
]

list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "Make a red square."}],
tools = tools,
max_tool_iterations = 2,
)
)

assert len(payloads) == 3


def test_repeat_guard_resets_after_a_tool_runs(monkeypatch):
"""A tool execution opens a new phase, so the same intent text is nudged again.

Without the reset the pre-tool stall text still sits in the repeat tracker and
the identical post-tool stall is surrendered as the visible final answer.
"""

stall = "I will search the web now."
streams = [
[_sse({"content": stall}), _done()],
[
_sse(
{
"tool_calls": [
{
"index": 0,
"id": "call_first",
"type": "function",
"function": {
"name": "web_search",
"arguments": json.dumps({"query": "red square"}),
},
}
]
}
),
_done(),
],
[_sse({"content": stall}), _done()],
[_sse({"content": "Final answer: the square is red."}), _done()],
]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, streams, payloads)

monkeypatch.setattr(
"core.inference.tools.execute_tool",
lambda *_a, **_k: "Search results: red is #f00.",
)

tools = [
{
"type": "function",
"function": {
"name": "web_search",
"description": "Search the web.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
}
]

events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "Make a red square."}],
tools = tools,
max_tool_iterations = 2,
)
)

assert len(payloads) == 4
content_texts = [event.get("text", "") for event in events if event.get("type") == "content"]
assert content_texts[-1] == "Final answer: the square is red."


def test_forced_turn_suppression_covers_obligation_phrasing():
from core.inference.llama_cpp import _should_suppress_forced_no_tool_output as suppress
for stall in (
"I need to use render_html now",
"Need to call web_search",
"I will summarize the results now",
"I have to run the search first",
"I should call web_search now",
"I should use render_html now",
):
assert suppress(stall), f"leaked {stall!r}"

for answer in (
"You need to install the package first.",
"The square is red.",
"Here is the summary of what I found.",
"Run `pip install unsloth` to get started.",
"I should mention that the square is red.",
"You should call your bank about the charge.",
):
assert not suppress(answer), f"dropped {answer!r}"


def test_forced_reprompt_plain_final_answer_is_visible(monkeypatch):
Expand Down
Loading
Loading