Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
71 changes: 61 additions & 10 deletions studio/backend/core/inference/llama_cpp.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@
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_reprompt_restatement as _is_reprompt_restatement,
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 +341,15 @@ 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 must
# call ..."), paired with an action verb: the bare topic words this replaced ate
# real answers. Two shapes, not one list: the semi-modals take a "to"
# ("I need to call"), the plain modals take a bare infinitive ("I must call") --
# folding "should"/"must" 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|must)|^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 Require a restatement before suppressing obligation phrases

On a forced retry, this regex suppresses any matching response before comparing it with the stalled response, which can discard a completed answer such as “The API I should invoke is foo() because it supports streaming.” The newly added invoke/query cases make this a regression from the previous suppression pattern: when the answer lacks one of _FINAL_ANSWER_SIGNAL's narrow labels, the loop returns without emitting it. Treat these phrases as stalls only when the retry actually restates the prior plan, or otherwise distinguish an announced action from explanatory prose.

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 21b96b2. _should_suppress_forced_no_tool_output("The API I should invoke is foo() because it supports streaming.") returned True at the previous head, so that answer was dropped. As you say, invoke is new here, so it is a regression rather than a pre-existing gap.

I did not take the restatement gate for this branch, because on a forced retry there is always a previous stall, and a model that answers one stall with a different stall ("I must call web_search now" after "I will search the web") would then leak, undoing the should/must fix from the last round.

Anchored to sentence starts instead. An announced action is its own sentence; the same words mid-sentence are prose that happens to name a tool:

r"(?:(?:^|[.!?]\s+)\s*i\s+(?:(?:need|have|ought)\s+to|should|must)"
r"|^need\s+to|^going\s+to)"
r"\s+(?:\w+\s+){0,2}?(?:call|use|run|search|fetch|render|invoke|query)\b"

Checked both directions: still True for I must call web_search now, I should call web_search now, I need to use render_html now, Need to call web_search and Understood. I should call web_search now.; now False for the example above plus The tool I need to use is documented here., and still False for I should mention that the square is red. and You should call your bank about the charge.

re.I,
)
_FINAL_ANSWER_SIGNAL = re.compile(
Expand Down Expand Up @@ -436,14 +445,27 @@ def _held_rehearsal_tail_len(text: str, active_tools: list[dict]) -> int:
return len(tail) if tail and _is_rehearsal_prefix(tail, active_tools) else 0


def _should_suppress_forced_no_tool_output(text: str) -> bool:
"""Suppress only repeated forced-turn planning text, not final answers."""
def _should_suppress_forced_no_tool_output(text: str, previous: str = "") -> bool:
"""Suppress only repeated forced-turn planning text, not final answers.

``previous`` is the stall text that triggered the nudge, so a retry that
moved on can be told from one that just said the same thing again.
"""
stripped = text.strip()
if not stripped or len(stripped) >= _REPROMPT_MAX_CHARS:
return False
if _FINAL_ANSWER_SIGNAL.search(stripped):
return False
return _FORCED_REPEAT_PLAN_SIGNAL.search(stripped) is not None
if _FORCED_PLAN_INTENT.search(stripped) is not None:
return True

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 answers appended to obligation-style plans

When the forced retry starts with obligation phrasing but then provides an answer (for example, I should call web_search, but the answer is Tokyo.), this unconditional match suppresses the entire turn. _FINAL_ANSWER_SIGNAL does not recognize common forms such as “the answer is,” and unlike the intent branch below, this branch never checks whether the retry merely restates previous, so users can receive no final answer despite the model having completed one. Apply the same restatement/progress check here before discarding the output.

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 this is the worst failure direction the feature has, so thank you. Reproduced in the worktree: _should_suppress_forced_no_tool_output("I should call web_search, but the answer is Tokyo.", previous) returned True, and _FINAL_ANSWER_SIGNAL matched answer: but not "the answer is", so the turn reached the user as nothing at all.

Fixed in eb59611. I did not gate this branch on the restatement check, because an obligation plan is a stall on its own even when it differs from the nudged text, and that behaviour was added for an earlier finding ("I need to use render_html now" leaking on forced retries). Instead the branch now suppresses the plan only when nothing follows it: if a pivot ("but", "however", "although", "though", "that said", "meanwhile") appears after the match, the output ships. _FINAL_ANSWER_SIGNAL also learned "the answer is" and "to summarise".

The trade is deliberate and asymmetric: "I should call web_search but first let me think" will now leak its plan sentence, which is cosmetic, whereas the case you found lost a completed answer. The doubtful case resolves towards shipping the turn.

Four new negatives are pinned in test_forced_turn_suppression_covers_obligation_phrasing, alongside the existing positives, which all still suppress. 434 tests pass.

if not _is_short_intent_without_action(stripped):
return False
# INTENT_SIGNAL also fires on lead-ins that introduce a real answer ("Now I
# have the results. The capital is Tokyo."), which _FORCED_REPEAT_PLAN_SIGNAL
# used to let through. Dropping those loses the answer outright, so a bare
# intent match only counts as a stall once the retry adds nothing to what we
# nudged; no ``previous`` keeps the standalone "is this a stall?" contract.
return not previous or _is_reprompt_restatement(stripped, previous)
Comment on lines +498 to +503

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 Suppress changed plan-only retries after the nudge budget

Do not preserve every non-identical direct-intent retry as progress. For example, after nudging Let me summarize the results, a retry of I will review the results now is still only a plan, but it is not an obligation-style _FORCED_PLAN_INTENT and is not an exact restatement, so this branch surfaces it as the final GGUF response once the post-tool cap or pre-tool budget is exhausted. The previous suppression recognized I will plans, so this can newly leave users with another unfinished promise instead of an answer.

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.

Closing this one: it reverses a finding from an earlier round of this same review, and the direction it asks for is the more damaging one.

The restatement gate on this branch exists because INTENT_SIGNAL fires on lead-ins that introduce a real answer ("Now I have the search results. The capital of Japan is Tokyo."). Suppressing on a bare intent match dropped those answers, which is what test_forced_turn_intent_lead_in_needs_a_restatement_to_be_dropped pins. Your P1 last round made the same point from the other side, and I agreed with it: suppression means the user gets nothing at all.

"I will review the results now" and "Now I have the results. Tokyo is the capital." are not separable by pattern, which is why the branch asks whether the retry moved past the nudged text instead. Obligation plans keep their unconditional path; direct-intent plans do not, deliberately. A leaked plan sentence is cosmetic; a dropped answer is not.



# ── Pre-compiled patterns for GGUF shard detection ───────────
Expand Down Expand Up @@ -11241,14 +11263,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 +11902,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 +11915,35 @@ 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.
# RAG autoinject retrieves before the controller exists, so it
# never lands in history; without _auto a doc-grounded turn
# would still be read as pre-tool and get the full budget.
# Mirrors the safetensors loop's rag_autoinjected.
_already_acted = bool(_auto) or 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
# 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 @@ -11935,7 +11978,10 @@ def _tool_succeeded(tool_name: str) -> bool:

if _forced_tool_call_pending:
_forced_tool_call_pending = False
if not _should_suppress_forced_no_tool_output(_stripped):
if not _should_suppress_forced_no_tool_output(
_stripped,
_last_reprompt_text,
):
if cumulative_display:
forced_visible_text = _strip_tool_markup(
cumulative_display,
Expand Down Expand Up @@ -12252,6 +12298,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
49 changes: 44 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,42 @@ 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.



# Stricter sibling of ``is_reprompt_repeat``: nothing at all was added. Near-repeat
# is the right test for "stop nudging, it isn't working", but not for "throw the
# turn away" -- a retry that restates the plan and appends the answer ("... for
# you: Tokyo.") clears the similarity bar, and dropping it loses the answer.
def is_reprompt_restatement(text: str, previous: str) -> bool:
if not previous:
return False
a, b = _normalize_for_repeat(text), _normalize_for_repeat(previous)
if not a or not b:
return False
return set(a.split()) <= set(b.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 removed words when comparing restatements

When a forced GGUF retry removes a word that changes the answer, this set-containment check treats it as adding nothing and suppresses the corrected response. For example, after Now I think the feature is not supported in version 1. triggers a nudge, Now I think the feature is supported in version 1. is both classified as a repeat and considered a restatement because its word set is a subset; _should_suppress_forced_no_tool_output then drops it, leaving the user with the original incorrect answer. The comparison needs to retain ordering and deletions rather than reducing both responses to sets.

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. Reproduced it: with previous = "Now I think the feature is not supported in version 1." and the corrected "... is supported ...", the word set is a strict subset, so is_reprompt_restatement returned True and the correction was dropped.

The helper now compares the normalized token sequences directly rather than their sets, so both ordering and deletions survive:

a, b = _normalize_for_repeat(text), _normalize_for_repeat(previous)
return bool(a) and a == b

That reduces it to "the retry said the same thing again, word for word", which is the only case where discarding the turn is safe. Case, punctuation and whitespace still normalize away, so Understood. I'll search for that now. after Understood, I'll search for that now. is still a restatement. Regression test test_restatement_keeps_deletions_that_change_the_answer.



def reprompt_to_act_message(tool_hint: str) -> str:
"""The user message appended when re-prompting a plan-without-action turn."""
return (
Expand Down
Loading
Loading