Compare commits

...
Sign in to create a new pull request.

42 commits

Author SHA1 Message Date
Daniel Han
7f317b1e3a Studio: r25 fixes - symmetric step / action variants and look-up forms
- _DIRECT_NUMBERED_PLAN_FRAMING collapses take/follow/complete steps
  and perform actions into the symmetric
  (take|follow|complete|perform) (these|the following) (steps|actions)
  pattern. Phrasings like "I'll perform these steps:" or "I will
  take the following actions:" are now caught alongside the existing
  variants.

- _BARE_INTENT_NUMBERED_PLAN expands "look up" to
  "look (this|that|it|them)? up" so "I'll:\n1. Look this up." is
  treated as a plan stall, matching _DIRECT_NUMBERED_PLAN_FRAMING.
2026-05-24 22:35:29 +00:00
pre-commit-ci[bot]
d77b38d51d [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 22:25:46 +00:00
Daniel Han
6cf6797fb3 Studio: r24 fixes - revert bare i need to, add complete-these-steps verbs
- _INTENT_SIGNAL, _DIRECT_NUMBERED_PLAN_FRAMING, _BARE_INTENT_NUMBERED_PLAN,
  and _STRONG_INTENT_BEFORE_LIST all drop bare "i need to" from their
  intent vocabulary. The phrase is too common in ordinary clarification
  prose ("I need to know your operating system") and quoted answer text
  ("I need to leave early"), so adding it as a re-prompt trigger
  produced too many false positives. Genuine "I need to X..." plans
  are still caught when paired with "I'll", "Let me", or "first" /
  "step N" framing elsewhere in the candidate.

- _DIRECT_NUMBERED_PLAN_FRAMING adds "complete these steps" /
  "complete the following steps" to the verb whitelist, parallel
  to the existing "take/follow these steps" / "perform these actions".
2026-05-24 22:25:21 +00:00
pre-commit-ci[bot]
b6477eddb1 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 22:14:31 +00:00
Daniel Han
5564cfa1f4 Studio: r23 fixes - inline fence prose guard, I need to / visit / gather verbs
- _has_unclosed_code_fence() splits inline fence handling from
  column-0 handling. Inline fences (text before the delimiters on
  the same line) now require a clean info-string trailing (no
  internal whitespace, no leading space) to count. Prose mentions
  like "Use \`\`\` to start" or "Use \`\`\`python to open a block."
  no longer falsely flag the response as mid-stream. Column-0
  fences keep their permissive info-string parsing.

- _INTENT_SIGNAL + _DIRECT_NUMBERED_PLAN_FRAMING +
  _BARE_INTENT_NUMBERED_PLAN + _STRONG_INTENT_BEFORE_LIST all add
  "i need to" as a direct first-person intent phrase.

- _DIRECT_NUMBERED_PLAN_FRAMING and _BARE_INTENT_NUMBERED_PLAN add
  visit / access / navigate / gather / collect / identify / update
  / edit so browser-navigation and data-gathering plans still
  re-prompt.

- _LOCAL_ACTION_VERBS adds gather / collect / identify so
  numbered lists whose item verbs match these still trigger the
  intent-+-action-item cross-check.
2026-05-24 22:14:18 +00:00
pre-commit-ci[bot]
9f3c49a733 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 22:02:12 +00:00
Daniel Han
32b64aef97 Studio: r22 fixes - prose backtick guard, take/follow steps verbs
- _has_unclosed_code_fence() ignores a fence run when the trailing
  text on the same line starts with a space (typical English prose
  like "Use \`\`\` to start a markdown fence."). Real fence openers
  either end the line right after the delimiters or carry an info
  string with no leading space (\`\`\`python, \`\`\`bash-session).

- _DIRECT_NUMBERED_PLAN_FRAMING accepts "take these steps",
  "follow these steps", and "perform these actions" as first-person
  intent verbs. Plans like "I'll take these steps:\n1. Open URL\n
  2. Read" still re-prompt instead of being read as final answers.
2026-05-24 22:01:57 +00:00
pre-commit-ci[bot]
394cdf48ea [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 21:51:15 +00:00
Daniel Han
834b34c68d Studio: r21 fixes - strip orphan tool-call XML before artifact, broaden first-person plan verbs
- Re-prompt path calls _strip_tool_markup(final=True) on content_accum
  before measuring intent / artifact / length. An orphan
  ``<tool_call>...</tool_call>`` block containing a code fence no
  longer hides the intent-only visible answer from the artifact check.

- _DIRECT_NUMBERED_PLAN_FRAMING splits into two branches:
  * First-person intent ("I'll", "Let me", "I will", etc.) accepts a
    broader work-verb set (open, read, search, check, review, inspect,
    examine, etc.). Direct first-person announcements are strong
    plan-like signals.
  * Bare "First, ..." / "Step N: ..." keeps the narrow verb set so
    algorithmic answers ("First, use binary search:") stay valid.
  Catches stalls like "I will check the docs:\n1. Gather..." and
  "Let me read the uploaded file:\n1. Identify the columns..." that
  previously slipped past the freshness-gated lookup verbs.
2026-05-24 21:50:39 +00:00
pre-commit-ci[bot]
64a400be2d [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 21:35:08 +00:00
Daniel Han
6ea922a58d Studio: r20 fixes - skip markup-count when real artifact exists
When a real complete artifact is already in the response, prose
mentions of bare <html> / <svg> tags in explanatory text are common
(for example "Use the <html> tag for the root"). The unbalanced-
open/close count would falsely classify the response as mid-stream
and wipe the valid answer. The artifact-counting cross-check now
only runs when NO real artifact has been emitted yet; once a real
artifact exists, mid-stream second markup is rare enough that the
count-based detector is not worth the false-positive cost.

This also unblocks complete <html> answers that nest <svg> children
or contain JS string literals like "<svg width=10>", since those
unmatched markup tokens were being flagged as unclosed.
2026-05-24 21:34:52 +00:00
pre-commit-ci[bot]
ae8f70f05e [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 21:22:40 +00:00
Daniel Han
2ea2f3519a Studio: r19 fixes - cross-strip closed artifacts, iterate skeleton matches, bare-intent colon plan
- _has_answer_artifact() now strips closed code fences before checking
  for unclosed markup, and strips closed markup before checking for
  unclosed code fences. A Python / JS snippet containing literal
  "<html>" / "<svg>" strings no longer trips the unclosed-markup
  cross-check, and complete HTML containing a JS string with literal
  backticks no longer trips the unclosed-fence cross-check.

- _looks_like_real_artifact() iterates every artifact match. An empty
  <html></html> / <svg></svg> skeleton followed by a real complete
  page no longer hides the real artifact.

- _is_empty_markup_skeleton() strips an optional <!doctype ...> prefix
  before testing the empty-skeleton pattern, so
  "<!doctype html><html></html>" plan-only mentions also re-prompt.

- _BARE_INTENT_NUMBERED_PLAN catches the tight "I'll:\n1. Open ..." /
  "Let me:\n1. Parse ..." shape where bare first-person intent +
  colon + newline is immediately followed by numbered action items.
  No work verb is required between the intent and the list.
2026-05-24 21:22:26 +00:00
pre-commit-ci[bot]
7c08ade3a4 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 21:11:13 +00:00
Daniel Han
4809503cce Studio: r18 fixes - unbalanced markup detection, empty-skeleton reject, list-item verb cross-check
- _has_unclosed_markup_block() now compares open / close tag counts.
  A response with one closed <html> followed by a second still-open
  <html> (multi-page mid-stream) or <svg></svg><svg> is unbalanced,
  so the artifact path returns False and the re-prompt fires. The
  helper now runs BEFORE _HAS_ANSWER_ARTIFACT so an earlier complete
  artifact cannot mask a later open block.

- _looks_like_real_artifact() rejects empty <html></html> /
  <svg></svg> skeletons. Plan-only mentions ("First, I'll create an
  <html></html> skeleton, then add CSS.") no longer suppress the
  re-prompt.

- _NUMBERED_ACTION_ITEM + _STRONG_INTENT_BEFORE_LIST catches plans
  where the work verbs sit in the list ITEMS rather than before the
  list (e.g. "First, I'll:\n1. Load the CSV.\n2. Compute total").
  The verb whitelist is intentionally narrow (load, parse, calculate,
  compute, analyze, run, execute, fetch, download, query, inspect,
  extract) so ordinary algorithm answers ("First, use binary search:
  1. Search the left half") stay valid. The intent gate excludes
  bare "First" / "Step N:" for the same reason - direct first-person
  pronoun is required.
2026-05-24 21:10:59 +00:00
pre-commit-ci[bot]
aba4824de2 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 20:59:40 +00:00
Daniel Han
e078605936 Studio: r17 fixes - unclosed-markup cross-check, compare/review lookup verbs, defer artifact scan
- _has_unclosed_markup_block() short-circuits the numbered-list fallback
  when the response contains an open <html> or <svg> with no matching
  close. A partial markup body that happens to contain two numbered
  lines no longer reads as a final answer.

- _TOOL_ACTION_VERBS adds freshness-gated "compare" and "review" so
  plans phrased as "Compare the latest release sources" or "Review
  the current documentation" still re-prompt.

- Re-prompt call site defers the visible-artifact regex scan until
  the cheap gates (tools enabled, _reprompt_count, length window,
  intent regex) have all passed. Long final answers that can never
  re-prompt no longer pay the artifact-scan cost.
2026-05-24 20:59:26 +00:00
pre-commit-ci[bot]
2d4d7136ed [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 20:47:47 +00:00
Daniel Han
7b0ed8333a Studio: r16 fixes - inline fence tracking, broaden direct-intent plan with first/step prefixes
- _has_unclosed_code_fence() now scans every line with re.search and a
  shared FENCE_RUN regex, so an inline opening fence such as
  "First, let me write it. \`\`\`python" is tracked alongside the
  column-0 openers. A numbered list emitted INSIDE an inline-open
  fence no longer reads as a final answer.

- _DIRECT_NUMBERED_PLAN_FRAMING adds "first" and "step N(:?)" to its
  intent prefixes and "look up" to its verb whitelist. Plans like
  "First, analyze the uploaded CSV:\n1. Load rows\n2. Compute total"
  or "I'll look that up:\n1. Search the docs" now re-prompt instead
  of being mis-classified as final answers. The verb whitelist still
  excludes bare search/find/check/verify so "First, use binary
  search:\n1. Search the left half" stays an answer.
2026-05-24 20:47:34 +00:00
Daniel Han
9fa736bef1 Studio: r15 fixes - direct-intent numbered plan stalls, unclosed-fence short-circuit
- _DIRECT_NUMBERED_PLAN_FRAMING matches first-person intent ("I'll",
  "Let me", etc.) plus a narrow follow-up verb ("do this", "do these",
  "create", "build", "set up", "calculate", "parse", "run", etc.)
  followed by a numbered list. This catches stalls like "First, I'll
  do this:\n1. Search for X." or "Let me do this:\n1. Parse the
  JSON.\n2. Calculate the average." where the model announces actions
  but never invokes a tool. The verb whitelist stays narrow so
  "Let me explain" / "Let me show" / "Let me draft a poem" answers
  are NOT misclassified.

- _has_answer_artifact() now checks for an unclosed code fence BEFORE
  consulting _HAS_ANSWER_ARTIFACT. A response with one complete fence
  followed by a second, still-open fence (mid-stream multi-file
  answers) no longer suppresses the re-prompt; the unclosed second
  fence wins.
2026-05-24 20:33:15 +00:00
Daniel Han
4652a4b03c Studio: r14 fixes - longer CommonMark closing fence, explicit-plan header standalone, use-python tool wording
- _HAS_ANSWER_ARTIFACT closing fence now accepts strictly more delimiters
  than the opener (CommonMark rule). The opener stays anchored on both
  sides so a 4-open / 3-close payload still does not match, but a
  legitimate 3-open / 4-close (and 3-tilde / 4-tilde) answer is now
  recognised as a completed artifact.

- _EXPLICIT_PLAN_HEADER triggers the plan classification by itself when
  the response contains \"Here's my plan\" / \"Here's my approach\" /
  \"Here's the plan\". Numbered stalls like \"Here's my plan:\n1. Analyze\n
  2. Draft\" re-prompt again without needing a freshness-gated verb.
  Plain \"Plan:\" / \"My weekly plan:\" stay valid answers because they
  lack the possessive first-person header.

- _TOOL_ACTION_VERBS adds \"use python (tool) to ...\", \"use the python
  tool\", \"invoke the python tool\", and \"use the search tool\" so
  numbered plans that route through these phrasings still re-prompt.
2026-05-24 20:20:38 +00:00
Daniel Han
b7c7427eb8 Studio: add ReDoS regression test for full-window plan-framing scan 2026-05-24 16:17:24 +00:00
pre-commit-ci[bot]
cff8a6a1bc [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 16:00:05 +00:00
Daniel Han
e16f898c26 Studio: r13 fixes - freshness-gated lookup verbs, anchored CommonMark fences, open-fence cross-check
- _TOOL_ACTION_VERBS gates the lookup verbs (search / look up /
  browse / google / fetch / research / investigate / find / check /
  verify) on a freshness or web/internet/online target. Plain answer
  prose like \"binary search: 1. Search the left half\" or \"1. Find
  the bug\" stays a valid answer, while \"1. Search the web for X\"
  / \"1. Google the current chart\" / \"1. Research the latest docs\"
  still re-prompts. Strong unambiguous patterns (web search, query
  the web, call a tool, run python) remain bare.

- _HAS_ANSWER_ARTIFACT anchors the fence opener and closer with
  (?<!\\`) / (?!\\`) lookarounds so a 4-backtick opener cannot
  backtrack to a 3-backtick fence and treat the surplus delimiter as
  info-string text. Same rule for tildes.

- _has_answer_artifact now consults a small _has_unclosed_code_fence
  helper before the numbered-list fallback. A numbered list embedded
  INSIDE an open fence no longer masquerades as a final answer.

- Existing plan-framing tests updated to use freshness-gated lookup
  phrasing so they continue to assert the intended invariants.
2026-05-24 15:58:19 +00:00
pre-commit-ci[bot]
693bd89a79 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 15:36:23 +00:00
Daniel Han
ef3ee3d8bd Studio: r12 fixes - CommonMark 3+ fences, query/consult synonyms, full-window plan scan, visible-reasoning artifact
- _HAS_ANSWER_ARTIFACT now matches fences with three OR MORE backticks
  / tildes using a named-group backreference (CommonMark rule). Models
  routinely emit \`\`\`\` / \`\`\`\`\` when the body itself contains a triple
  fence. The previous regex only matched exactly three.

- _TOOL_ACTION_VERBS adds \"query / consult the web / internet / online
  sources\" so numbered plan stalls phrased with these synonyms still
  re-prompt instead of being read as final answers.

- _PLAN_LIST_FRAMING widens the intent-to-action scan from 80 chars to
  the full short candidate (caller already gates at _REPROMPT_MAX_CHARS
  = 2000). Realistic plans where item 1 is preamble and item 2 is the
  explicit tool action no longer slip through.

- Re-prompt call site separates VISIBLE-content artifact check from
  hidden reasoning. When content_accum is empty AND has_content_tokens
  is False, reasoning_accum is the user-visible text and counts for
  the artifact check. Otherwise reasoning stays hidden and an artifact
  inside it must not suppress the re-prompt.
2026-05-24 15:36:00 +00:00
pre-commit-ci[bot]
562af754c8 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 15:22:59 +00:00
Daniel Han
197b7755ea Merge remote-tracking branch 'origin/main' into fix-tool-reprompt-overfire 2026-05-24 15:22:38 +00:00
Daniel Han
9e79a3e2c6 Studio: harden re-prompt guard - visible-only artifact check, closing-fence end-of-line, freshness-gated find/check/verify
- Re-prompt path now treats a closed artifact in hidden reasoning as
  no artifact for the user; only visible content_accum counts. Stops
  hidden chain-of-thought from suppressing the tool-forcing nudge
  when content_accum is empty.

- Closed backtick / tilde fences must end the line cleanly. Trailing
  prose after the closing fence (```not actually closed) no longer
  reads as a complete artifact.

- _TOOL_ACTION_VERBS admits find / check / verify only when paired
  with a freshness signal (current / latest / today / up-to-date /
  live / online / web). Numbered plan stalls like \"1. Find the
  current Billboard chart\" re-prompt again, while \"1. Find the
  bug\" / \"2. Check the answer\" stay valid answer text.
2026-05-24 15:22:21 +00:00
pre-commit-ci[bot]
94e7e12aaf [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 15:08:32 +00:00
Daniel Han
d38bb3f077 Studio: revert Plan: / "Here is the plan" intent and narrow plan verbs
Reviewer round 9 (5 of 10 reviewers) flagged that the new bare
``Plan:`` / ``Approach:`` / ``Here is the plan`` intent branches
reintroduced the original "wipe a complete answer" failure for
realistic final answers whose topic happens to contain a tool-action
word. Triggers for prompts like "Create a lesson plan for teaching
search skills" when the model answers:

  Plan:
  1. Search skills: students learn query keywords.
  2. Source evaluation: compare domains.
  3. Reflection: write what worked.

``_INTENT_SIGNAL`` matched the new ``Plan:`` lookahead because
``search`` appears within 120 chars, then ``_PLAN_LIST_FRAMING``
disqualified the numbered list, and the synthetic STOP turn wiped a
valid answer.

Revert the additions in ``_INTENT_SIGNAL``:
  * Drop ``Plan:`` / ``Approach:`` (newline + action-verb lookahead).
  * Drop ``Here is the plan`` / ``Here are my steps`` (action-verb
    lookahead).

Plan stalls phrased with explicit first-person intent ("I'll search...",
"First, I'll fetch...", "Let me look up...") are still caught by the
existing intent patterns and ``_PLAN_LIST_FRAMING``.

Also narrow the plan-list action-verb whitelist to tool-specific verbs
(``search`` / ``look up`` / ``fetch`` / ``browse`` / ``web search`` /
``call (a) tool`` / ``run python`` / ``execute python``). Broad verbs
like ``use`` / ``compare`` / ``check`` / ``find`` / ``think`` /
``respond`` / ``answer`` / ``analyse`` / ``explore`` / ``outline`` /
``reason`` are removed because real answer lists use them ("1. Use
BFS", "1. Compare versions").

Finally, fix the test module's ``loggers`` / ``structlog`` stub
injection to only fire when the real module is missing AND to set
``__path__ = []`` on the stub. Previously the bare ``ModuleType`` could
poison ``sys.modules`` for any later test that imports a real
submodule (``from loggers.handlers import ...``).

Net behavioural change vs the previous commit: stricter on what
counts as a plan stall, never wipes a final answer titled
``Plan:`` / ``My plan:`` / ``Here is the plan you asked for``.
2026-05-24 15:08:18 +00:00
Daniel Han
a6f6022bd0 Studio: require nearby action verb for Plan: / "Here is the plan" intents
Reviewer round 8 surfaced a real false positive in the previous commit:
a final answer naturally titled "Plan:" / "My plan:" / "Approach:" with
numbered content items now slipped through _INTENT_SIGNAL and got
wiped by the synthetic STOP turn. Examples:

  Plan:
  1. Warm-up: Students review fractions.
  2. Group practice.
  3. Assessment.

  My plan:
  1. Breakfast: oatmeal and fruit.
  2. Lunch: rice bowl.
  3. Dinner: lentil soup.

  Here is the plan you asked for. It is two pages long.

Add a lookahead requiring one of the conservative re-prompt action
verbs (search / fetch / verify / look up / call / compare / think /
respond / etc.) to appear within 120 chars after the "Plan:" /
"Approach:" / "Here is the plan" / "Here are my steps" marker. Plan
stalls whose items are tool actions ("Plan:\n1. search the docs\n2.
summarise the result") still match and re-prompt; prose plans whose
items are content do not.

Also mirror "first" in _PLAN_LIST_FRAMING so numbered action plans
that start with "First" stay disqualified even after the helper enters
the numbered-list branch.

Factor the action-verb set out as _REPROMPT_ACTION_VERBS so both
regexes share one source of truth.

Six new regression samples: three lesson / meal / weather plans that
must NOT wipe, three action-plan headers that must re-prompt, three
prose "Here is the plan" answers that must not wipe.
2026-05-24 14:56:04 +00:00
Daniel Han
3dc26e7acf Studio: require newline after Plan: / Approach: header so inline product text does not re-prompt
After narrowing the colon marker to lines starting with a generic
determiner ("My plan:" / "The approach:" / ...), inline product or
pricing answers like "Your current Plan: Pro includes local chats",
"The plan: Basic is free, Pro is $10/month", or
"My plan: use dynamic programming" still slipped into the re-prompt
path and could wipe a valid answer.

Add a lookahead requiring a newline (with optional trailing horizontal
whitespace) after the colon, so only header-style framings like
"Plan:\n1. search\n2. summarise" or "My approach:\n1. fetch" count.
Inline "Plan: <text>" is now treated as ordinary prose.

Add eight regression samples (lesson plan, meal plan, marketing plan,
pricing plan, recommended approach, migration plan, dynamic-programming
plan, currently active plan) all of which previously re-prompted under
the unanchored matcher and now correctly do not.
2026-05-24 14:41:44 +00:00
Daniel Han
64ae2ac4c5 Studio: sync re-prompt guard intent forms and tighten Plan: anchor
Two more gaps surfaced by another reviewer sweep on the previous commit:

1. _PLAN_LIST_FRAMING was missing several intent forms that
   _INTENT_SIGNAL accepts, so numbered tool-action plans phrased with
   "Allow me", "I'm going to", "I'm gonna", "I am gonna", or "I shall"
   were silently classified as completed answers and skipped the
   tool-call re-prompt. Mirror the full intent set from _INTENT_SIGNAL
   so the two regexes stay in lock-step.

2. Bare \b(?:plan|approach): in _INTENT_SIGNAL / _PLAN_LIST_FRAMING
   matched any in-text occurrence of "plan:" / "approach:", including
   "lesson plan:" / "meal plan:" / "migration plan:". A direct answer
   like "Here is a lesson plan:\n1. Warm-up\n2. Group practice" would
   trip _INTENT_SIGNAL and risk wiping the response. Anchor the colon
   marker to start of line and only allow generic determiners (my, the,
   our, a, this, that) between the line start and the keyword.

3. Add "Here is the plan" / "Here are my steps" to both _INTENT_SIGNAL
   and _PLAN_LIST_FRAMING so non-apostrophe phrasings of the same
   framing pattern are caught.

Added regression tests covering every intent form against a numbered
action plan, and a line-anchor test that distinguishes generic plan
framings ("My plan:", "The approach:") from content noun phrases
("lesson plan:", "meal plan:").
2026-05-24 14:36:04 +00:00
Daniel Han
a2ab9895b1 Studio: require apostrophe in _PLAN_LIST_FRAMING i'll alternative
The follow-up commit used ``i['’]?ll`` (apostrophe optional) in
``_PLAN_LIST_FRAMING``. With the apostrophe optional the alternative
also matches the word "ill" (sick), so a response like
"She is ill. Here is the list:\n1. ...\n2. ..." plus an unrelated
action verb within 80 chars was misclassified as a plan and re-prompted.

Make the apostrophe required (``i['’]ll``) to mirror the original
_INTENT_SIGNAL definition. Add a regression test that pins the
distinction: "ill" as adjective does not trigger plan framing, but
"I'll" / "I will" do.
2026-05-24 14:29:46 +00:00
Daniel Han
4165878734 Studio: extend re-prompt guard for tilde fences, Plan: intent, and contemplative verbs
Three follow-up gaps surfaced by another reviewer sweep on the previous commit:

1. Tilde-fenced code (~~~lang ... ~~~) was not detected. CommonMark allows
   it and several models emit it when the body itself contains backticks.
   Add a tilde alternative to _HAS_ANSWER_ARTIFACT mirroring the backtick
   form (any info string, optional indent on close, length-bounded body).

2. Bare "Plan:" / "Approach:" lines did not match _INTENT_SIGNAL, so a
   "Plan:\n1. search\n2. summarise" stall slipped past the entry gate
   entirely. Add the colon form to the step / plan framing alternative.

3. The plan-framing verb whitelist missed common contemplative verbs
   (think / respond / answer / analy[sz]e / explore / outline / gather /
   query / reason) so plan stalls phrased without explicit "Here's my
   plan" framing were misclassified as completed answers. Keep the
   whitelist conservative: write / create / make / build / read / list /
   try are intentionally out because real answer lists use them
   ("1. Write a poem", "1. Read War and Peace").

Added regression tests for each fix plus an extra ReDoS budget test for
the doctype/<html alternation worst case (about 7 ms today; assert < 50
ms so a future quantifier change that drops the inner {0,4000} bound
fails loudly).
2026-05-24 14:25:35 +00:00
pre-commit-ci[bot]
c2c448684c [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 14:06:25 +00:00
Daniel Han
69ef56edb3 Studio: tighten re-prompt artifact guard for non-alpha fences, planning lists, incomplete HTML
Addresses three follow-ups flagged on the first cut of this PR by static
reviewers and parallel reviewer runs:

1. Numbered plan-only stalls were treated as completed answers. A
   response like `Here's my plan:\n1. Search the web\n2. Summarise`
   matched both `_INTENT_SIGNAL` and the numbered-list branch of
   `_HAS_ANSWER_ARTIFACT`, so the tool-forcing re-prompt was skipped.
   That contradicted the PR's stated invariant that plan-only stalls
   still re-prompt. The list now has to be paired with no plan framing
   (no `Here's my plan` / `plan:` / `approach:`, no intent phrase
   followed by a tool-action verb) to count as an artifact.

2. Closed code fences with non-alpha info strings (`python3`, `c++`,
   `c#`, `objective-c`, `ts-node`, `bash-session`, `python linenums="1"`)
   were not recognised by the `[a-zA-Z]*` info-string class. Complete
   answers in those languages still re-prompted and could be wiped.
   The info-string class is now `[^\r\n]{0,200}` and the closing fence
   may be indented.

3. Bare `<!doctype` or `<html` text was treated as an artifact. A
   plan-only response that mentions `<html>` in prose now no longer
   bypasses the re-prompt; the HTML branch requires a closing
   `</html>` (doctype prefix optional).

All `[\s\S]{...}?` runs are length-bounded so ReDoS-style adversarial
input stays linear. ReDoS guard tests cover CRLF spam and repeated
`<html ` openings without close.
2026-05-24 14:04:49 +00:00
pre-commit-ci[bot]
6639a3b31a [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-23 14:00:39 +00:00
Daniel Han
2db8b81854 Studio: harden re-prompt artifact regex for CRLF + catastrophic backtracking
Two robustness fixes for the `_HAS_ANSWER_ARTIFACT` regex from the
parent commit, both caught by a thorough simulation suite covering
Linux/Mac/Windows line-ending portability and adversarial inputs.

1. **CRLF line endings.** The original `\n` literals missed Windows-
   authored or CRLF-converted content (model echoing a pasted prompt,
   etc.). Replaced with `\r?\n` everywhere a newline is required, so
   closed code fences, numbered lists, and end-to-end re-prompt
   decisions all work on `\r\n` as well as `\n`.

2. **Catastrophic backtracking on whitespace spam.** The numbered-list
   alternative `(?:^|\r?\n)\s*\d+\.\s+\S.*?\r?\n\s*\d+\.` was
   O(n^2) on long whitespace runs: `\s*` greedy + `\d+` failing +
   `\s` matching `\r\n` led to repeated backtracking through the
   newline characters. Measured at ~630ms for 10KB of `\r\n` repeats.
   Fix: restrict the post-newline indent to `[ \t]*` (spaces / tabs
   only). After `\r?\n` we are at column 0 and only spaces / tabs
   are a sensible leading indent for a list item; greedy whitespace
   was never needed. New worst case on the same input: <1ms (1000x
   speedup).

Added 5 in-tree tests:
  - test_artifact_regex_handles_crlf_code_fence
  - test_artifact_regex_handles_crlf_numbered_list
  - test_artifact_regex_handles_mixed_lf_crlf
  - test_no_backtrack_on_crlf_spam (asserts <50ms on 10KB \r\n)
  - test_no_reprompt_on_crlf_complete_python_game

All 18 reprompt-guard tests pass. All 253 llama_cpp-related tests pass.
Out-of-tree simulation suite (84 tests) passes on both Python 3.12 and
Python 3.13 inside isolated uv venvs.
2026-05-23 14:00:39 +00:00
pre-commit-ci[bot]
cb6ebc032a [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-23 14:00:39 +00:00
Daniel Han
078ae64cdf Studio: don't re-prompt after model already produced a complete answer
The plan-without-action re-prompt at
`studio/backend/core/inference/llama_cpp.py` fires when the model
emits intent-only language ("first I'll ...", "let me ...") without
calling a tool. Previously the heuristic only checked an intent regex
and a 2000-char length cap. The same intent words occur in long
explanations that accompany REAL code or markup, so a complete reply
like "First, let me set up pygame. ```python ... ```" still tripped
the re-prompt, and the synthetic follow-up ("STOP. Do NOT write code
or explain.") wiped the user-visible answer.

Reproduced at scale in a 900-run sweep across 15 Qwen3.5/3.6 GGUF
configs: prompts that emit code or markup (Create a Python game,
Create a Flappy Bird game, weather dashboard HTML, sloth SVG)
landed empty `final_text` for the majority of seeds even on the
strongest configs.

Fix adds a `_HAS_ANSWER_ARTIFACT` regex covering:
  - closed code fences (```...```)
  - HTML pages (<!doctype, <html)
  - complete SVG (<svg...</svg>)
  - 2+ item numbered lists

and a `and not _HAS_ANSWER_ARTIFACT.search(_stripped)` guard on the
re-prompt condition. Plan-only stalls still re-prompt; complete
responses no longer do.

13 new unit tests in `test_llama_cpp_reprompt_guard.py` pin both
directions (artifact present -> no re-prompt; plan-only -> still
re-prompts).
2026-05-23 14:00:39 +00:00
2 changed files with 1685 additions and 6 deletions

View file

@ -52,6 +52,34 @@ logger = get_logger(__name__)
# ── Pre-compiled patterns for plan-without-action re-prompt ──
# Tool-action verbs used by _PLAN_LIST_FRAMING to distinguish plan-only
# numbered lists ("1. search the web for X", "1. query the internet")
# from answer numbered lists ("1. Search the left half", "1. Apple",
# "1. Write a poem"). Each lookup verb is gated on a freshness or
# web/internet/online target so ordinary answer prose like
# "binary search: 1. Search the left half" or "1. Find the bug" is
# preserved. The strong, unambiguous patterns (``web search``,
# ``query the web``, ``call a tool``, ``run python``) stay bare.
_TOOL_LOOKUP_TARGET = (
r"(?:web|internet|online(?: sources?)?|"
r"current|latest|today[']?s?|up[- ]to[- ]date|live)"
)
_TOOL_ACTION_VERBS = (
r"web[ _-]?search|"
r"(?:search|look up|browse|google) (?:for )?(?:the |a |an )?"
rf"{_TOOL_LOOKUP_TARGET}|"
r"(?:query|consult) (?:the |a |an )?"
r"(?:web|internet|online(?: sources?)?)|"
r"fetch (?:the |a |an )?"
rf"{_TOOL_LOOKUP_TARGET}|"
r"(?:research|investigate|find|check|verify|compare|review) "
r"(?:for )?(?:the |a |an )?"
rf"{_TOOL_LOOKUP_TARGET}|"
r"(?:use|invoke|call) (?:the )?(?:python|search) tool|"
r"use python(?: tool)? to|"
r"call (?:a |the )?tool|run (?:python|the code)|execute (?:python|the code)"
)
# Forward-looking intent signals that indicate the model is
# describing what it *will* do rather than giving a final answer.
_INTENT_SIGNAL = re.compile(
@ -62,7 +90,7 @@ _INTENT_SIGNAL = re.compile(
# appear frequently in direct answers / explanations.
r"\b(i['\u2019](ll|m going to|m gonna)|i am (going to|gonna)|i will|i shall|let me|allow me)\b"
r"|"
# Step/plan framing: "First ...", "Step 1:", "Here's my plan"
# Step/plan framing: "First ...", "Step 1:", "Here's my plan".
r"\b(?:first\b|step \d+:?|here['\u2019]?s (?:my |the |a )?(?:plan|approach))"
r"|"
# "Now I" / "Next I" patterns
@ -71,6 +99,324 @@ _INTENT_SIGNAL = re.compile(
)
_MAX_REPROMPTS = 3
# Substantive answer artifacts. Re-prompt fires when the model emits
# intent-only language ("first I'll ...", "let me ...") without a tool
# call, but the same intent words appear in long explanations that
# accompany REAL code or markup. Without this guard, a complete reply
# like "First, let me set up pygame. ```python ... ```" trips the
# re-prompt and the next user-visible message wipes the code. We
# require ALL of (intent signal, length < _REPROMPT_MAX_CHARS, no
# answer artifact) to fire.
#
# Notes on the patterns:
# * `\r?\n` everywhere a newline is required so Windows-authored or
# CRLF-converted content still matches.
# * Code-fence info string is `[^\r\n]{0,200}` so common languages with
# digits / symbols (python3, c++, c#, objective-c, ts-node, ...) are
# all recognised; closing fence may be indented (` ``` ` inside a
# list or blockquote).
# * HTML branches require a closing `</html>` so plan-only mentions of
# `<html>` or `<!doctype>` do not bypass the re-prompt.
# * All `[\s\S]{...}?` runs are length-bounded so the search stays
# linear on adversarial input (CRLF spam, repeated `<html>` etc.).
_CLOSED_CODE_FENCE = re.compile(
r"(?<!`)(?P<bf>`{3,})(?!`)[^\r\n]{0,200}\r?\n[\s\S]{1,4000}?\r?\n[ \t]*(?P=bf)`*[ \t]*(?:\r?\n|\Z)"
r"|(?<!~)(?P<tf>~{3,})(?!~)[^\r\n]{0,200}\r?\n[\s\S]{1,4000}?\r?\n[ \t]*(?P=tf)~*[ \t]*(?:\r?\n|\Z)",
re.IGNORECASE,
)
_CLOSED_MARKUP_ARTIFACT = re.compile(
r"(?:<!doctype\b[\s\S]{0,200}?)?<html\b[\s\S]{0,4000}?</html>"
r"|<svg\b[\s\S]{0,4000}?</svg>",
re.IGNORECASE,
)
_HAS_ANSWER_ARTIFACT = re.compile(
# Closed backtick code fence (any markdown info string, optional indent
# on close). CommonMark allows opening fences of 3+ backticks; the
# closing fence must have at least as many delimiters, and the line
# must end cleanly (only trailing whitespace before newline / EOS),
# so spam like ``` ```not actually closed ``` does not count.
r"(?<!`)(?P<bf>`{3,})(?!`)[^\r\n]{0,200}\r?\n[\s\S]{1,4000}?\r?\n[ \t]*(?P=bf)`*[ \t]*(?:\r?\n|\Z)"
# Closed tilde code fence; same 3+ rule (several models emit ~~~ when
# the body itself contains backticks). Opener anchored to the full
# run of tildes; closer accepts >= opener length per CommonMark.
r"|(?<!~)(?P<tf>~{3,})(?!~)[^\r\n]{0,200}\r?\n[\s\S]{1,4000}?\r?\n[ \t]*(?P=tf)~*[ \t]*(?:\r?\n|\Z)"
# Complete HTML page; doctype prefix is optional.
r"|(?:<!doctype\b[\s\S]{0,200}?)?<html\b[\s\S]{0,4000}?</html>"
# Complete SVG document.
r"|<svg\b[\s\S]{0,4000}?</svg>",
re.IGNORECASE,
)
# Two or more numbered list items at column 0. Indent is spaces / tabs
# only so the regex stays linear on long whitespace runs.
_NUMBERED_LIST_ARTIFACT = re.compile(
r"(?:^|\r?\n)[ \t]*\d+\.[ \t]+\S.*?\r?\n[ \t]*\d+\.",
)
# Markers that a numbered list is a plan (still re-promptable), not a
# final answer. Fires when an intent phrase from _INTENT_SIGNAL is
# followed anywhere in the short re-prompt candidate by a tool-action
# verb. The apostrophe in ``i[']ll`` is required (no ``?``) so the
# regex does not accidentally match the word "ill". Without a tool-
# action verb the numbered list is treated as a completed answer
# artifact. The scan window is bounded at _REPROMPT_MAX_CHARS by the
# caller, so the lazy ``[\s\S]{0,2000}?`` quantifier stays linear.
_PLAN_LIST_FRAMING = re.compile(
r"\b(?:here[']?s (?:my |the |a )?(?:plan|approach)|"
r"step \d+|first|"
r"i['](?:ll|m going to|m gonna)|i am (?:going to|gonna)|"
r"i will|i shall|let me|allow me|now i|next i)\b"
r"[\s\S]{0,2000}?"
rf"\b(?:{_TOOL_ACTION_VERBS})\b",
re.IGNORECASE,
)
# "Here's my plan" / "Here's my approach" are strong stand-alone plan
# signals: a possessive, first-person framing where the model is
# announcing what it WILL do. Treat the following numbered list as a
# plan regardless of the specific verbs each item uses, so stalls like
# ``Here's my plan: 1. Analyze 2. Draft`` still re-prompt.
_EXPLICIT_PLAN_HEADER = re.compile(
r"\bhere[']?s (?:my |the |a )?(?:plan|approach)\b",
re.IGNORECASE,
)
# Direct first-person intent + a tool/work verb that the model is about
# to perform + a numbered list. Catches stalls like
# ``First, I'll do this:\n1. Search ...`` or ``Let me do this:\n1. Parse
# the file ...`` where each list item is an action the model promised
# to take without actually invoking a tool. The first-person intent
# branch tolerates a broad set of work verbs (open/read/search/check/
# review/inspect/etc.) because direct first-person announcements are
# strongly plan-like; the "First, ..." / "Step N:" branch stays
# narrow so algorithmic answers ("First, use binary search:") are
# preserved.
_DIRECT_NUMBERED_PLAN_FRAMING = re.compile(
r"(?:"
r"\b(?:i['](?:ll|m going to|m gonna)|i am (?:going to|gonna)|"
r"i will|i shall|let me|allow me|now i|next i)\b"
r"[^\r\n]{0,160}"
r"\b(?:open|read|search|look (?:this |that |it |them )?up|browse|"
r"google|find|check|verify|compare|review|inspect|examine|"
r"visit|access|navigate|gather|collect|"
r"do (?:this|these|the following|it)|"
r"(?:take|follow|complete|perform) (?:these|the following) (?:steps|actions)|"
r"proceed|start|begin|"
r"create|build|implement|set up|add|calculate|compute|analy[sz]e|"
r"parse|load|run|execute|test)\b"
r"|"
r"\b(?:first|step \d+:?)\b"
r"[^\r\n]{0,160}"
r"\b(?:do (?:this|these|the following|it)|"
r"look (?:this |that |it |them )?up|"
r"proceed|start|begin|"
r"create|build|implement|set up|add|"
r"calculate|compute|analy[sz]e|parse|load|run|execute|test)\b"
r")"
r"[\s\S]{0,500}?"
r"(?:^|\r?\n)[ \t]*\d+\.",
re.IGNORECASE,
)
_FENCE_RUN_RE = re.compile(
r"(?<!`)(?P<backticks>`{3,})(?!`)|(?<!~)(?P<tildes>~{3,})(?!~)"
)
def _has_unclosed_code_fence(text: str) -> bool:
"""True if ``text`` contains a code fence whose closer is missing.
Each line is scanned with ``search`` so inline openers like
``First. \\`\\`\\`python`` are tracked. To avoid reading prose
mentions of triple backticks as openers, an INLINE fence (fence
not at line start) is only accepted when its trailing characters
look like a clean CommonMark info-string token with no internal
whitespace. Column-0 fences always count, so multi-token info
strings like ``\\`\\`\\`python linenums=1`` still work.
"""
active_char: Optional[str] = None
active_len = 0
for line in text.splitlines():
m = _FENCE_RUN_RE.search(line)
if not m:
continue
fence = m.group("backticks") or m.group("tildes")
raw_trailing = line[m.end() :]
trailing = raw_trailing.strip()
ch = fence[0]
is_inline = bool(line[: m.start()].strip())
# Inline + multi-word trailing or leading-space trailing both
# read as prose ("Use ``` to start", "Use ```python to open").
if is_inline:
if raw_trailing and raw_trailing[0] == " " and trailing:
continue
if trailing and (" " in trailing or "\t" in trailing):
continue
if active_char is None:
active_char = ch
active_len = len(fence)
elif ch == active_char and len(fence) >= active_len and not trailing:
active_char = None
active_len = 0
return active_char is not None
def _has_unclosed_markup_block(text: str) -> bool:
"""True if ``text`` opens an <html>/<svg> block without closing it.
Either a missing close on the only block, OR a closed block followed
by a still-open block, qualifies. The check is unbalanced-count
based so half-finished output ALWAYS disqualifies the artifact path,
even when an earlier complete artifact is also present in the same
response.
"""
opens_html = len(re.findall(r"<html\b", text, re.IGNORECASE))
closes_html = len(re.findall(r"</html>", text, re.IGNORECASE))
if opens_html > closes_html:
return True
opens_svg = len(re.findall(r"<svg\b", text, re.IGNORECASE))
closes_svg = len(re.findall(r"</svg>", text, re.IGNORECASE))
return opens_svg > closes_svg
# Matches the full span of an empty <html></html> or <svg></svg>
# skeleton. Plan-only mentions ("First, I'll create an <html></html>
# skeleton") would otherwise look like a complete page.
_EMPTY_MARKUP_SKELETON = re.compile(
r"<(html|svg)\b[^>]*>\s*</\1>",
re.IGNORECASE,
)
_DOCTYPE_PREFIX = re.compile(
r"^<!doctype\b[\s\S]{0,200}?>",
re.IGNORECASE,
)
def _is_empty_markup_skeleton(matched: str) -> bool:
"""True if ``matched`` is just an empty <html></html> / <svg></svg>
(optionally with a `<!doctype>` prefix and surrounding whitespace).
These read as plan-only mentions, not substantive answers."""
candidate = _DOCTYPE_PREFIX.sub("", matched.strip(), count = 1).strip()
return _EMPTY_MARKUP_SKELETON.fullmatch(candidate) is not None
# A numbered list whose item lines start with a strong work / tool
# verb. Combined with first-person intent framing this catches stalls
# like "First, I'll:\n1. Load the CSV\n2. Compute the total" where the
# verbs sit in the list items rather than before the list. The verb
# list deliberately excludes ``search`` / ``look up`` / ``read`` /
# ``open`` / ``create`` / ``build`` etc. so ordinary algorithm or
# instructional answers ("1. Search the left half", "1. Read the
# docs") stay valid answers.
_LOCAL_ACTION_VERBS = (
r"load|inspect|parse|"
r"calculate|compute|analy[sz]e|extract|"
r"run|execute|fetch|download|query|"
r"gather|collect|identify"
)
_NUMBERED_ACTION_ITEM = re.compile(
rf"(?:^|\r?\n)[ \t]*\d+\.[ \t]+(?:{_LOCAL_ACTION_VERBS})\b",
re.IGNORECASE,
)
# Direct first-person pronoun intent only. "First," and "Step N:" are
# intentionally excluded here because they appear in non-plan answers
# ("First, use binary search:") and would over-trigger the items-in-
# numbered-list cross-check.
_STRONG_INTENT_BEFORE_LIST = re.compile(
r"\b(?:i['](?:ll|m going to|m gonna)|i am (?:going to|gonna)|"
r"i will|i shall|let me|allow me|now i|next i)\b",
re.IGNORECASE,
)
# Bare first-person intent immediately followed by ``:`` and a numbered
# list whose first item begins with a work verb. Catches
# ``I'll:\n1. Open the URL`` and ``Let me:\n1. Parse the JSON`` where
# no work verb appears between the intent phrase and the list. The
# verb set here is broader than _LOCAL_ACTION_VERBS because the
# tight ``intent + : + newline + numbered`` shape is itself the strong
# signal that this is a tool stall.
_BARE_INTENT_NUMBERED_PLAN = re.compile(
r"\b(?:i['](?:ll|m going to|m gonna)|i am (?:going to|gonna)|"
r"i will|i shall|let me|allow me|now i|next i)\s*:[ \t]*"
r"(?:\r?\n)[ \t]*\d+\.[ \t]+"
r"(?:open|read|search|"
r"look (?:this |that |it |them )?up|"
r"check|verify|create|build|add|set up|"
r"load|inspect|parse|calculate|compute|analy[sz]e|extract|run|execute|"
r"fetch|download|query|summari[sz]e|implement|generate|draft|write|"
r"visit|access|navigate|gather|collect|identify|update|edit)\b",
re.IGNORECASE,
)
def _looks_like_real_artifact(text: str) -> bool:
"""Match _HAS_ANSWER_ARTIFACT but reject empty markup skeletons.
Iterates every artifact match in ``text`` so an empty <html></html>
skeleton followed by a real complete page still classifies as a
real artifact (the second match wins)."""
for m in _HAS_ANSWER_ARTIFACT.finditer(text):
if not _is_empty_markup_skeleton(m.group(0)):
return True
return False
def _has_answer_artifact(text: str) -> bool:
"""True if ``text`` looks like a completed answer artifact.
Code fences, complete HTML, and complete SVG count directly. A
numbered list counts only when there is no plan framing, so stalls
like ``Here's my plan:\\n1. search\\n2. summarise`` still re-prompt.
An explicit ``Here's my plan`` / ``Here's my approach`` header, or a
direct first-person ``I'll do this:\\n1. ...`` framing with a
work/tool verb before the list, also flags the list as a plan even
when no narrow tool-action verb appears in the items. Any unclosed
fence or unclosed `<html>` / `<svg>` block disqualifies the
artifact path so half-finished output does not look like a final
answer, even when an earlier complete artifact is also present.
Empty `<html></html>` / `<svg></svg>` skeletons do not count.
"""
# Cross-strip closed artifacts before the unclosed-state checks so
# delimiter-like content INSIDE a complete code fence (e.g.
# `html = '<html>'` literal in a Python snippet) or INSIDE complete
# HTML (e.g. a JS string containing backticks) does not falsely
# disqualify the artifact path.
text_without_closed_fences = _CLOSED_CODE_FENCE.sub("", text)
text_without_closed_markup = _CLOSED_MARKUP_ARTIFACT.sub("", text)
text_without_both = _CLOSED_MARKUP_ARTIFACT.sub("", text_without_closed_fences)
if _has_unclosed_code_fence(text_without_closed_markup):
return False
# When NO complete artifact has been emitted yet, count-based markup
# detection is reliable for spotting mid-stream output. Once a real
# artifact already exists, prose mentions of bare ``<html>`` /
# ``<svg>`` tags in explanations are common (and would falsely
# unbalance the open/close count), so we rely on the closed-artifact
# path instead and skip the count check.
real_artifact = _looks_like_real_artifact(text)
if not real_artifact and _has_unclosed_markup_block(text_without_both):
return False
if real_artifact:
return True
if _NUMBERED_LIST_ARTIFACT.search(text):
if _EXPLICIT_PLAN_HEADER.search(text):
return False
if _DIRECT_NUMBERED_PLAN_FRAMING.search(text):
return False
if _BARE_INTENT_NUMBERED_PLAN.search(text):
return False
# First-person pronoun intent + numbered list where the items
# themselves start with a strong work verb ("First, I'll:\n
# 1. Load...\n2. Run...") is a plan stall, even when no work
# verb appears before the list.
if _STRONG_INTENT_BEFORE_LIST.search(text) and _NUMBERED_ACTION_ITEM.search(
text
):
return False
return _PLAN_LIST_FRAMING.search(text) is None
return False
# Without max_tokens, llama-server defaults to n_predict = n_ctx (up to
# 262144 for Qwen3.5), producing many-minute zombie decodes when cancel
# fails. t_max_predict_ms is a wall-clock backstop applied unconditionally,
@ -4810,15 +5156,48 @@ class LlamaCppBackend:
# like "4" or "Hello!" won't trigger this.
# Use content if available, otherwise fall back
# to reasoning text (reasoning-only stalls).
_stripped = content_accum.strip()
if not _stripped:
_stripped = reasoning_accum.strip()
if (
# Artifact check uses USER-VISIBLE text only.
# Reasoning is only user-visible when there are
# no content tokens (the branch above yields
# reasoning_accum as plain content in that
# case); otherwise reasoning stays hidden and
# an artifact inside it must NOT suppress the
# re-prompt.
# Strip orphan tool-call XML before measuring
# the visible answer. An ``<tool_call>...</tool_call>``
# block that the route layer would scrub from the
# final visible message must not satisfy the
# artifact check.
_visible_raw = content_accum.strip()
_visible = (
_strip_tool_markup(content_accum, final = True).strip()
if _visible_raw
else ""
)
_reasoning = reasoning_accum.strip()
_stripped = _visible if _visible else _reasoning
# Cheap gates first so long final answers never
# pay the artifact-regex scan. The artifact
# check only runs when the candidate already
# passes length + intent + state checks.
_should_consider_reprompt = bool(
tools
and _reprompt_count < _MAX_REPROMPTS
and 0 < len(_stripped) < _REPROMPT_MAX_CHARS
and _INTENT_SIGNAL.search(_stripped)
):
)
if _should_consider_reprompt:
_artifact_text = (
_visible
if _visible
else (_reasoning if not has_content_tokens else "")
)
_visible_has_artifact = bool(
_artifact_text
) and _has_answer_artifact(_artifact_text)
else:
_visible_has_artifact = False
if _should_consider_reprompt and not _visible_has_artifact:
_reprompt_count += 1
logger.info(
f"Re-prompt {_reprompt_count}/{_MAX_REPROMPTS}: "

File diff suppressed because it is too large Load diff