From 078ae64cdf4171e97cacd812e991b4fcabe3658b Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 22 May 2026 15:41:11 +0000 Subject: [PATCH 01/41] 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 () - 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). --- studio/backend/core/inference/llama_cpp.py | 18 ++ .../tests/test_llama_cpp_reprompt_guard.py | 208 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 studio/backend/tests/test_llama_cpp_reprompt_guard.py diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index bf8a3c04df..ed22e329d6 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -71,6 +71,23 @@ _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. +_HAS_ANSWER_ARTIFACT = re.compile( + r"```[a-zA-Z]*\n[\s\S]+?\n```" # closed code fence + r"|" # complete SVG + r"|(?:^|\n)\s*\d+\.\s+\S.*?\n\s*\d+\.", # 2+ numbered list items + re.IGNORECASE, +) + # 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, @@ -4818,6 +4835,7 @@ class LlamaCppBackend: and _reprompt_count < _MAX_REPROMPTS and 0 < len(_stripped) < _REPROMPT_MAX_CHARS and _INTENT_SIGNAL.search(_stripped) + and not _HAS_ANSWER_ARTIFACT.search(_stripped) ): _reprompt_count += 1 logger.info( diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py new file mode 100644 index 0000000000..fbe12e9727 --- /dev/null +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -0,0 +1,208 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Tests for the plan-without-action re-prompt guard. + +The re-prompt path in ``LlamaCppEngine.chat_stream`` exists to nudge a +model that described what it *will* do (forward-looking language) +without actually calling a tool. Before the guard added in this PR, the +heuristic only checked ``len(content) < _REPROMPT_MAX_CHARS`` and the +intent regex, which over-fired on long-but-complete responses that +happened to contain phrases like "first" or "let me". Specifically, a +correct Python game answer of the form :: + + First, let me set up pygame. + ```python + import pygame; ... + ``` + +would still match (length < 2000, intent signal present) and the next +synthetic user turn ("STOP. Do NOT write code or explain.") wiped the +visible code from the conversation. + +The new ``_HAS_ANSWER_ARTIFACT`` regex blocks the re-prompt whenever +the response already contains a real answer artifact: a closed code +fence, an HTML page, a complete SVG, or a numbered list of items. +""" + +from __future__ import annotations + +import sys +import types as _types +from pathlib import Path + +_BACKEND_DIR = str(Path(__file__).resolve().parent.parent) +if _BACKEND_DIR not in sys.path: + sys.path.insert(0, _BACKEND_DIR) + +_loggers_stub = _types.ModuleType("loggers") +_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name) +sys.modules.setdefault("loggers", _loggers_stub) + +_structlog_stub = _types.ModuleType("structlog") +_structlog_stub.get_logger = lambda *a, **k: __import__("logging").getLogger("stub") +sys.modules.setdefault("structlog", _structlog_stub) + +from core.inference.llama_cpp import ( # noqa: E402 + _HAS_ANSWER_ARTIFACT, + _INTENT_SIGNAL, +) + + +# ── _INTENT_SIGNAL still matches plan-only stalls ────────────────── + + +def test_intent_signal_matches_plan_only_phrases(): + """Original behaviour is preserved: intent regex still matches the + plan-without-action phrases that motivated the re-prompt.""" + plan_only_samples = [ + "I'll search the web for that.", + "I will look that up.", + "I am going to search.", + "Let me search the web for the answer.", + "First, I need to look up the date.", + "Step 1: I'll search for the song list.", + "Now I need to call the tool.", + "Here's my plan: search for X.", + ] + for s in plan_only_samples: + assert _INTENT_SIGNAL.search(s), f"_INTENT_SIGNAL should match {s!r}" + + +def test_intent_signal_ignores_direct_answers(): + """Direct, complete answers do not match the intent regex.""" + direct_samples = [ + "4", + "Hello!", + "The answer is 42.", + "The capital of France is Paris.", + ] + for s in direct_samples: + assert not _INTENT_SIGNAL.search(s), f"_INTENT_SIGNAL must not match {s!r}" + + +# ── _HAS_ANSWER_ARTIFACT recognises substantive content ──────────── + + +def test_artifact_regex_detects_closed_code_fence(): + """Closed Python code fence is an answer artifact.""" + text = "First, let me set up pygame.\n```python\nimport pygame\npygame.init()\n```" + assert _HAS_ANSWER_ARTIFACT.search(text), ( + "Closed code fence must be detected as an answer artifact" + ) + + +def test_artifact_regex_detects_html_page(): + """HTML pages (doctype or root) are answer artifacts.""" + text_a = "" + text_b = "Sure, here is the dashboard:\n..." + assert _HAS_ANSWER_ARTIFACT.search(text_a) + assert _HAS_ANSWER_ARTIFACT.search(text_b) + + +def test_artifact_regex_detects_complete_svg(): + """A complete ... is an answer artifact.""" + text = ( + "Here is the sloth SVG:\n" + "" + "" + "" + "" + ) + assert _HAS_ANSWER_ARTIFACT.search(text) + + +def test_artifact_regex_detects_numbered_list(): + """A list of 2+ numbered items is an answer artifact.""" + text = ( + "Let me list these:\n" + "1. Animals — Maroon 5\n" + "2. Take Me to Church — Hozier\n" + "3. Love Me Like You Do — Ellie Goulding\n" + ) + assert _HAS_ANSWER_ARTIFACT.search(text) + + +def test_artifact_regex_ignores_open_code_fence(): + """An UNCLOSED code fence is not yet a complete artifact.""" + text = "Let me set up pygame.\n```python\nimport pygame" + assert not _HAS_ANSWER_ARTIFACT.search(text), ( + "Open code fence must not satisfy the artifact guard" + ) + + +def test_artifact_regex_ignores_plain_text(): + """Plain conversational text contains no artifact.""" + text = "First, I will search for the songs that charted #3 in 2015." + assert not _HAS_ANSWER_ARTIFACT.search(text) + + +# ── End-to-end guard semantics on realistic responses ────────────── + + +def _would_reprompt(content: str) -> bool: + """Return True if the re-prompt block at llama_cpp.py would fire.""" + from core.inference.llama_cpp import _REPROMPT_MAX_CHARS + stripped = content.strip() + return bool( + 0 < len(stripped) < _REPROMPT_MAX_CHARS + and _INTENT_SIGNAL.search(stripped) + and not _HAS_ANSWER_ARTIFACT.search(stripped) + ) + + +def test_no_reprompt_on_complete_python_game(): + """Response with intent phrasing + complete code does NOT re-prompt.""" + content = ( + "First, let me set up pygame.\n" + "```python\n" + "import pygame\n" + "pygame.init()\n" + "screen = pygame.display.set_mode((640, 480))\n" + "while True:\n" + " for e in pygame.event.get():\n" + " if e.type == pygame.QUIT: break\n" + "```" + ) + assert not _would_reprompt(content), ( + "Re-prompt must not fire after a complete code block was produced" + ) + + +def test_no_reprompt_on_complete_svg(): + """Response with intent phrasing + complete SVG does NOT re-prompt.""" + content = ( + "Let me draw a cute sloth:\n" + "" + "" + "" + "" + "" + "" + ) + assert not _would_reprompt(content) + + +def test_no_reprompt_on_numbered_list_answer(): + """Response with intent + numbered list (Billboard-style) does NOT re-prompt.""" + content = ( + "Here's my list of #3 hits:\n" + "1. Animals — Maroon 5\n" + "2. Take Me to Church — Hozier\n" + "3. Drag Me Down — One Direction\n" + ) + assert not _would_reprompt(content) + + +def test_reprompts_on_plan_only_stall(): + """Response that is purely a plan and no artifact STILL re-prompts.""" + content = "I'll search the web for the answer." + assert _would_reprompt(content), ( + "Plan-only stalls must still trigger the re-prompt" + ) + + +def test_reprompts_on_intent_with_open_fence(): + """Open code fence is not a complete artifact, so we still re-prompt.""" + content = "First, let me write the code.\n```python\nimport" + assert _would_reprompt(content) From cb6ebc032a57d827362e5ef18308a9c87ad9264d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 15:42:07 +0000 Subject: [PATCH 02/41] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/llama_cpp.py | 8 +++---- .../tests/test_llama_cpp_reprompt_guard.py | 23 +++++++++---------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index ed22e329d6..7e28c0db3f 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -80,11 +80,11 @@ _MAX_REPROMPTS = 3 # require ALL of (intent signal, length < _REPROMPT_MAX_CHARS, no # answer artifact) to fire. _HAS_ANSWER_ARTIFACT = re.compile( - r"```[a-zA-Z]*\n[\s\S]+?\n```" # closed code fence - r"|" # complete SVG - r"|(?:^|\n)\s*\d+\.\s+\S.*?\n\s*\d+\.", # 2+ numbered list items + r"|" # complete SVG + r"|(?:^|\n)\s*\d+\.\s+\S.*?\n\s*\d+\.", # 2+ numbered list items re.IGNORECASE, ) diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index fbe12e9727..2f161918d4 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -87,9 +87,9 @@ def test_intent_signal_ignores_direct_answers(): def test_artifact_regex_detects_closed_code_fence(): """Closed Python code fence is an answer artifact.""" text = "First, let me set up pygame.\n```python\nimport pygame\npygame.init()\n```" - assert _HAS_ANSWER_ARTIFACT.search(text), ( - "Closed code fence must be detected as an answer artifact" - ) + assert _HAS_ANSWER_ARTIFACT.search( + text + ), "Closed code fence must be detected as an answer artifact" def test_artifact_regex_detects_html_page(): @@ -126,9 +126,9 @@ def test_artifact_regex_detects_numbered_list(): def test_artifact_regex_ignores_open_code_fence(): """An UNCLOSED code fence is not yet a complete artifact.""" text = "Let me set up pygame.\n```python\nimport pygame" - assert not _HAS_ANSWER_ARTIFACT.search(text), ( - "Open code fence must not satisfy the artifact guard" - ) + assert not _HAS_ANSWER_ARTIFACT.search( + text + ), "Open code fence must not satisfy the artifact guard" def test_artifact_regex_ignores_plain_text(): @@ -143,6 +143,7 @@ def test_artifact_regex_ignores_plain_text(): def _would_reprompt(content: str) -> bool: """Return True if the re-prompt block at llama_cpp.py would fire.""" from core.inference.llama_cpp import _REPROMPT_MAX_CHARS + stripped = content.strip() return bool( 0 < len(stripped) < _REPROMPT_MAX_CHARS @@ -164,9 +165,9 @@ def test_no_reprompt_on_complete_python_game(): " if e.type == pygame.QUIT: break\n" "```" ) - assert not _would_reprompt(content), ( - "Re-prompt must not fire after a complete code block was produced" - ) + assert not _would_reprompt( + content + ), "Re-prompt must not fire after a complete code block was produced" def test_no_reprompt_on_complete_svg(): @@ -197,9 +198,7 @@ def test_no_reprompt_on_numbered_list_answer(): def test_reprompts_on_plan_only_stall(): """Response that is purely a plan and no artifact STILL re-prompts.""" content = "I'll search the web for the answer." - assert _would_reprompt(content), ( - "Plan-only stalls must still trigger the re-prompt" - ) + assert _would_reprompt(content), "Plan-only stalls must still trigger the re-prompt" def test_reprompts_on_intent_with_open_fence(): From 2db8b81854fea0689589ea359fd4e4b124236ef8 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sat, 23 May 2026 02:35:13 +0000 Subject: [PATCH 03/41] 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. --- studio/backend/core/inference/llama_cpp.py | 11 +++- .../tests/test_llama_cpp_reprompt_guard.py | 56 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 7e28c0db3f..0ed8920285 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -79,12 +79,19 @@ _MAX_REPROMPTS = 3 # 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. +# +# `\r?\n` is used everywhere a newline is required so Windows-authored or +# CRLF-converted content still matches. The numbered-list indent uses +# `[ \t]*` (spaces / tabs only) rather than `\s*` so the regex stays +# linear on long whitespace runs -- greedy `\s*` + failing `\d+` caused +# O(n^2) backtracking through embedded `\r\n` characters on adversarial +# inputs. _HAS_ANSWER_ARTIFACT = re.compile( - r"```[a-zA-Z]*\n[\s\S]+?\n```" # closed code fence + r"```[a-zA-Z]*\r?\n[\s\S]+?\r?\n```" # closed code fence r"|" # complete SVG - r"|(?:^|\n)\s*\d+\.\s+\S.*?\n\s*\d+\.", # 2+ numbered list items + r"|(?:^|\r?\n)[ \t]*\d+\.[ \t]+\S.*?\r?\n[ \t]*\d+\.", # 2+ numbered list items re.IGNORECASE, ) diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 2f161918d4..2205832272 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -205,3 +205,59 @@ def test_reprompts_on_intent_with_open_fence(): """Open code fence is not a complete artifact, so we still re-prompt.""" content = "First, let me write the code.\n```python\nimport" assert _would_reprompt(content) + + +# ── Cross-platform line endings ──────────────────────────────────── + + +def test_artifact_regex_handles_crlf_code_fence(): + """Windows / CRLF-converted content still detects a closed fence.""" + content = "First, let me code.\r\n```python\r\nimport sys\r\nprint('hi')\r\n```" + assert _HAS_ANSWER_ARTIFACT.search(content), ( + "CRLF (\\r\\n) line endings inside a code fence must still match" + ) + + +def test_artifact_regex_handles_crlf_numbered_list(): + """CRLF numbered list also matches.""" + content = "Here's the plan:\r\n1. one\r\n2. two\r\n" + assert _HAS_ANSWER_ARTIFACT.search(content) + + +def test_artifact_regex_handles_mixed_lf_crlf(): + """Mixed line endings (real-world: paste-and-edit on Windows).""" + content = "Here's the code:\r\n```python\nimport sys\r\n```" + assert _HAS_ANSWER_ARTIFACT.search(content) + + +def test_no_backtrack_on_crlf_spam(): + """10K of `\\r\\n` repeats must complete fast. + + Pre-fix the numbered-list alternative `(?:^|\\r?\\n)\\s*\\d+\\.` would + O(n^2)-backtrack on this kind of input (measured at ~630ms for 10KB + of `\\r\\n` repeats). The post-fix `[ \\t]*` indent restriction + keeps it linear. + """ + import time + payload = "\r\n" * 5000 + t0 = time.time() + _HAS_ANSWER_ARTIFACT.search(payload) + elapsed_ms = (time.time() - t0) * 1000 + assert elapsed_ms < 50, f"regex took {elapsed_ms:.1f}ms on 10KB CRLF spam" + + +def test_no_reprompt_on_crlf_complete_python_game(): + """End-to-end CRLF: complete fence -> no re-prompt.""" + content = ( + "First, let me set up pygame.\r\n" + "```python\r\n" + "import pygame\r\n" + "pygame.init()\r\n" + "while True:\r\n" + " for e in pygame.event.get():\r\n" + " if e.type == pygame.QUIT: break\r\n" + "```" + ) + assert not _would_reprompt(content), ( + "CRLF-encoded complete fence must also suppress the re-prompt" + ) From 6639a3b31a9b955bfc98959f38ed5be44c492831 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 02:36:45 +0000 Subject: [PATCH 04/41] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../backend/tests/test_llama_cpp_reprompt_guard.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 2205832272..53eb8b06bd 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -213,9 +213,9 @@ def test_reprompts_on_intent_with_open_fence(): def test_artifact_regex_handles_crlf_code_fence(): """Windows / CRLF-converted content still detects a closed fence.""" content = "First, let me code.\r\n```python\r\nimport sys\r\nprint('hi')\r\n```" - assert _HAS_ANSWER_ARTIFACT.search(content), ( - "CRLF (\\r\\n) line endings inside a code fence must still match" - ) + assert _HAS_ANSWER_ARTIFACT.search( + content + ), "CRLF (\\r\\n) line endings inside a code fence must still match" def test_artifact_regex_handles_crlf_numbered_list(): @@ -239,6 +239,7 @@ def test_no_backtrack_on_crlf_spam(): keeps it linear. """ import time + payload = "\r\n" * 5000 t0 = time.time() _HAS_ANSWER_ARTIFACT.search(payload) @@ -258,6 +259,6 @@ def test_no_reprompt_on_crlf_complete_python_game(): " if e.type == pygame.QUIT: break\r\n" "```" ) - assert not _would_reprompt(content), ( - "CRLF-encoded complete fence must also suppress the re-prompt" - ) + assert not _would_reprompt( + content + ), "CRLF-encoded complete fence must also suppress the re-prompt" From 69ef56edb3be3f5bb00b56076d0d389b84bf0079 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 14:04:49 +0000 Subject: [PATCH 05/41] 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 `` in prose now no longer bypasses the re-prompt; the HTML branch requires a closing `` (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 `` so plan-only mentions of +# `` or `` do not bypass the re-prompt. +# * All `[\s\S]{...}?` runs are length-bounded so the search stays +# linear on adversarial input (CRLF spam, repeated `` etc.). _HAS_ANSWER_ARTIFACT = re.compile( - r"```[a-zA-Z]*\r?\n[\s\S]+?\r?\n```" # closed code fence - r"|" # complete SVG - r"|(?:^|\r?\n)[ \t]*\d+\.[ \t]+\S.*?\r?\n[ \t]*\d+\.", # 2+ numbered list items + # Closed code fence (any markdown info string, optional indent on close). + r"```[^\r\n]{0,200}\r?\n[\s\S]{1,4000}?\r?\n[ \t]*```" + # Complete HTML page; doctype prefix is optional. + r"|(?:" + # Complete SVG document. + r"|", 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. Explicit "Here's my plan" / "plan:" / "approach:", OR +# intent phrasing followed shortly by a tool-action verb. +_PLAN_LIST_FRAMING = re.compile( + r"\b(?:here['’]?s (?:my |the |a )?(?:plan|approach)|step \d+|" + r"i['’]?ll|i will|i am going to|let me|now i|next i)\b" + r"[\s\S]{0,80}" + r"\b(?:search|look up|call|use|fetch|browse|run|execute|" + r"check|find|open|verify|compare|summari[sz]e)\b" + r"|\b(?:plan|approach):", + re.IGNORECASE, +) + + +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. + """ + if _HAS_ANSWER_ARTIFACT.search(text): + return True + if _NUMBERED_LIST_ARTIFACT.search(text): + 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, @@ -4842,7 +4881,7 @@ class LlamaCppBackend: and _reprompt_count < _MAX_REPROMPTS and 0 < len(_stripped) < _REPROMPT_MAX_CHARS and _INTENT_SIGNAL.search(_stripped) - and not _HAS_ANSWER_ARTIFACT.search(_stripped) + and not _has_answer_artifact(_stripped) ): _reprompt_count += 1 logger.info( diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 53eb8b06bd..49954918f0 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -20,9 +20,12 @@ would still match (length < 2000, intent signal present) and the next synthetic user turn ("STOP. Do NOT write code or explain.") wiped the visible code from the conversation. -The new ``_HAS_ANSWER_ARTIFACT`` regex blocks the re-prompt whenever -the response already contains a real answer artifact: a closed code -fence, an HTML page, a complete SVG, or a numbered list of items. +The guard recognises completed code fences (any markdown info string, +indented closing fence allowed), complete HTML documents, and complete +SVGs as answer artifacts. A numbered list is an artifact only when the +response does NOT also contain plan framing ("Here's my plan", a tool- +action verb following intent phrasing, etc.), so plan-only stalls of +the form ``Here's my plan:\\n1. search\\n2. summarise`` still re-prompt. """ from __future__ import annotations @@ -46,6 +49,9 @@ sys.modules.setdefault("structlog", _structlog_stub) from core.inference.llama_cpp import ( # noqa: E402 _HAS_ANSWER_ARTIFACT, _INTENT_SIGNAL, + _NUMBERED_LIST_ARTIFACT, + _PLAN_LIST_FRAMING, + _has_answer_artifact, ) @@ -81,23 +87,78 @@ def test_intent_signal_ignores_direct_answers(): assert not _INTENT_SIGNAL.search(s), f"_INTENT_SIGNAL must not match {s!r}" -# ── _HAS_ANSWER_ARTIFACT recognises substantive content ──────────── +# ── Code fence artifact detection ────────────────────────────────── def test_artifact_regex_detects_closed_code_fence(): """Closed Python code fence is an answer artifact.""" text = "First, let me set up pygame.\n```python\nimport pygame\npygame.init()\n```" - assert _HAS_ANSWER_ARTIFACT.search( - text - ), "Closed code fence must be detected as an answer artifact" + assert _has_answer_artifact(text) + + +def test_artifact_regex_detects_non_alpha_info_strings(): + """Common languages with digits / symbols in the fence info string + (python3, c++, c#, objective-c, ts-node, bash-session) must all be + recognised as complete code answers.""" + samples = [ + "First, let me write it.\n```python3\nprint('hi')\n```", + "First, let me write it.\n```c++\nint main() { return 0; }\n```", + "First, let me write it.\n```c#\nConsole.WriteLine(\"hi\");\n```", + "First, let me write it.\n```objective-c\nNSLog(@\"hi\");\n```", + "First, let me write it.\n```ts-node\nconsole.log('hi')\n```", + "First, let me script it.\n```bash-session\n$ echo hi\n```", + "First, let me show it.\n```python linenums=\"1\"\nprint('hi')\n```", + ] + for text in samples: + assert _has_answer_artifact(text), text + assert not _would_reprompt(text), text + + +def test_artifact_regex_detects_indented_close_fence(): + """A closing fence indented under a list / blockquote still counts. + Common when the model nests code in markdown structure.""" + text = "First, let me show:\n```python\nx = 1\n ```" + assert _has_answer_artifact(text) + + +def test_artifact_regex_ignores_open_code_fence(): + """An UNCLOSED code fence is not yet a complete artifact.""" + text = "Let me set up pygame.\n```python\nimport pygame" + assert not _has_answer_artifact(text) + + +def test_artifact_regex_ignores_plain_text(): + """Plain conversational text contains no artifact.""" + text = "First, I will search for the songs that charted #3 in 2015." + assert not _has_answer_artifact(text) + + +# ── HTML artifact detection ──────────────────────────────────────── def test_artifact_regex_detects_html_page(): - """HTML pages (doctype or root) are answer artifacts.""" + """Complete HTML pages (doctype optional, required) match.""" text_a = "" text_b = "Sure, here is the dashboard:\n..." - assert _HAS_ANSWER_ARTIFACT.search(text_a) - assert _HAS_ANSWER_ARTIFACT.search(text_b) + assert _has_answer_artifact(text_a) + assert _has_answer_artifact(text_b) + + +def test_artifact_regex_ignores_incomplete_html_mention(): + """A plan-only mention of / without close + must NOT be treated as a completed answer. Pre-fix the guard matched + bare `` skeleton, then add CSS and JavaScript.", + "First, I'll write a complete page with a button.", + "Let me design a structure for the dashboard.", + ] + for s in samples: + assert not _has_answer_artifact(s), s + + +# ── SVG artifact detection ───────────────────────────────────────── def test_artifact_regex_detects_complete_svg(): @@ -109,32 +170,57 @@ def test_artifact_regex_detects_complete_svg(): "" "" ) - assert _HAS_ANSWER_ARTIFACT.search(text) + assert _has_answer_artifact(text) -def test_artifact_regex_detects_numbered_list(): - """A list of 2+ numbered items is an answer artifact.""" +def test_artifact_regex_ignores_incomplete_svg(): + text = "Let me draw a sloth: bool: return bool( 0 < len(stripped) < _REPROMPT_MAX_CHARS and _INTENT_SIGNAL.search(stripped) - and not _HAS_ANSWER_ARTIFACT.search(stripped) + and not _has_answer_artifact(stripped) ) @@ -165,9 +251,7 @@ def test_no_reprompt_on_complete_python_game(): " if e.type == pygame.QUIT: break\n" "```" ) - assert not _would_reprompt( - content - ), "Re-prompt must not fire after a complete code block was produced" + assert not _would_reprompt(content) def test_no_reprompt_on_complete_svg(): @@ -185,12 +269,12 @@ def test_no_reprompt_on_complete_svg(): def test_no_reprompt_on_numbered_list_answer(): - """Response with intent + numbered list (Billboard-style) does NOT re-prompt.""" + """A list answer without plan framing does NOT re-prompt.""" content = ( "Here's my list of #3 hits:\n" - "1. Animals — Maroon 5\n" - "2. Take Me to Church — Hozier\n" - "3. Drag Me Down — One Direction\n" + "1. Animals - Maroon 5\n" + "2. Take Me to Church - Hozier\n" + "3. Drag Me Down - One Direction\n" ) assert not _would_reprompt(content) @@ -198,7 +282,7 @@ def test_no_reprompt_on_numbered_list_answer(): def test_reprompts_on_plan_only_stall(): """Response that is purely a plan and no artifact STILL re-prompts.""" content = "I'll search the web for the answer." - assert _would_reprompt(content), "Plan-only stalls must still trigger the re-prompt" + assert _would_reprompt(content) def test_reprompts_on_intent_with_open_fence(): @@ -207,44 +291,49 @@ def test_reprompts_on_intent_with_open_fence(): assert _would_reprompt(content) +def test_reprompts_on_numbered_plan_only_stall(): + """Numbered plan ("Here's my plan: 1. search 2. summarise") STILL + re-prompts. Pre-fix the numbered-list artifact branch suppressed + the tool-call nudge, which contradicted the PR's stated invariant.""" + content = ( + "Here's my plan:\n" + "1. Search the web for the current Billboard Hot 100 2015 data.\n" + "2. Use python to categorise the matching songs." + ) + assert _would_reprompt(content) + + +def test_reprompts_on_intent_with_numbered_action_plan(): + """Numbered list where each item is an action (search, fetch, ...) + paired with intent phrasing is treated as a plan, not an answer.""" + content = ( + "First, I'll do these:\n" + "1. Search the web\n" + "2. Compare the sources\n" + "3. Answer concisely" + ) + assert _would_reprompt(content) + + +def test_reprompts_on_incomplete_html_intent(): + """A plan-only mention of without close STILL re-prompts.""" + content = "First, I'll create an skeleton, then add CSS." + assert _would_reprompt(content) + + # ── Cross-platform line endings ──────────────────────────────────── def test_artifact_regex_handles_crlf_code_fence(): """Windows / CRLF-converted content still detects a closed fence.""" content = "First, let me code.\r\n```python\r\nimport sys\r\nprint('hi')\r\n```" - assert _HAS_ANSWER_ARTIFACT.search( - content - ), "CRLF (\\r\\n) line endings inside a code fence must still match" - - -def test_artifact_regex_handles_crlf_numbered_list(): - """CRLF numbered list also matches.""" - content = "Here's the plan:\r\n1. one\r\n2. two\r\n" - assert _HAS_ANSWER_ARTIFACT.search(content) + assert _has_answer_artifact(content) def test_artifact_regex_handles_mixed_lf_crlf(): """Mixed line endings (real-world: paste-and-edit on Windows).""" content = "Here's the code:\r\n```python\nimport sys\r\n```" - assert _HAS_ANSWER_ARTIFACT.search(content) - - -def test_no_backtrack_on_crlf_spam(): - """10K of `\\r\\n` repeats must complete fast. - - Pre-fix the numbered-list alternative `(?:^|\\r?\\n)\\s*\\d+\\.` would - O(n^2)-backtrack on this kind of input (measured at ~630ms for 10KB - of `\\r\\n` repeats). The post-fix `[ \\t]*` indent restriction - keeps it linear. - """ - import time - - payload = "\r\n" * 5000 - t0 = time.time() - _HAS_ANSWER_ARTIFACT.search(payload) - elapsed_ms = (time.time() - t0) * 1000 - assert elapsed_ms < 50, f"regex took {elapsed_ms:.1f}ms on 10KB CRLF spam" + assert _has_answer_artifact(content) def test_no_reprompt_on_crlf_complete_python_game(): @@ -259,6 +348,36 @@ def test_no_reprompt_on_crlf_complete_python_game(): " if e.type == pygame.QUIT: break\r\n" "```" ) - assert not _would_reprompt( - content - ), "CRLF-encoded complete fence must also suppress the re-prompt" + assert not _would_reprompt(content) + + +# ── ReDoS guards ─────────────────────────────────────────────────── + + +def test_no_backtrack_on_crlf_spam(): + """10K of `\\r\\n` repeats must complete fast. + + The numbered-list alternative previously used greedy `\\s*` which + O(n^2)-backtracked through embedded `\\r\\n` characters (~630 ms on + 10 KB). The current `[ \\t]*` indent restriction plus length-bounded + `[\\s\\S]{...}?` runs keep every alternative linear.""" + import time + + payload = "\r\n" * 5000 + t0 = time.time() + _has_answer_artifact(payload) + elapsed_ms = (time.time() - t0) * 1000 + assert elapsed_ms < 50, f"guard took {elapsed_ms:.1f}ms on 10KB CRLF spam" + + +def test_no_backtrack_on_open_html_spam(): + """Many `` close must still complete + quickly. Bounded `[\\s\\S]{0,4000}?` between the open and close caps + the scan per occurrence.""" + import time + + payload = " Date: Sun, 24 May 2026 14:06:23 +0000 Subject: [PATCH 06/41] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/llama_cpp.py | 1 + studio/backend/tests/test_llama_cpp_reprompt_guard.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 6995df4f41..f9e66d4945 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -134,6 +134,7 @@ def _has_answer_artifact(text: str) -> bool: 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, diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 49954918f0..f750023a40 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -103,8 +103,8 @@ def test_artifact_regex_detects_non_alpha_info_strings(): samples = [ "First, let me write it.\n```python3\nprint('hi')\n```", "First, let me write it.\n```c++\nint main() { return 0; }\n```", - "First, let me write it.\n```c#\nConsole.WriteLine(\"hi\");\n```", - "First, let me write it.\n```objective-c\nNSLog(@\"hi\");\n```", + 'First, let me write it.\n```c#\nConsole.WriteLine("hi");\n```', + 'First, let me write it.\n```objective-c\nNSLog(@"hi");\n```', "First, let me write it.\n```ts-node\nconsole.log('hi')\n```", "First, let me script it.\n```bash-session\n$ echo hi\n```", "First, let me show it.\n```python linenums=\"1\"\nprint('hi')\n```", From 4165878734db98093bb0b40f57061b55184ad5ca Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 14:25:35 +0000 Subject: [PATCH 07/41] 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/` etc.). _HAS_ANSWER_ARTIFACT = re.compile( - # Closed code fence (any markdown info string, optional indent on close). + # Closed backtick code fence (any markdown info string, optional indent on close). r"```[^\r\n]{0,200}\r?\n[\s\S]{1,4000}?\r?\n[ \t]*```" + # Closed tilde code fence (CommonMark also allows ~~~ fences; several + # models emit them when the body itself contains backticks). + r"|~~~[^\r\n]{0,200}\r?\n[\s\S]{1,4000}?\r?\n[ \t]*~~~" # Complete HTML page; doctype prefix is optional. r"|(?:" # Complete SVG document. @@ -108,14 +112,18 @@ _NUMBERED_LIST_ARTIFACT = re.compile( ) # Markers that a numbered list is a plan (still re-promptable), not a -# final answer. Explicit "Here's my plan" / "plan:" / "approach:", OR -# intent phrasing followed shortly by a tool-action verb. +# final answer. Explicit "plan:" / "approach:" / "Here's my plan", OR +# intent phrasing followed shortly by a plan / tool-action verb. The +# verb set is intentionally conservative: ambiguous verbs like "write", +# "create", "make", "build" are omitted because real answer lists use +# them ("1. Write a poem", "1. Create directory"). _PLAN_LIST_FRAMING = re.compile( r"\b(?:here['’]?s (?:my |the |a )?(?:plan|approach)|step \d+|" r"i['’]?ll|i will|i am going to|let me|now i|next i)\b" r"[\s\S]{0,80}" r"\b(?:search|look up|call|use|fetch|browse|run|execute|" - r"check|find|open|verify|compare|summari[sz]e)\b" + r"check|find|open|verify|compare|summari[sz]e|think|respond|" + r"answer|analy[sz]e|explore|outline|gather|query|reason)\b" r"|\b(?:plan|approach):", re.IGNORECASE, ) diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index f750023a40..e06f59b3d2 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -121,6 +121,19 @@ def test_artifact_regex_detects_indented_close_fence(): assert _has_answer_artifact(text) +def test_artifact_regex_detects_tilde_code_fence(): + """CommonMark also allows ``~~~`` fences. Models emit them when the + body itself contains backticks, e.g. shell or markdown.""" + samples = [ + "First, let me write it.\n~~~python\nprint('hi')\n~~~", + "First, let me show:\n~~~\nplain block\n~~~", + "Sure, here is the script.\n~~~bash\necho hi\n~~~", + ] + for text in samples: + assert _has_answer_artifact(text), text + assert not _would_reprompt(text), text + + def test_artifact_regex_ignores_open_code_fence(): """An UNCLOSED code fence is not yet a complete artifact.""" text = "Let me set up pygame.\n```python\nimport pygame" @@ -321,6 +334,33 @@ def test_reprompts_on_incomplete_html_intent(): assert _would_reprompt(content) +def test_reprompts_on_plan_colon_intent(): + """Bare ``Plan:`` / ``Approach:`` at the start of a structured reply + is now an intent signal so the plan stall re-prompts. Pre-fix the + response slipped past ``_INTENT_SIGNAL`` entirely.""" + samples = [ + "Plan:\n1. search the docs\n2. summarise", + "Approach:\n1. fetch the data\n2. compare", + "Plan: search the docs then summarise", + ] + for s in samples: + assert _INTENT_SIGNAL.search(s), s + assert _would_reprompt(s), s + + +def test_reprompts_on_plan_with_extended_action_verbs(): + """The plan-framing verb whitelist also covers think / respond / + answer / analy[sz]e / explore / outline / gather / query / reason + so plan stalls phrased with those verbs still re-prompt.""" + samples = [ + "Here is what I will do:\n1. think it through\n2. respond clearly", + "First, let me reason about this:\n1. weigh options\n2. answer concisely", + "Now I will analyse this:\n1. break it down\n2. summarise findings", + ] + for s in samples: + assert _would_reprompt(s), s + + # ── Cross-platform line endings ──────────────────────────────────── @@ -381,3 +421,30 @@ def test_no_backtrack_on_open_html_spam(): _has_answer_artifact(payload) elapsed_ms = (time.time() - t0) * 1000 assert elapsed_ms < 50, f"guard took {elapsed_ms:.1f}ms on `` is retried at every `` Date: Sun, 24 May 2026 14:29:46 +0000 Subject: [PATCH 08/41] Studio: require apostrophe in _PLAN_LIST_FRAMING i'll alternative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- studio/backend/core/inference/llama_cpp.py | 10 ++++++---- .../tests/test_llama_cpp_reprompt_guard.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 815a77f6d9..0968bf9b6d 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -114,12 +114,14 @@ _NUMBERED_LIST_ARTIFACT = re.compile( # Markers that a numbered list is a plan (still re-promptable), not a # final answer. Explicit "plan:" / "approach:" / "Here's my plan", OR # intent phrasing followed shortly by a plan / tool-action verb. The -# verb set is intentionally conservative: ambiguous verbs like "write", -# "create", "make", "build" are omitted because real answer lists use -# them ("1. Write a poem", "1. Create directory"). +# apostrophe in ``i['’]ll`` is required (no ``?``) so the regex does not +# accidentally match the word "ill". The verb set is intentionally +# conservative: ambiguous verbs like "write", "create", "make", "build" +# are omitted because real answer lists use them ("1. Write a poem", +# "1. Create directory"). _PLAN_LIST_FRAMING = re.compile( r"\b(?:here['’]?s (?:my |the |a )?(?:plan|approach)|step \d+|" - r"i['’]?ll|i will|i am going to|let me|now i|next i)\b" + r"i['’]ll|i will|i am going to|let me|now i|next i)\b" r"[\s\S]{0,80}" r"\b(?:search|look up|call|use|fetch|browse|run|execute|" r"check|find|open|verify|compare|summari[sz]e|think|respond|" diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index e06f59b3d2..22f5d11185 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -361,6 +361,21 @@ def test_reprompts_on_plan_with_extended_action_verbs(): assert _would_reprompt(s), s +def test_plan_framing_requires_apostrophe_in_ill(): + """The ``i['’]ll`` plan-framing alternative requires an apostrophe so + the regex does not match the word "ill" (sick). Without this, a + numbered list near "ill" plus an unrelated action verb would be + misclassified as a plan and trigger a spurious re-prompt.""" + samples = [ + ("She is ill. Here is the list:\n1. Apple\n2. Orange\n3. Banana", False), + ("I'll search the docs:\n1. step\n2. step", True), + ("I will search:\n1. step\n2. step", True), + ] + for content, expected in samples: + got = _would_reprompt(content) + assert got == expected, f"{content!r} expected reprompt={expected} got {got}" + + # ── Cross-platform line endings ──────────────────────────────────── From 64ae2ac4c583e2ccf9d92a193736889f2345f20e Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 14:36:04 +0000 Subject: [PATCH 09/41] 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:"). --- studio/backend/core/inference/llama_cpp.py | 30 +++++++---- .../tests/test_llama_cpp_reprompt_guard.py | 52 +++++++++++++++++++ 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 0968bf9b6d..1207395ed6 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -62,9 +62,17 @@ _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", bare - # "Plan:" / "Approach:" as the first line of a structured reply. - r"\b(?:first\b|step \d+:?|here['\u2019]?s (?:my |the |a )?(?:plan|approach)|(?:plan|approach):)" + # Step/plan framing: "First ...", "Step 1:", "Here's my plan", + # "Here is the plan", "Here are my steps". + r"\b(?:first\b|step \d+:?|" + r"here['\u2019]?s (?:my |the |a )?(?:plan|approach)|" + r"here (?:is|are) (?:my |the |a )?(?:plan|approach|steps))" + r"|" + # Bare "Plan:" / "Approach:" (optionally preceded by a determiner + # like "My" / "The" / "Our") anchored to start of line so direct + # answers like "Here is a lesson plan:" or "meal plan:" do not trip + # the re-prompt path. + r"(?:^|\r?\n)[ \t]*(?:(?:my|the|our|a|this|that)\s+)?(?:plan|approach):" r"|" # "Now I" / "Next I" patterns r"\b(?:now i|next i)\b" @@ -112,21 +120,25 @@ _NUMBERED_LIST_ARTIFACT = re.compile( ) # Markers that a numbered list is a plan (still re-promptable), not a -# final answer. Explicit "plan:" / "approach:" / "Here's my plan", OR -# intent phrasing followed shortly by a plan / tool-action verb. The +# final answer. The intent alternatives mirror _INTENT_SIGNAL above so +# every recognised intent phrase can disqualify a numbered list. The # apostrophe in ``i['’]ll`` is required (no ``?``) so the regex does not # accidentally match the word "ill". The verb set is intentionally # conservative: ambiguous verbs like "write", "create", "make", "build" # are omitted because real answer lists use them ("1. Write a poem", -# "1. Create directory"). +# "1. Create directory"). ``plan:`` / ``approach:`` is anchored to the +# start of a line so "lesson plan:" / "meal plan:" do not trip the guard. _PLAN_LIST_FRAMING = re.compile( - r"\b(?:here['’]?s (?:my |the |a )?(?:plan|approach)|step \d+|" - r"i['’]ll|i will|i am going to|let me|now i|next i)\b" + r"\b(?:here['’]?s (?:my |the |a )?(?:plan|approach)|" + r"here (?:is|are) (?:my |the |a )?(?:plan|approach|steps)|" + r"step \d+|" + 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,80}" r"\b(?:search|look up|call|use|fetch|browse|run|execute|" r"check|find|open|verify|compare|summari[sz]e|think|respond|" r"answer|analy[sz]e|explore|outline|gather|query|reason)\b" - r"|\b(?:plan|approach):", + r"|(?:^|\r?\n)[ \t]*(?:(?:my|the|our|a|this|that)\s+)?(?:plan|approach):", re.IGNORECASE, ) diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 22f5d11185..955dc423eb 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -376,6 +376,58 @@ def test_plan_framing_requires_apostrophe_in_ill(): assert got == expected, f"{content!r} expected reprompt={expected} got {got}" +def test_reprompts_on_all_intent_form_numbered_action_plans(): + """``_PLAN_LIST_FRAMING`` must mirror every intent form that + ``_INTENT_SIGNAL`` accepts so numbered action plans phrased with + ``Allow me``, ``I'm going to``, ``I'm gonna``, ``I am gonna``, + ``I shall``, ``Now I``, ``Next I`` also re-prompt instead of being + silently classified as completed answers.""" + samples = [ + "Allow me to do this:\n1. search the docs\n2. fetch the result", + "I'm going to do this:\n1. search the docs\n2. fetch the result", + "I'm gonna do this:\n1. search the docs\n2. fetch the result", + "I am gonna do this:\n1. search the docs\n2. fetch the result", + "I shall do this:\n1. search the docs\n2. fetch the result", + "Now I will do these:\n1. search\n2. summarise", + "Next I will do these:\n1. fetch\n2. compare", + ] + for s in samples: + assert _would_reprompt(s), s + + +def test_plan_colon_intent_is_line_anchored(): + """``Plan:`` / ``Approach:`` only counts as an intent marker when it + is at the start of a line. Without this anchor, normal direct + answers containing phrases like ``lesson plan:``, ``meal plan:``, + ``migration plan:``, or ``My approach:`` would trigger the + re-prompt path and risk wiping a valid response.""" + # These mid-line "plan:" / "approach:" mentions are NOT intent signals. + # The qualifier before "plan" is a content noun ("lesson", "meal", + # "migration") rather than a generic determiner ("my", "the", ...). + direct_answers = [ + "Here is a lesson plan:\n1. Warm-up\n2. Group practice\n3. Assessment", + "I prepared a meal plan: rice, beans, eggs.", + "Quick approach: top-down then bottom-up.", + ] + for s in direct_answers: + assert not _INTENT_SIGNAL.search(s), s + assert not _would_reprompt(s), s + # "Plan:" / "Approach:" with optional generic determiner at the start + # of a line IS an intent signal. + plan_starts = [ + "Plan:\n1. search\n2. summarise", + "Approach:\n1. fetch\n2. compare", + " Plan:\n1. think\n2. respond", # leading indent OK + "Lorem ipsum\nPlan:\n1. step\n2. step", # plan: on a later line + "My plan:\n1. search\n2. summarise", + "The plan:\n1. look up\n2. compare", + "Our approach:\n1. fetch\n2. verify", + ] + for s in plan_starts: + assert _INTENT_SIGNAL.search(s), s + assert _would_reprompt(s), s + + # ── Cross-platform line endings ──────────────────────────────────── From 3dc26e7acf9dc7e876dd312ed7b3d380fd624486 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 14:41:44 +0000 Subject: [PATCH 10/41] 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: " 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. --- studio/backend/core/inference/llama_cpp.py | 11 +++++----- .../tests/test_llama_cpp_reprompt_guard.py | 21 +++++++++++++------ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 1207395ed6..2650168efd 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -69,10 +69,11 @@ _INTENT_SIGNAL = re.compile( r"here (?:is|are) (?:my |the |a )?(?:plan|approach|steps))" r"|" # Bare "Plan:" / "Approach:" (optionally preceded by a determiner - # like "My" / "The" / "Our") anchored to start of line so direct - # answers like "Here is a lesson plan:" or "meal plan:" do not trip - # the re-prompt path. - r"(?:^|\r?\n)[ \t]*(?:(?:my|the|our|a|this|that)\s+)?(?:plan|approach):" + # like "My" / "The" / "Our") anchored to start of line AND followed + # by a newline. Inline forms like "Your current Plan: Pro includes + # local chats" or "The plan: $10/month" must NOT trip the re-prompt + # path; only header-style framings ("Plan:\n1. ...") count. + r"(?:^|\r?\n)[ \t]*(?:(?:my|the|our|a|this|that)\s+)?(?:plan|approach):[ \t]*(?=\r?\n)" r"|" # "Now I" / "Next I" patterns r"\b(?:now i|next i)\b" @@ -138,7 +139,7 @@ _PLAN_LIST_FRAMING = re.compile( r"\b(?:search|look up|call|use|fetch|browse|run|execute|" r"check|find|open|verify|compare|summari[sz]e|think|respond|" r"answer|analy[sz]e|explore|outline|gather|query|reason)\b" - r"|(?:^|\r?\n)[ \t]*(?:(?:my|the|our|a|this|that)\s+)?(?:plan|approach):", + r"|(?:^|\r?\n)[ \t]*(?:(?:my|the|our|a|this|that)\s+)?(?:plan|approach):[ \t]*(?=\r?\n)", re.IGNORECASE, ) diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 955dc423eb..8923a8294c 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -335,13 +335,14 @@ def test_reprompts_on_incomplete_html_intent(): def test_reprompts_on_plan_colon_intent(): - """Bare ``Plan:`` / ``Approach:`` at the start of a structured reply - is now an intent signal so the plan stall re-prompts. Pre-fix the - response slipped past ``_INTENT_SIGNAL`` entirely.""" + """Bare ``Plan:`` / ``Approach:`` followed by a newline at the start + of a structured reply is now an intent signal so the plan stall + re-prompts. Inline ``Plan: `` (no newline) is NOT an intent + signal because that shape is common in marketing / product answers + such as ``Plan: Pro is $10/month``.""" samples = [ "Plan:\n1. search the docs\n2. summarise", "Approach:\n1. fetch the data\n2. compare", - "Plan: search the docs then summarise", ] for s in samples: assert _INTENT_SIGNAL.search(s), s @@ -403,17 +404,25 @@ def test_plan_colon_intent_is_line_anchored(): re-prompt path and risk wiping a valid response.""" # These mid-line "plan:" / "approach:" mentions are NOT intent signals. # The qualifier before "plan" is a content noun ("lesson", "meal", - # "migration") rather than a generic determiner ("my", "the", ...). + # "migration") rather than a generic determiner, OR the colon is + # followed by inline content instead of a newline-anchored header. direct_answers = [ "Here is a lesson plan:\n1. Warm-up\n2. Group practice\n3. Assessment", "I prepared a meal plan: rice, beans, eggs.", "Quick approach: top-down then bottom-up.", + "Your current Plan: Pro includes local chats.", + "The plan: Basic is free, Pro is $10/month, Enterprise is custom.", + "My plan: use dynamic programming with memoisation.", + "Recommended approach: use the Python SDK for uploads.", + "The migration plan: backup, run, verify all in one window.", ] for s in direct_answers: assert not _INTENT_SIGNAL.search(s), s assert not _would_reprompt(s), s # "Plan:" / "Approach:" with optional generic determiner at the start - # of a line IS an intent signal. + # of a line, FOLLOWED BY A NEWLINE, IS an intent signal. The newline + # requirement is what filters inline product/answer text such as + # "The plan: Pro is $10/month" out of the intent path. plan_starts = [ "Plan:\n1. search\n2. summarise", "Approach:\n1. fetch\n2. compare", From a6f6022bd0fd418225b231bda38c82b4bbe22c43 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 14:56:04 +0000 Subject: [PATCH 11/41] 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. --- studio/backend/core/inference/llama_cpp.py | 51 ++++++++++++------- .../tests/test_llama_cpp_reprompt_guard.py | 50 ++++++++++++++++-- 2 files changed, 79 insertions(+), 22 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 2650168efd..5c44c2062b 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -52,6 +52,20 @@ logger = get_logger(__name__) # ── Pre-compiled patterns for plan-without-action re-prompt ── +# Re-prompt-action verbs. Used both as a nearby-verb lookahead for the +# new ``Plan:`` / ``Here is the plan`` intents (so prose final answers +# such as ``Plan:\n1. Warm-up\n2. Group practice`` or "Here is the plan +# you asked for" do not wipe) and as the plan-list disqualifier verb +# set in _PLAN_LIST_FRAMING below. Conservative on purpose: ambiguous +# verbs like ``write``, ``create``, ``make``, ``build``, ``do``, +# ``handle`` are deliberately excluded because real answer lists use +# them ("1. Write a poem", "1. Create directory"). +_REPROMPT_ACTION_VERBS = ( + r"search|look up|call|use|fetch|browse|run|execute|" + r"check|find|open|verify|compare|summari[sz]e|think|respond|" + r"answer|analy[sz]e|explore|outline|gather|query|reason" +) + # 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,18 +76,22 @@ _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", - # "Here is the plan", "Here are my steps". - r"\b(?:first\b|step \d+:?|" - r"here['\u2019]?s (?:my |the |a )?(?:plan|approach)|" - r"here (?:is|are) (?:my |the |a )?(?:plan|approach|steps))" + # 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"|" + # "Here is the plan" / "Here are my steps" framings. Require an + # action verb within 120 chars so prose answers like "Here is the + # plan you asked for" do not match. + r"\bhere (?:is|are) (?:my |the |a )?(?:plan|approach|steps)\b" + rf"(?=[\s\S]{{0,120}}\b(?:{_REPROMPT_ACTION_VERBS})\b)" r"|" # Bare "Plan:" / "Approach:" (optionally preceded by a determiner # like "My" / "The" / "Our") anchored to start of line AND followed - # by a newline. Inline forms like "Your current Plan: Pro includes - # local chats" or "The plan: $10/month" must NOT trip the re-prompt - # path; only header-style framings ("Plan:\n1. ...") count. + # by a newline AND followed within 120 chars by an action verb so + # final answers shaped like "Plan:\n1. Warm-up\n2. Group practice" + # or "My plan:\n1. Breakfast\n2. Lunch" do NOT wipe. r"(?:^|\r?\n)[ \t]*(?:(?:my|the|our|a|this|that)\s+)?(?:plan|approach):[ \t]*(?=\r?\n)" + rf"(?=[\s\S]{{0,120}}\b(?:{_REPROMPT_ACTION_VERBS})\b)" r"|" # "Now I" / "Next I" patterns r"\b(?:now i|next i)\b" @@ -124,22 +142,19 @@ _NUMBERED_LIST_ARTIFACT = re.compile( # final answer. The intent alternatives mirror _INTENT_SIGNAL above so # every recognised intent phrase can disqualify a numbered list. The # apostrophe in ``i['’]ll`` is required (no ``?``) so the regex does not -# accidentally match the word "ill". The verb set is intentionally -# conservative: ambiguous verbs like "write", "create", "make", "build" -# are omitted because real answer lists use them ("1. Write a poem", -# "1. Create directory"). ``plan:`` / ``approach:`` is anchored to the -# start of a line so "lesson plan:" / "meal plan:" do not trip the guard. +# accidentally match the word "ill". Both branches require an action +# verb nearby so plan-style answer headers ("Plan:\n1. Warm-up\n2. +# Group practice") are NOT treated as plans and stay artifacts. _PLAN_LIST_FRAMING = re.compile( r"\b(?:here['’]?s (?:my |the |a )?(?:plan|approach)|" r"here (?:is|are) (?:my |the |a )?(?:plan|approach|steps)|" - r"step \d+|" + 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,80}" - r"\b(?:search|look up|call|use|fetch|browse|run|execute|" - r"check|find|open|verify|compare|summari[sz]e|think|respond|" - r"answer|analy[sz]e|explore|outline|gather|query|reason)\b" - r"|(?:^|\r?\n)[ \t]*(?:(?:my|the|our|a|this|that)\s+)?(?:plan|approach):[ \t]*(?=\r?\n)", + rf"\b(?:{_REPROMPT_ACTION_VERBS})\b" + r"|(?:^|\r?\n)[ \t]*(?:(?:my|the|our|a|this|that)\s+)?(?:plan|approach):" + rf"[\s\S]{{0,120}}\b(?:{_REPROMPT_ACTION_VERBS})\b", re.IGNORECASE, ) diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 8923a8294c..7f531c1115 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -396,6 +396,46 @@ def test_reprompts_on_all_intent_form_numbered_action_plans(): assert _would_reprompt(s), s +def test_no_reprompt_on_plan_titled_final_answer_without_actions(): + """A final answer naturally titled ``Plan:`` / ``My plan:`` / + ``Approach:`` whose numbered items are content (not action verbs) + must NOT wipe. The action-verb lookahead on the Plan: intent + branch is what filters lesson plans, meal plans, dinner plans, + and similar from being misclassified as tool-action stalls.""" + samples = [ + "Plan:\n1. Warm-up: Students review fractions.\n2. Group practice.\n3. Assessment.", + "My plan:\n1. Breakfast: oatmeal and fruit.\n2. Lunch: rice bowl.\n3. Dinner: lentil soup.", + "The plan:\n1. Bring umbrellas.\n2. Pack snacks.\n3. Drive carefully.", + ] + for s in samples: + assert not _would_reprompt(s), s + + +def test_reprompts_on_plan_titled_action_stall(): + """A ``Plan:`` / ``Approach:`` header whose items DO contain action + verbs (search / fetch / verify / ...) still re-prompts.""" + samples = [ + "Plan:\n1. search the docs\n2. summarise the result", + "My plan:\n1. fetch the data\n2. verify the rows", + "The approach:\n1. look up the value\n2. compare versions", + ] + for s in samples: + assert _would_reprompt(s), s + + +def test_no_reprompt_on_here_is_the_plan_prose_answer(): + """``Here is the plan you asked for. ...`` and similar prose + answers without action verbs must NOT wipe. The action-verb + lookahead on the ``Here is the plan`` intent branch filters them.""" + samples = [ + "Here is the plan you asked for. It is two pages long and covers Q4 goals.", + "Here are my steps in plain English. Step one is patience.", + "Here is a plan for the dinner party. Welcome, eat, dance.", + ] + for s in samples: + assert not _would_reprompt(s), s + + def test_plan_colon_intent_is_line_anchored(): """``Plan:`` / ``Approach:`` only counts as an intent marker when it is at the start of a line. Without this anchor, normal direct @@ -420,14 +460,16 @@ def test_plan_colon_intent_is_line_anchored(): assert not _INTENT_SIGNAL.search(s), s assert not _would_reprompt(s), s # "Plan:" / "Approach:" with optional generic determiner at the start - # of a line, FOLLOWED BY A NEWLINE, IS an intent signal. The newline - # requirement is what filters inline product/answer text such as - # "The plan: Pro is $10/month" out of the intent path. + # of a line, followed by a newline AND followed by an action verb + # within 120 chars, IS an intent signal. The newline requirement + # filters inline product/answer text such as "The plan: Pro is + # $10/month"; the action-verb requirement filters real prose answers + # such as "Plan:\n1. Warm-up\n2. Group practice". plan_starts = [ "Plan:\n1. search\n2. summarise", "Approach:\n1. fetch\n2. compare", " Plan:\n1. think\n2. respond", # leading indent OK - "Lorem ipsum\nPlan:\n1. step\n2. step", # plan: on a later line + "Lorem ipsum\nPlan:\n1. search\n2. fetch", # plan: on a later line "My plan:\n1. search\n2. summarise", "The plan:\n1. look up\n2. compare", "Our approach:\n1. fetch\n2. verify", From d38bb3f077351a4fe3cd23cc426ddb759c580da5 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 15:08:18 +0000 Subject: [PATCH 12/41] 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``. --- studio/backend/core/inference/llama_cpp.py | 54 +++----- .../tests/test_llama_cpp_reprompt_guard.py | 127 ++++++------------ 2 files changed, 58 insertions(+), 123 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 5c44c2062b..74a6e3529a 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -52,18 +52,18 @@ logger = get_logger(__name__) # ── Pre-compiled patterns for plan-without-action re-prompt ── -# Re-prompt-action verbs. Used both as a nearby-verb lookahead for the -# new ``Plan:`` / ``Here is the plan`` intents (so prose final answers -# such as ``Plan:\n1. Warm-up\n2. Group practice`` or "Here is the plan -# you asked for" do not wipe) and as the plan-list disqualifier verb -# set in _PLAN_LIST_FRAMING below. Conservative on purpose: ambiguous -# verbs like ``write``, ``create``, ``make``, ``build``, ``do``, -# ``handle`` are deliberately excluded because real answer lists use -# them ("1. Write a poem", "1. Create directory"). -_REPROMPT_ACTION_VERBS = ( - r"search|look up|call|use|fetch|browse|run|execute|" - r"check|find|open|verify|compare|summari[sz]e|think|respond|" - r"answer|analy[sz]e|explore|outline|gather|query|reason" +# Tool-action verbs used by _PLAN_LIST_FRAMING to distinguish plan-only +# numbered lists ("1. search the docs", "1. fetch the data") from +# answer numbered lists ("1. Apple", "1. Use BFS", "1. Write a poem"). +# Kept intentionally narrow: only verbs that strongly imply an actual +# tool invocation. Broad verbs like ``use``, ``compare``, ``check``, +# ``find``, ``write``, ``create``, ``make``, ``build``, ``think``, +# ``respond``, ``answer``, ``analyse``, ``explore`` are deliberately +# excluded because real answer lists use them ("1. Use BFS", +# "1. Compare versions", "1. Write a poem"). +_TOOL_ACTION_VERBS = ( + r"search|look up|fetch|browse|web[ _-]?search|" + r"call (?:a |the )?tool|run (?:python|the code)|execute (?:python|the code)" ) # Forward-looking intent signals that indicate the model is @@ -79,20 +79,6 @@ _INTENT_SIGNAL = re.compile( # 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"|" - # "Here is the plan" / "Here are my steps" framings. Require an - # action verb within 120 chars so prose answers like "Here is the - # plan you asked for" do not match. - r"\bhere (?:is|are) (?:my |the |a )?(?:plan|approach|steps)\b" - rf"(?=[\s\S]{{0,120}}\b(?:{_REPROMPT_ACTION_VERBS})\b)" - r"|" - # Bare "Plan:" / "Approach:" (optionally preceded by a determiner - # like "My" / "The" / "Our") anchored to start of line AND followed - # by a newline AND followed within 120 chars by an action verb so - # final answers shaped like "Plan:\n1. Warm-up\n2. Group practice" - # or "My plan:\n1. Breakfast\n2. Lunch" do NOT wipe. - r"(?:^|\r?\n)[ \t]*(?:(?:my|the|our|a|this|that)\s+)?(?:plan|approach):[ \t]*(?=\r?\n)" - rf"(?=[\s\S]{{0,120}}\b(?:{_REPROMPT_ACTION_VERBS})\b)" - r"|" # "Now I" / "Next I" patterns r"\b(?:now i|next i)\b" r")" @@ -139,22 +125,18 @@ _NUMBERED_LIST_ARTIFACT = re.compile( ) # Markers that a numbered list is a plan (still re-promptable), not a -# final answer. The intent alternatives mirror _INTENT_SIGNAL above so -# every recognised intent phrase can disqualify a numbered list. The -# apostrophe in ``i['’]ll`` is required (no ``?``) so the regex does not -# accidentally match the word "ill". Both branches require an action -# verb nearby so plan-style answer headers ("Plan:\n1. Warm-up\n2. -# Group practice") are NOT treated as plans and stay artifacts. +# final answer. Only fires when an intent phrase from _INTENT_SIGNAL is +# already followed within 80 chars by a narrow 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. _PLAN_LIST_FRAMING = re.compile( r"\b(?:here['’]?s (?:my |the |a )?(?:plan|approach)|" - r"here (?:is|are) (?:my |the |a )?(?:plan|approach|steps)|" 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,80}" - rf"\b(?:{_REPROMPT_ACTION_VERBS})\b" - r"|(?:^|\r?\n)[ \t]*(?:(?:my|the|our|a|this|that)\s+)?(?:plan|approach):" - rf"[\s\S]{{0,120}}\b(?:{_REPROMPT_ACTION_VERBS})\b", + rf"\b(?:{_TOOL_ACTION_VERBS})\b", re.IGNORECASE, ) diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 7f531c1115..492ee1ecc7 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -38,13 +38,26 @@ _BACKEND_DIR = str(Path(__file__).resolve().parent.parent) if _BACKEND_DIR not in sys.path: sys.path.insert(0, _BACKEND_DIR) -_loggers_stub = _types.ModuleType("loggers") -_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name) -sys.modules.setdefault("loggers", _loggers_stub) +# Inject minimal stand-ins ONLY when the real modules are unavailable. +# Using ``setdefault`` with a non-package ``ModuleType`` would otherwise +# poison ``sys.modules`` for any later test that does +# ``from loggers.handlers import ...`` (Python would raise "loggers is +# not a package" because the stub has no ``__path__``). +try: # noqa: E402 + import loggers # type: ignore # real backend package +except ModuleNotFoundError: + _loggers_stub = _types.ModuleType("loggers") + _loggers_stub.__path__ = [] # type: ignore[attr-defined] + _loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name) + sys.modules["loggers"] = _loggers_stub -_structlog_stub = _types.ModuleType("structlog") -_structlog_stub.get_logger = lambda *a, **k: __import__("logging").getLogger("stub") -sys.modules.setdefault("structlog", _structlog_stub) +try: # noqa: E402 + import structlog # type: ignore +except ModuleNotFoundError: + _structlog_stub = _types.ModuleType("structlog") + _structlog_stub.__path__ = [] # type: ignore[attr-defined] + _structlog_stub.get_logger = lambda *a, **k: __import__("logging").getLogger("stub") + sys.modules["structlog"] = _structlog_stub from core.inference.llama_cpp import ( # noqa: E402 _HAS_ANSWER_ARTIFACT, @@ -220,16 +233,16 @@ def test_numbered_list_without_plan_framing_is_artifact(): def test_numbered_list_with_plan_framing_is_NOT_artifact(): - """A numbered list paired with explicit plan framing must NOT count - as a completed artifact. The list IS the plan, not the answer.""" + """A numbered list paired with explicit plan framing (intent phrase + followed by a narrow tool-action verb such as ``search`` / ``fetch`` + / ``browse``) must NOT count as a completed artifact. The list IS + the plan, not the answer. Broad verbs like ``compare`` / ``use`` / + ``verify`` are intentionally NOT plan framing because real answer + lists use them.""" samples = [ - # "Here's my plan" / "plan:" / "approach:". - "Here's my plan:\n1. Search the web\n2. Summarise the result.", - "Here is the plan:\n1. Look up the date.\n2. Compare versions.", - "My approach:\n1. Search\n2. Verify\n3. Answer.", - # Intent phrase + tool-action verb in close proximity. + "Here's my plan:\n1. Search the web\n2. then summarise.", "First, I'll do these:\n1. search for the song list\n2. cross-check the chart", - "Let me look up the values:\n1. fetch the data\n2. compare to baseline", + "Let me look up the values: fetch the data first.", ] for s in samples: assert _PLAN_LIST_FRAMING.search(s), s @@ -334,32 +347,8 @@ def test_reprompts_on_incomplete_html_intent(): assert _would_reprompt(content) -def test_reprompts_on_plan_colon_intent(): - """Bare ``Plan:`` / ``Approach:`` followed by a newline at the start - of a structured reply is now an intent signal so the plan stall - re-prompts. Inline ``Plan: `` (no newline) is NOT an intent - signal because that shape is common in marketing / product answers - such as ``Plan: Pro is $10/month``.""" - samples = [ - "Plan:\n1. search the docs\n2. summarise", - "Approach:\n1. fetch the data\n2. compare", - ] - for s in samples: - assert _INTENT_SIGNAL.search(s), s - assert _would_reprompt(s), s -def test_reprompts_on_plan_with_extended_action_verbs(): - """The plan-framing verb whitelist also covers think / respond / - answer / analy[sz]e / explore / outline / gather / query / reason - so plan stalls phrased with those verbs still re-prompt.""" - samples = [ - "Here is what I will do:\n1. think it through\n2. respond clearly", - "First, let me reason about this:\n1. weigh options\n2. answer concisely", - "Now I will analyse this:\n1. break it down\n2. summarise findings", - ] - for s in samples: - assert _would_reprompt(s), s def test_plan_framing_requires_apostrophe_in_ill(): @@ -398,10 +387,10 @@ def test_reprompts_on_all_intent_form_numbered_action_plans(): def test_no_reprompt_on_plan_titled_final_answer_without_actions(): """A final answer naturally titled ``Plan:`` / ``My plan:`` / - ``Approach:`` whose numbered items are content (not action verbs) - must NOT wipe. The action-verb lookahead on the Plan: intent - branch is what filters lesson plans, meal plans, dinner plans, - and similar from being misclassified as tool-action stalls.""" + ``Approach:`` must NOT wipe. Bare ``Plan:`` / ``Approach:`` is + deliberately NOT an intent signal in _INTENT_SIGNAL because it + too often appears as a normal answer heading (lesson plan, meal + plan, business plan, project plan, ...).""" samples = [ "Plan:\n1. Warm-up: Students review fractions.\n2. Group practice.\n3. Assessment.", "My plan:\n1. Breakfast: oatmeal and fruit.\n2. Lunch: rice bowl.\n3. Dinner: lentil soup.", @@ -411,16 +400,21 @@ def test_no_reprompt_on_plan_titled_final_answer_without_actions(): assert not _would_reprompt(s), s -def test_reprompts_on_plan_titled_action_stall(): - """A ``Plan:`` / ``Approach:`` header whose items DO contain action - verbs (search / fetch / verify / ...) still re-prompts.""" +def test_no_reprompt_on_bare_plan_header_action_stall(): + """Bare ``Plan:`` / ``Approach:`` headers paired with tool-action + verbs are NOT classified as plan stalls. Adding them as intent + markers caused false positives on legitimate plan answers; we + accept the smaller false negative (action plans titled only with + ``Plan:`` slip through) in exchange for not wiping valid answers. + Plan stalls that use an explicit first-person intent phrase ("I'll + search...", "First, I'll fetch...") are still caught.""" samples = [ "Plan:\n1. search the docs\n2. summarise the result", "My plan:\n1. fetch the data\n2. verify the rows", "The approach:\n1. look up the value\n2. compare versions", ] for s in samples: - assert _would_reprompt(s), s + assert not _would_reprompt(s), s def test_no_reprompt_on_here_is_the_plan_prose_answer(): @@ -436,47 +430,6 @@ def test_no_reprompt_on_here_is_the_plan_prose_answer(): assert not _would_reprompt(s), s -def test_plan_colon_intent_is_line_anchored(): - """``Plan:`` / ``Approach:`` only counts as an intent marker when it - is at the start of a line. Without this anchor, normal direct - answers containing phrases like ``lesson plan:``, ``meal plan:``, - ``migration plan:``, or ``My approach:`` would trigger the - re-prompt path and risk wiping a valid response.""" - # These mid-line "plan:" / "approach:" mentions are NOT intent signals. - # The qualifier before "plan" is a content noun ("lesson", "meal", - # "migration") rather than a generic determiner, OR the colon is - # followed by inline content instead of a newline-anchored header. - direct_answers = [ - "Here is a lesson plan:\n1. Warm-up\n2. Group practice\n3. Assessment", - "I prepared a meal plan: rice, beans, eggs.", - "Quick approach: top-down then bottom-up.", - "Your current Plan: Pro includes local chats.", - "The plan: Basic is free, Pro is $10/month, Enterprise is custom.", - "My plan: use dynamic programming with memoisation.", - "Recommended approach: use the Python SDK for uploads.", - "The migration plan: backup, run, verify all in one window.", - ] - for s in direct_answers: - assert not _INTENT_SIGNAL.search(s), s - assert not _would_reprompt(s), s - # "Plan:" / "Approach:" with optional generic determiner at the start - # of a line, followed by a newline AND followed by an action verb - # within 120 chars, IS an intent signal. The newline requirement - # filters inline product/answer text such as "The plan: Pro is - # $10/month"; the action-verb requirement filters real prose answers - # such as "Plan:\n1. Warm-up\n2. Group practice". - plan_starts = [ - "Plan:\n1. search\n2. summarise", - "Approach:\n1. fetch\n2. compare", - " Plan:\n1. think\n2. respond", # leading indent OK - "Lorem ipsum\nPlan:\n1. search\n2. fetch", # plan: on a later line - "My plan:\n1. search\n2. summarise", - "The plan:\n1. look up\n2. compare", - "Our approach:\n1. fetch\n2. verify", - ] - for s in plan_starts: - assert _INTENT_SIGNAL.search(s), s - assert _would_reprompt(s), s # ── Cross-platform line endings ──────────────────────────────────── From 94e7e12aaf2923ed72c16757c2fe0df1ec5de5ff Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 15:08:29 +0000 Subject: [PATCH 13/41] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/tests/test_llama_cpp_reprompt_guard.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 492ee1ecc7..0bfc55fc7c 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -347,10 +347,6 @@ def test_reprompts_on_incomplete_html_intent(): assert _would_reprompt(content) - - - - def test_plan_framing_requires_apostrophe_in_ill(): """The ``i['’]ll`` plan-framing alternative requires an apostrophe so the regex does not match the word "ill" (sick). Without this, a @@ -430,8 +426,6 @@ def test_no_reprompt_on_here_is_the_plan_prose_answer(): assert not _would_reprompt(s), s - - # ── Cross-platform line endings ──────────────────────────────────── From 9e79a3e2c664641137a1ae44d2588fe43508a8dc Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 15:22:16 +0000 Subject: [PATCH 14/41] 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. --- studio/backend/core/inference/llama_cpp.py | 36 +++++++---- .../tests/test_llama_cpp_reprompt_guard.py | 63 +++++++++++++++++++ 2 files changed, 87 insertions(+), 12 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 74a6e3529a..47db379743 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -56,13 +56,18 @@ logger = get_logger(__name__) # numbered lists ("1. search the docs", "1. fetch the data") from # answer numbered lists ("1. Apple", "1. Use BFS", "1. Write a poem"). # Kept intentionally narrow: only verbs that strongly imply an actual -# tool invocation. Broad verbs like ``use``, ``compare``, ``check``, -# ``find``, ``write``, ``create``, ``make``, ``build``, ``think``, -# ``respond``, ``answer``, ``analyse``, ``explore`` are deliberately -# excluded because real answer lists use them ("1. Use BFS", -# "1. Compare versions", "1. Write a poem"). +# tool invocation. Broad verbs like ``use``, ``compare``, ``write``, +# ``create``, ``make``, ``build``, ``think``, ``respond``, ``answer``, +# ``analyse``, ``explore`` are deliberately excluded because real +# answer lists use them ("1. Use BFS", "1. Compare versions", +# "1. Write a poem"). ``find`` / ``check`` / ``verify`` are admitted +# ONLY when paired with a freshness signal (current / latest / +# today's / up-to-date / live / online / web), so "find the bug" or +# "check the answer" still read as valid answer text. _TOOL_ACTION_VERBS = ( r"search|look up|fetch|browse|web[ _-]?search|" + r"(?:find|check|verify) (?:for )?(?:the |a |an )?" + r"(?:current|latest|today['’]?s?|up[- ]to[- ]date|live|online|web)|" r"call (?:a |the )?tool|run (?:python|the code)|execute (?:python|the code)" ) @@ -106,11 +111,14 @@ _MAX_REPROMPTS = 3 # * All `[\s\S]{...}?` runs are length-bounded so the search stays # linear on adversarial input (CRLF spam, repeated `` etc.). _HAS_ANSWER_ARTIFACT = re.compile( - # Closed backtick code fence (any markdown info string, optional indent on close). - r"```[^\r\n]{0,200}\r?\n[\s\S]{1,4000}?\r?\n[ \t]*```" + # Closed backtick code fence (any markdown info string, optional indent + # on close). The closing fence must end the line: only optional + # trailing whitespace before a newline or end-of-string, so spam + # like ``` ```not actually closed ``` does not count. + r"```[^\r\n]{0,200}\r?\n[\s\S]{1,4000}?\r?\n[ \t]*```[ \t]*(?:\r?\n|\Z)" # Closed tilde code fence (CommonMark also allows ~~~ fences; several # models emit them when the body itself contains backticks). - r"|~~~[^\r\n]{0,200}\r?\n[\s\S]{1,4000}?\r?\n[ \t]*~~~" + r"|~~~[^\r\n]{0,200}\r?\n[\s\S]{1,4000}?\r?\n[ \t]*~~~[ \t]*(?:\r?\n|\Z)" # Complete HTML page; doctype prefix is optional. r"|(?:" # Complete SVG document. @@ -4894,15 +4902,19 @@ 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() + # Artifact check uses VISIBLE content only: + # a closed code fence inside hidden reasoning is + # not a user-visible answer, so it must not + # suppress the re-prompt. + _visible = content_accum.strip() + _stripped = _visible if _visible else reasoning_accum.strip() + _visible_has_artifact = bool(_visible) and _has_answer_artifact(_visible) if ( tools and _reprompt_count < _MAX_REPROMPTS and 0 < len(_stripped) < _REPROMPT_MAX_CHARS and _INTENT_SIGNAL.search(_stripped) - and not _has_answer_artifact(_stripped) + and not _visible_has_artifact ): _reprompt_count += 1 logger.info( diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 0bfc55fc7c..59e10a2083 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -513,3 +513,66 @@ def test_no_backtrack_on_tilde_fence_spam(): _has_answer_artifact(payload) elapsed_ms = (time.time() - t0) * 1000 assert elapsed_ms < 50, f"guard took {elapsed_ms:.1f}ms on ~~~ spam" + + +# ── Closing-fence-must-end-line edge cases ──────────────────────── + + +def test_artifact_regex_rejects_backtick_close_with_trailing_text(): + """``\\n```not actually closed`` must NOT match a closed fence. + + The closing fence must end the line (only trailing whitespace + before a newline or end-of-string). Otherwise an unclosed fence + where a later line begins with three backticks plus prose is + treated as a complete artifact and the re-prompt is wrongly + suppressed.""" + samples = [ + "First, let me write it.\n```python\nprint('hi')\n```not actually closed", + "First, let me show:\n```python\nprint('hi')\n```more text after", + ] + for s in samples: + assert not _has_answer_artifact(s), s + assert _would_reprompt(s), s + + +def test_artifact_regex_rejects_tilde_close_with_trailing_text(): + """Same rule for tilde fences.""" + text = "First, let me write it.\n~~~python\nprint('hi')\n~~~not actually closed" + assert not _has_answer_artifact(text) + assert _would_reprompt(text) + + +# ── Freshness-gated find / check / verify lookup plans ──────────── + + +def test_reprompts_on_numbered_lookup_plan_with_freshness_verbs(): + """``find the current``, ``check the latest``, ``verify today's`` in a + numbered plan are tool-lookup framing and STILL re-prompt.""" + samples = [ + "Here's my plan:\n1. Find the current Billboard chart.\n2. Summarise.", + "First, I'll do these:\n1. Check the latest release notes.\n2. Answer.", + "Let me proceed:\n1. Verify today's USD/EUR rate.\n2. Cite the source.", + "I'll do this:\n1. Find the up-to-date docs.\n2. Quote the change.", + ] + for s in samples: + assert _would_reprompt(s), s + + +def test_no_reprompt_on_numbered_answer_with_bare_find_or_check(): + """Bare ``find`` / ``check`` / ``verify`` without a freshness word + stay valid answer verbs ("find the bug", "check the answer").""" + samples = [ + ( + "Here's how I'd debug this:\n" + "1. Find the failing test.\n" + "2. Check the stack trace." + ), + ( + "First, here are common steps:\n" + "1. Verify your input.\n" + "2. Check each assertion." + ), + ] + for s in samples: + assert _has_answer_artifact(s), s + assert not _would_reprompt(s), s From 562af754c89892fb93334311deb70252c8092cb4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 15:22:57 +0000 Subject: [PATCH 15/41] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/llama_cpp.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 47db379743..d885734a9e 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -4908,7 +4908,9 @@ class LlamaCppBackend: # suppress the re-prompt. _visible = content_accum.strip() _stripped = _visible if _visible else reasoning_accum.strip() - _visible_has_artifact = bool(_visible) and _has_answer_artifact(_visible) + _visible_has_artifact = bool(_visible) and _has_answer_artifact( + _visible + ) if ( tools and _reprompt_count < _MAX_REPROMPTS From ef3ee3d8bd34e9a645a658d103ed9860277ee6af Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 15:35:27 +0000 Subject: [PATCH 16/41] 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. --- studio/backend/core/inference/llama_cpp.py | 52 ++++--- .../tests/test_llama_cpp_reprompt_guard.py | 130 ++++++++++++++++++ 2 files changed, 162 insertions(+), 20 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index d885734a9e..eef41608a8 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -66,6 +66,8 @@ logger = get_logger(__name__) # "check the answer" still read as valid answer text. _TOOL_ACTION_VERBS = ( r"search|look up|fetch|browse|web[ _-]?search|" + r"(?:query|consult) (?:the |a |an )?" + r"(?:web|internet|online(?: sources?)?)|" r"(?:find|check|verify) (?:for )?(?:the |a |an )?" r"(?:current|latest|today['’]?s?|up[- ]to[- ]date|live|online|web)|" r"call (?:a |the )?tool|run (?:python|the code)|execute (?:python|the code)" @@ -112,13 +114,14 @@ _MAX_REPROMPTS = 3 # linear on adversarial input (CRLF spam, repeated `` etc.). _HAS_ANSWER_ARTIFACT = re.compile( # Closed backtick code fence (any markdown info string, optional indent - # on close). The closing fence must end the line: only optional - # trailing whitespace before a newline or end-of-string, so spam - # like ``` ```not actually closed ``` does not count. - r"```[^\r\n]{0,200}\r?\n[\s\S]{1,4000}?\r?\n[ \t]*```[ \t]*(?:\r?\n|\Z)" - # Closed tilde code fence (CommonMark also allows ~~~ fences; several - # models emit them when the body itself contains backticks). - r"|~~~[^\r\n]{0,200}\r?\n[\s\S]{1,4000}?\r?\n[ \t]*~~~[ \t]*(?:\r?\n|\Z)" + # 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`{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). + r"|(?P~{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"|(?:" # Complete SVG document. @@ -133,17 +136,19 @@ _NUMBERED_LIST_ARTIFACT = re.compile( ) # Markers that a numbered list is a plan (still re-promptable), not a -# final answer. Only fires when an intent phrase from _INTENT_SIGNAL is -# already followed within 80 chars by a narrow 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. +# 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,80}" + r"[\s\S]{0,2000}?" rf"\b(?:{_TOOL_ACTION_VERBS})\b", re.IGNORECASE, ) @@ -4902,14 +4907,21 @@ class LlamaCppBackend: # like "4" or "Hello!" won't trigger this. # Use content if available, otherwise fall back # to reasoning text (reasoning-only stalls). - # Artifact check uses VISIBLE content only: - # a closed code fence inside hidden reasoning is - # not a user-visible answer, so it must not - # suppress the re-prompt. + # 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. _visible = content_accum.strip() - _stripped = _visible if _visible else reasoning_accum.strip() - _visible_has_artifact = bool(_visible) and _has_answer_artifact( - _visible + _reasoning = reasoning_accum.strip() + _stripped = _visible if _visible else _reasoning + _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 ) if ( tools diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 59e10a2083..b81f0c80df 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -576,3 +576,133 @@ def test_no_reprompt_on_numbered_answer_with_bare_find_or_check(): for s in samples: assert _has_answer_artifact(s), s assert not _would_reprompt(s), s + + +# ── CommonMark fences with 4+ delimiters ────────────────────────── + + +def test_artifact_regex_detects_four_or_more_backticks(): + """CommonMark allows opening fences of 3+ backticks. Models use + 4+ delimiters when the body itself contains a triple fence.""" + samples = [ + "First, let me show.\n````python\nprint('``` inside')\n````", + "Let me show.\n`````markdown\n```python\nprint(1)\n```\n`````", + ] + for text in samples: + assert _has_answer_artifact(text), text + assert not _would_reprompt(text), text + + +def test_artifact_regex_detects_four_or_more_tildes(): + """Same 3+ delimiter rule for tilde fences.""" + text = "First, let me show.\n~~~~python\nprint('hi')\n~~~~" + assert _has_answer_artifact(text) + assert not _would_reprompt(text) + + +# ── Query / consult online sources ──────────────────────────────── + + +def test_reprompts_on_numbered_plan_with_query_consult_synonyms(): + """``query the web`` / ``consult online sources`` are tool-lookup + synonyms and STILL re-prompt as numbered tool plans.""" + samples = [ + "Here's my plan:\n1. Query the web for today's USD/EUR rate.\n2. Summarize.", + "Here's my plan:\n1. Consult online sources for the latest release.\n2. Answer.", + "First, I'll do this:\n1. Query the internet for the current chart.\n2. Summarize.", + ] + for s in samples: + assert _would_reprompt(s), s + + +# ── Delayed numbered tool action ────────────────────────────────── + + +def test_reprompts_on_numbered_plan_when_action_after_long_first_item(): + """Plans where the explicit tool action appears beyond the first 80 + chars (long preamble or long item 1) must STILL re-prompt. The + framing scan needs to cover the whole short candidate, not just the + nearest 80 chars.""" + samples = [ + ( + "Here's my plan:\n" + "1. Review the question and identify exactly what current data is " + "needed before using external sources.\n" + "2. Search the web for today's USD/EUR rate.\n" + "3. Answer with a citation." + ), + ( + "Here's my plan:\n" + "1. Clarify the requirements and identify the exact data source " + "that contains the current numbers.\n" + "2. Search the web for the current Billboard chart.\n" + "3. Summarise the answer." + ), + ( + "First, I'll explain the process before acting so the user can " + "follow along safely and so I can avoid using stale information.\n" + "1. Search the web for the current Billboard chart.\n" + "2. Summarise the answer." + ), + ] + for s in samples: + assert _would_reprompt(s), s + + +# ── Reasoning-only visible-output path ──────────────────────────── + + +def test_reasoning_only_visible_artifact_suppresses_reprompt(): + """When content_accum is empty AND there are no content tokens, the + backend yields reasoning_accum as plain content. In that case the + reasoning text IS the user-visible answer and a complete artifact + inside it should suppress the re-prompt.""" + from core.inference.llama_cpp import _REPROMPT_MAX_CHARS + + content_accum = "" + reasoning_accum = ( + "First, let me set up pygame.\n" + "```python\n" + "import pygame\n" + "pygame.init()\n" + "```" + ) + has_content_tokens = False + + visible = content_accum.strip() + reasoning = reasoning_accum.strip() + stripped = visible if visible else reasoning + artifact_text = visible if visible else (reasoning if not has_content_tokens else "") + would_reprompt = bool( + 0 < len(stripped) < _REPROMPT_MAX_CHARS + and _INTENT_SIGNAL.search(stripped) + and not (artifact_text and _has_answer_artifact(artifact_text)) + ) + assert not would_reprompt + + +def test_hidden_reasoning_artifact_still_reprompts(): + """When content tokens were emitted but content_accum is empty (a + streaming oddity) and reasoning hides a complete artifact, the user + sees nothing, so the re-prompt MUST still fire.""" + from core.inference.llama_cpp import _REPROMPT_MAX_CHARS + + content_accum = "" + reasoning_accum = ( + "First, let me draft it.\n" + "```python\n" + "print('hidden answer')\n" + "```" + ) + has_content_tokens = True # content existed but was stripped + + visible = content_accum.strip() + reasoning = reasoning_accum.strip() + stripped = visible if visible else reasoning + artifact_text = visible if visible else (reasoning if not has_content_tokens else "") + would_reprompt = bool( + 0 < len(stripped) < _REPROMPT_MAX_CHARS + and _INTENT_SIGNAL.search(stripped) + and not (artifact_text and _has_answer_artifact(artifact_text)) + ) + assert would_reprompt From 693bd89a79acf6ce10664e5f8be58413bbc7dd71 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 15:36:20 +0000 Subject: [PATCH 17/41] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/llama_cpp.py | 10 ++++++---- .../backend/tests/test_llama_cpp_reprompt_guard.py | 13 +++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index eef41608a8..7e21ad349c 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -4917,12 +4917,14 @@ class LlamaCppBackend: _visible = content_accum.strip() _reasoning = reasoning_accum.strip() _stripped = _visible if _visible else _reasoning - _artifact_text = _visible if _visible else ( - _reasoning if not has_content_tokens else "" + _artifact_text = ( + _visible + if _visible + else (_reasoning if not has_content_tokens else "") ) - _visible_has_artifact = bool(_artifact_text) and _has_answer_artifact( + _visible_has_artifact = bool( _artifact_text - ) + ) and _has_answer_artifact(_artifact_text) if ( tools and _reprompt_count < _MAX_REPROMPTS diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index b81f0c80df..23019fc2c7 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -672,7 +672,9 @@ def test_reasoning_only_visible_artifact_suppresses_reprompt(): visible = content_accum.strip() reasoning = reasoning_accum.strip() stripped = visible if visible else reasoning - artifact_text = visible if visible else (reasoning if not has_content_tokens else "") + artifact_text = ( + visible if visible else (reasoning if not has_content_tokens else "") + ) would_reprompt = bool( 0 < len(stripped) < _REPROMPT_MAX_CHARS and _INTENT_SIGNAL.search(stripped) @@ -689,17 +691,16 @@ def test_hidden_reasoning_artifact_still_reprompts(): content_accum = "" reasoning_accum = ( - "First, let me draft it.\n" - "```python\n" - "print('hidden answer')\n" - "```" + "First, let me draft it.\n" "```python\n" "print('hidden answer')\n" "```" ) has_content_tokens = True # content existed but was stripped visible = content_accum.strip() reasoning = reasoning_accum.strip() stripped = visible if visible else reasoning - artifact_text = visible if visible else (reasoning if not has_content_tokens else "") + artifact_text = ( + visible if visible else (reasoning if not has_content_tokens else "") + ) would_reprompt = bool( 0 < len(stripped) < _REPROMPT_MAX_CHARS and _INTENT_SIGNAL.search(stripped) From e16f898c262a3115c755c6696f8801c69e16db4a Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 15:58:19 +0000 Subject: [PATCH 18/41] 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 (?`{3,})[^\r\n]{0,200}\r?\n[\s\S]{1,4000}?\r?\n[ \t]*(?P=bf)`*[ \t]*(?:\r?\n|\Z)" + r"(?`{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). - r"|(?P~{3,})[^\r\n]{0,200}\r?\n[\s\S]{1,4000}?\r?\n[ \t]*(?P=tf)~*[ \t]*(?:\r?\n|\Z)" + # the body itself contains backticks). Anchored to the full run of + # tildes on both sides so a 4-tilde open cannot match a 3-tilde close. + r"|(?~{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"|(?:" # Complete SVG document. @@ -154,15 +159,48 @@ _PLAN_LIST_FRAMING = re.compile( ) +_FENCE_LINE_RE = re.compile(r"^[ \t]*(?P`{3,}|~{3,})(?P[^\r\n]*)$") + + +def _has_unclosed_code_fence(text: str) -> bool: + """True if ``text`` contains a code fence whose closer is missing. + + A complete fence answer is already caught by _HAS_ANSWER_ARTIFACT. + This helper exists so that an OPEN fence (model still streaming + code, or stream cut short) does not let an embedded numbered list + inside the fence body masquerade as a final answer. + """ + active_char: Optional[str] = None + active_len = 0 + for line in text.splitlines(): + m = _FENCE_LINE_RE.match(line) + if not m: + continue + fence = m.group("fence") + trailing = m.group("trailing").strip() + ch = fence[0] + 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_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 unclosed fence disqualifies the numbered-list fallback so a list + INSIDE incomplete code does not look like a final answer. """ if _HAS_ANSWER_ARTIFACT.search(text): return True + if _has_unclosed_code_fence(text): + return False if _NUMBERED_LIST_ARTIFACT.search(text): return _PLAN_LIST_FRAMING.search(text) is None return False diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 23019fc2c7..6a6afaa50b 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -234,15 +234,16 @@ def test_numbered_list_without_plan_framing_is_artifact(): def test_numbered_list_with_plan_framing_is_NOT_artifact(): """A numbered list paired with explicit plan framing (intent phrase - followed by a narrow tool-action verb such as ``search`` / ``fetch`` - / ``browse``) must NOT count as a completed artifact. The list IS - the plan, not the answer. Broad verbs like ``compare`` / ``use`` / - ``verify`` are intentionally NOT plan framing because real answer - lists use them.""" + followed by a freshness-gated tool-action verb such as + ``search the web`` / ``fetch the latest`` / ``query the internet``) + must NOT count as a completed artifact. The list IS the plan, not + the answer. Bare ``search the docs`` / ``compare versions`` / + ``verify input`` are intentionally NOT plan framing because real + answer lists use them.""" samples = [ - "Here's my plan:\n1. Search the web\n2. then summarise.", - "First, I'll do these:\n1. search for the song list\n2. cross-check the chart", - "Let me look up the values: fetch the data first.", + "Here's my plan:\n1. Search the web for the answer.\n2. then summarise.", + "First, I'll do these:\n1. fetch the latest chart\n2. cross-check", + "Let me look up the values: fetch the current data first.", ] for s in samples: assert _PLAN_LIST_FRAMING.search(s), s @@ -350,12 +351,12 @@ def test_reprompts_on_incomplete_html_intent(): def test_plan_framing_requires_apostrophe_in_ill(): """The ``i['’]ll`` plan-framing alternative requires an apostrophe so the regex does not match the word "ill" (sick). Without this, a - numbered list near "ill" plus an unrelated action verb would be - misclassified as a plan and trigger a spurious re-prompt.""" + numbered list near "ill" plus a freshness-gated lookup verb would + be misclassified as a plan and trigger a spurious re-prompt.""" samples = [ ("She is ill. Here is the list:\n1. Apple\n2. Orange\n3. Banana", False), - ("I'll search the docs:\n1. step\n2. step", True), - ("I will search:\n1. step\n2. step", True), + ("I'll search the web for X:\n1. step\n2. step", True), + ("I will search the latest docs:\n1. step\n2. step", True), ] for content, expected in samples: got = _would_reprompt(content) @@ -367,15 +368,17 @@ def test_reprompts_on_all_intent_form_numbered_action_plans(): ``_INTENT_SIGNAL`` accepts so numbered action plans phrased with ``Allow me``, ``I'm going to``, ``I'm gonna``, ``I am gonna``, ``I shall``, ``Now I``, ``Next I`` also re-prompt instead of being - silently classified as completed answers.""" + silently classified as completed answers. Each sample pairs the + intent form with a freshness-gated lookup verb so the cross-check + against _TOOL_ACTION_VERBS succeeds.""" samples = [ - "Allow me to do this:\n1. search the docs\n2. fetch the result", - "I'm going to do this:\n1. search the docs\n2. fetch the result", - "I'm gonna do this:\n1. search the docs\n2. fetch the result", - "I am gonna do this:\n1. search the docs\n2. fetch the result", - "I shall do this:\n1. search the docs\n2. fetch the result", - "Now I will do these:\n1. search\n2. summarise", - "Next I will do these:\n1. fetch\n2. compare", + "Allow me to do this:\n1. search the web for X\n2. fetch the latest result", + "I'm going to do this:\n1. search the latest docs\n2. fetch the current result", + "I'm gonna do this:\n1. search the web for X\n2. fetch the latest result", + "I am gonna do this:\n1. search the latest docs\n2. fetch the current result", + "I shall do this:\n1. search the web for X\n2. fetch the latest result", + "Now I will do these:\n1. search the web\n2. summarise", + "Next I will do these:\n1. fetch the latest chart\n2. compare", ] for s in samples: assert _would_reprompt(s), s @@ -683,6 +686,76 @@ def test_reasoning_only_visible_artifact_suppresses_reprompt(): assert not would_reprompt +def test_no_reprompt_on_binary_search_algorithm_answer(): + """A final answer that uses ``search`` as an ordinary algorithm verb + (binary search, linear search, depth-first search, etc.) must NOT + re-prompt. The lookup gating on ``search`` requires a freshness or + web/internet target, so ``Search the left half`` stays an answer.""" + samples = [ + ( + "First, use binary search:\n" + "1. Search the left half.\n" + "2. Search the right half." + ), + ( + "First, here are the debugging steps:\n" + "1. Search the project for the failing function.\n" + "2. Check the stack trace.\n" + "3. Verify your fix with tests." + ), + ] + for content in samples: + assert _has_answer_artifact(content), content + assert not _would_reprompt(content), content + + +def test_reprompts_on_numbered_plan_with_google_synonym(): + """``google the current X`` reads as an external lookup and STILL + re-prompts as a numbered tool plan.""" + samples = [ + "Here's my plan:\n1. Google the current Billboard chart.\n2. Summarise.", + "First, I'll do this:\n1. Investigate the current exchange rate.\n2. Cite source.", + "Here's my approach:\n1. Research the latest release notes.\n2. Summarise.", + ] + for s in samples: + assert _would_reprompt(s), s + + +def test_artifact_regex_rejects_shorter_commonmark_closing_fence(): + """Four-or-more delimiter opening fence cannot be closed by fewer + delimiters. The opener cannot backtrack to three delimiters and + consume the rest as info-string text.""" + samples = [ + "First, let me show.\n````python\nprint('hi')\n```", + "First, let me show.\n~~~~python\nprint('hi')\n~~~", + ] + for content in samples: + assert not _has_answer_artifact(content), content + assert _would_reprompt(content), content + + +def test_open_fence_with_inner_numbered_list_still_reprompts(): + """A response that opens a code fence and emits numbered lines INSIDE + must NOT count those lines as a completed numbered-list answer.""" + samples = [ + ( + "First, let me write it.\n" + "```text\n" + "1. Install dependencies\n" + "2. Run the app" + ), + ( + "Let me draft a checklist.\n" + "````markdown\n" + "1. step one\n" + "2. step two" + ), + ] + for content in samples: + assert not _has_answer_artifact(content), content + assert _would_reprompt(content), content + + def test_hidden_reasoning_artifact_still_reprompts(): """When content tokens were emitted but content_accum is empty (a streaming oddity) and reasoning hides a complete artifact, the user From cff8a6a1bc10feb1aa8b6762aa9cd5ee54a50d08 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 16:00:02 +0000 Subject: [PATCH 19/41] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/tests/test_llama_cpp_reprompt_guard.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 6a6afaa50b..7475ac134b 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -744,12 +744,7 @@ def test_open_fence_with_inner_numbered_list_still_reprompts(): "1. Install dependencies\n" "2. Run the app" ), - ( - "Let me draft a checklist.\n" - "````markdown\n" - "1. step one\n" - "2. step two" - ), + ("Let me draft a checklist.\n" "````markdown\n" "1. step one\n" "2. step two"), ] for content in samples: assert not _has_answer_artifact(content), content From b7c7427eb864d27b8232de1b8c620882f8584065 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 16:17:24 +0000 Subject: [PATCH 20/41] Studio: add ReDoS regression test for full-window plan-framing scan --- .../tests/test_llama_cpp_reprompt_guard.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 7475ac134b..6ebff5778c 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -518,6 +518,21 @@ def test_no_backtrack_on_tilde_fence_spam(): assert elapsed_ms < 50, f"guard took {elapsed_ms:.1f}ms on ~~~ spam" +def test_no_backtrack_on_plan_framing_long_preamble(): + """``_PLAN_LIST_FRAMING`` scans up to _REPROMPT_MAX_CHARS chars between + the intent phrase and a tool-action verb. A pathological 2000-char + payload with many false intent triggers must still complete fast.""" + import time + + payload = ( + "Here's my plan:\n" + ("long preamble text. " * 90) + "\nsearch the web for X" + ) + t0 = time.time() + _has_answer_artifact(payload) + elapsed_ms = (time.time() - t0) * 1000 + assert elapsed_ms < 50, f"guard took {elapsed_ms:.1f}ms on long-preamble plan" + + # ── Closing-fence-must-end-line edge cases ──────────────────────── From 4652a4b03c63fc49a42a0ec1038aaa2a68dd172e Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 20:20:38 +0000 Subject: [PATCH 21/41] 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. --- studio/backend/core/inference/llama_cpp.py | 29 +++++++-- .../tests/test_llama_cpp_reprompt_guard.py | 63 +++++++++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 6278167491..a843336eaf 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -74,6 +74,8 @@ _TOOL_ACTION_VERBS = ( rf"{_TOOL_LOOKUP_TARGET}|" r"(?:research|investigate|find|check|verify) (?: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)" ) @@ -122,11 +124,11 @@ _HAS_ANSWER_ARTIFACT = re.compile( # 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"(?`{3,})(?!`)[^\r\n]{0,200}\r?\n[\s\S]{1,4000}?\r?\n[ \t]*(?P=bf)(?!`)[ \t]*(?:\r?\n|\Z)" + r"(?`{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). Anchored to the full run of - # tildes on both sides so a 4-tilde open cannot match a 3-tilde close. - r"|(?~{3,})(?!~)[^\r\n]{0,200}\r?\n[\s\S]{1,4000}?\r?\n[ \t]*(?P=tf)(?!~)[ \t]*(?:\r?\n|\Z)" + # the body itself contains backticks). Opener anchored to the full + # run of tildes; closer accepts >= opener length per CommonMark. + r"|(?~{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"|(?:" # Complete SVG document. @@ -158,6 +160,16 @@ _PLAN_LIST_FRAMING = re.compile( 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, +) + _FENCE_LINE_RE = re.compile(r"^[ \t]*(?P`{3,}|~{3,})(?P[^\r\n]*)$") @@ -194,14 +206,19 @@ def _has_answer_artifact(text: str) -> bool: 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 unclosed fence disqualifies the numbered-list fallback so a list - INSIDE incomplete code does not look like a final answer. + An explicit ``Here's my plan`` / ``Here's my approach`` header is + also enough to flag the list as a plan, even when no narrow tool- + action verb appears in the items. An unclosed fence disqualifies + the numbered-list fallback so a list INSIDE incomplete code does + not look like a final answer. """ if _HAS_ANSWER_ARTIFACT.search(text): return True if _has_unclosed_code_fence(text): return False if _NUMBERED_LIST_ARTIFACT.search(text): + if _EXPLICIT_PLAN_HEADER.search(text): + return False return _PLAN_LIST_FRAMING.search(text) is None return False diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 6ebff5778c..d712e1bbac 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -749,6 +749,69 @@ def test_artifact_regex_rejects_shorter_commonmark_closing_fence(): assert _would_reprompt(content), content +def test_artifact_regex_accepts_longer_commonmark_closing_fence(): + """CommonMark allows the closing fence to have MORE delimiters than + the opener. A 3-backtick opener with a 4-backtick close, or a + 3-tilde opener with a 4-tilde close, is still a complete artifact.""" + samples = [ + "First, let me show.\n```python\nprint('hi')\n````", + "First, let me show.\n````python\nprint('``` inside')\n`````", + "First, let me show.\n~~~python\nprint('hi')\n~~~~", + ] + for content in samples: + assert _has_answer_artifact(content), content + assert not _would_reprompt(content), content + + +def test_reprompts_on_explicit_plan_header_numbered_list(): + """``Here's my plan`` / ``Here's my approach`` is a strong stand-alone + plan signal. The following numbered list is the plan itself, not a + final answer, even when no narrow tool-action verb appears.""" + samples = [ + "Here's my plan:\n1. Analyze the request.\n2. Draft the answer.", + "Here's my plan:\n1. Create the Python file.\n2. Add the game loop.\n3. Test.", + "Here's my approach:\n1. Outline.\n2. Write.\n3. Review.", + "Here's the plan:\n1. Define the variables.\n2. Return the result.", + ] + for s in samples: + assert _would_reprompt(s), s + + +def test_reprompts_on_numbered_plan_with_python_tool_wording(): + """``use python (tool) to ...`` / ``use the python tool`` / ``use the + search tool`` in a numbered plan still re-prompts.""" + samples = [ + "Here's my plan:\n1. Use Python to calculate the answer.\n2. Return.", + "First, I'll do this:\n1. Use the python tool to parse the file.\n2. Summarize.", + "Here's my plan:\n1. Use the search tool.\n2. Summarize.", + ] + for s in samples: + assert _would_reprompt(s), s + + +def test_no_reprompt_on_lesson_plan_answer_without_explicit_header(): + """A final answer with a ``Plan:`` heading (no ``Here's my`` + possessive) and no tool framing must STILL count as an answer. + Common cases: lesson plan, workout plan, meal plan.""" + samples = [ + ( + "Plan:\n" + "1. Warm up for 5 minutes.\n" + "2. Run for 20 minutes.\n" + "3. Cool down with stretching." + ), + ( + "My weekly plan:\n" + "1. Monday: rest.\n" + "2. Tuesday: jog.\n" + "3. Wednesday: swim." + ), + ] + for content in samples: + assert _has_answer_artifact(content), content + assert not _would_reprompt(content), content + + def test_open_fence_with_inner_numbered_list_still_reprompts(): """A response that opens a code fence and emits numbered lines INSIDE must NOT count those lines as a completed numbered-list answer.""" From 9fa736bef192d195a5ea420ff3df64f74ac05f3c Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 20:33:15 +0000 Subject: [PATCH 22/41] 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. --- studio/backend/core/inference/llama_cpp.py | 39 ++++++++-- .../tests/test_llama_cpp_reprompt_guard.py | 74 +++++++++++++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index a843336eaf..64abdae139 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -170,6 +170,27 @@ _EXPLICIT_PLAN_HEADER = re.compile( 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 follow-up verb list +# stays narrow to "do/proceed/build/run" style words so common +# answer prose like "Let me explain", "Let me show", "Let me draft a +# poem" is not misclassified as a stall. +_DIRECT_NUMBERED_PLAN_FRAMING = 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" + r"[^\r\n]{0,160}" + r"\b(?:do (?:this|these|the following|it)|" + r"proceed|start|begin|" + r"create|build|implement|set up|add|" + r"calculate|compute|analy[sz]e|parse|load|run|execute|test)\b" + r"[\s\S]{0,500}?" + r"(?:^|\r?\n)[ \t]*\d+\.", + re.IGNORECASE, +) + _FENCE_LINE_RE = re.compile(r"^[ \t]*(?P`{3,}|~{3,})(?P[^\r\n]*)$") @@ -206,19 +227,23 @@ def _has_answer_artifact(text: str) -> bool: 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 is - also enough to flag the list as a plan, even when no narrow tool- - action verb appears in the items. An unclosed fence disqualifies - the numbered-list fallback so a list INSIDE incomplete code does - not look like a final answer. + 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. An unclosed + fence anywhere in the response (including after an earlier complete + fence) disqualifies the answer-artifact path so half-finished code + does not look like a final answer. """ - if _HAS_ANSWER_ARTIFACT.search(text): - return True if _has_unclosed_code_fence(text): return False + if _HAS_ANSWER_ARTIFACT.search(text): + 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 return _PLAN_LIST_FRAMING.search(text) is None return False diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index d712e1bbac..e54b8de05d 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -812,6 +812,80 @@ def test_no_reprompt_on_lesson_plan_answer_without_explicit_header(): assert not _would_reprompt(content), content +def test_reprompts_on_direct_intent_numbered_local_action_plan(): + """Direct first-person intent (``I'll do this``, ``Let me do this``, + etc.) followed by a numbered list of work/tool actions is a plan + stall, not a final answer. Even when no narrow lookup verb appears + in the items, the model is announcing actions it has not yet + taken.""" + samples = [ + ( + "First, I'll do this:\n" + "1. Load the uploaded CSV.\n" + "2. Compute the total revenue.\n" + "3. Return the answer." + ), + ( + "Let me do this:\n" + "1. Parse the pasted JSON.\n" + "2. Calculate the average.\n" + "3. Explain the result." + ), + ( + "First, I'll create a Python game:\n" + "1. Set up pygame.\n" + "2. Add the game loop." + ), + ( + "First, I'll do these:\n" + "1. Create the Python file.\n" + "2. Add the game loop.\n" + "3. Test it." + ), + ] + for content in samples: + assert _would_reprompt(content), content + + +def test_no_reprompt_on_let_me_explain_numbered_answer(): + """``Let me explain`` / ``Let me show`` followed by a numbered + answer must NOT be misclassified as a plan stall. The verb after + the intent phrase is not in the work/tool whitelist.""" + samples = [ + ( + "Let me explain in steps:\n" + "1. Apples are red.\n" + "2. Bananas are yellow.\n" + "3. Cherries are red." + ), + ( + "Let me show the matches:\n" + "1. Maroon 5 - Animals.\n" + "2. Hozier - Take Me to Church." + ), + ] + for content in samples: + assert _has_answer_artifact(content), content + assert not _would_reprompt(content), content + + +def test_reprompts_when_later_fence_is_open_after_closed_fence(): + """A response with a complete code fence followed by a SECOND, + unclosed fence is still mid-stream and must re-prompt. The + `_has_unclosed_code_fence` cross-check must short-circuit even + after `_HAS_ANSWER_ARTIFACT` finds the first complete fence.""" + content = ( + "First, let me provide two files:\n" + "```python\n" + "print('main')\n" + "```\n" + "```python\n" + "print('utils')" + ) + assert not _has_answer_artifact(content) + assert _would_reprompt(content) + + def test_open_fence_with_inner_numbered_list_still_reprompts(): """A response that opens a code fence and emits numbered lines INSIDE must NOT count those lines as a completed numbered-list answer.""" From 7b0ed8333a454666d39bb747f73b7d1122ab2bc3 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 20:47:34 +0000 Subject: [PATCH 23/41] 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. --- studio/backend/core/inference/llama_cpp.py | 19 ++++-- .../tests/test_llama_cpp_reprompt_guard.py | 63 +++++++++++++++++++ 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 64abdae139..ab4a078d92 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -180,9 +180,11 @@ _EXPLICIT_PLAN_HEADER = re.compile( # poem" is not misclassified as a stall. _DIRECT_NUMBERED_PLAN_FRAMING = 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" + r"i will|i shall|let me|allow me|now i|next i|" + r"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" @@ -192,7 +194,9 @@ _DIRECT_NUMBERED_PLAN_FRAMING = re.compile( ) -_FENCE_LINE_RE = re.compile(r"^[ \t]*(?P`{3,}|~{3,})(?P[^\r\n]*)$") +_FENCE_RUN_RE = re.compile( + r"(?`{3,})(?!`)|(?~{3,})(?!~)" +) def _has_unclosed_code_fence(text: str) -> bool: @@ -201,16 +205,19 @@ def _has_unclosed_code_fence(text: str) -> bool: A complete fence answer is already caught by _HAS_ANSWER_ARTIFACT. This helper exists so that an OPEN fence (model still streaming code, or stream cut short) does not let an embedded numbered list - inside the fence body masquerade as a final answer. + inside the fence body masquerade as a final answer. The scan is + done per line and uses ``search`` (not ``match``) so an inline + opener such as ``First, let me write it. \\`\\`\\`python`` is also + tracked. """ active_char: Optional[str] = None active_len = 0 for line in text.splitlines(): - m = _FENCE_LINE_RE.match(line) + m = _FENCE_RUN_RE.search(line) if not m: continue - fence = m.group("fence") - trailing = m.group("trailing").strip() + fence = m.group("backticks") or m.group("tildes") + trailing = line[m.end():].strip() ch = fence[0] if active_char is None: active_char = ch diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index e54b8de05d..94a368a7ea 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -869,6 +869,69 @@ def test_no_reprompt_on_let_me_explain_numbered_answer(): assert not _would_reprompt(content), content +def test_same_line_open_fence_with_numbered_body_still_reprompts(): + """An OPEN code fence on the same line as preceding prose ("First, + let me write it. ``\\u00e0``text\\n...") still gates the numbered-list + fallback. The unclosed-fence helper now uses ``search`` so inline + openers are tracked, not just openers at column 0.""" + content = ( + "First, let me write it. ```text\n" + "1. Install dependencies\n" + "2. Run the app" + ) + assert not _has_answer_artifact(content) + assert _would_reprompt(content) + + +def test_reprompts_on_first_step_numbered_compute_plan(): + """Bare ``First, [verb]`` / ``Step N: [verb]`` followed by a numbered + list is a plan stall when the verb implies compute / tool work + (analyze, parse, calculate, create, etc.). Distinct from + ``First, use binary search:`` (verb ``use`` not in whitelist).""" + samples = [ + ( + "First, analyze the uploaded CSV:\n" + "1. Load the rows.\n" + "2. Compute the average revenue." + ), + ( + "First, parse the pasted JSON:\n" + "1. Load the object.\n" + "2. Calculate the total." + ), + ( + "First, create the Python game:\n" + "1. Set up pygame.\n" + "2. Add the game loop." + ), + ( + "Step 1: analyze the uploaded CSV:\n" + "1. Load rows.\n" + "2. Compute the total." + ), + ( + "I'll look that up:\n" + "1. Search the docs.\n" + "2. Summarize the result." + ), + ] + for content in samples: + assert _would_reprompt(content), content + + +def test_no_reprompt_on_first_use_binary_search_answer(): + """``First, use binary search:`` is an ordinary algorithm answer. + ``use`` is not in the direct-numbered-plan verb whitelist so the + following list stays an answer.""" + content = ( + "First, use binary search:\n" + "1. Search the left half.\n" + "2. Search the right half." + ) + assert _has_answer_artifact(content) + assert not _would_reprompt(content) + + def test_reprompts_when_later_fence_is_open_after_closed_fence(): """A response with a complete code fence followed by a SECOND, unclosed fence is still mid-stream and must re-prompt. The From 2d4d7136ed81ddff0074ddb4d14dc7b0f34d5b27 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 20:47:44 +0000 Subject: [PATCH 24/41] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/llama_cpp.py | 2 +- studio/backend/tests/test_llama_cpp_reprompt_guard.py | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index ab4a078d92..b82c262485 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -217,7 +217,7 @@ def _has_unclosed_code_fence(text: str) -> bool: if not m: continue fence = m.group("backticks") or m.group("tildes") - trailing = line[m.end():].strip() + trailing = line[m.end() :].strip() ch = fence[0] if active_char is None: active_char = ch diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 94a368a7ea..7ec3464cad 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -875,9 +875,7 @@ def test_same_line_open_fence_with_numbered_body_still_reprompts(): fallback. The unclosed-fence helper now uses ``search`` so inline openers are tracked, not just openers at column 0.""" content = ( - "First, let me write it. ```text\n" - "1. Install dependencies\n" - "2. Run the app" + "First, let me write it. ```text\n" "1. Install dependencies\n" "2. Run the app" ) assert not _has_answer_artifact(content) assert _would_reprompt(content) @@ -909,11 +907,7 @@ def test_reprompts_on_first_step_numbered_compute_plan(): "1. Load rows.\n" "2. Compute the total." ), - ( - "I'll look that up:\n" - "1. Search the docs.\n" - "2. Summarize the result." - ), + ("I'll look that up:\n" "1. Search the docs.\n" "2. Summarize the result."), ] for content in samples: assert _would_reprompt(content), content From e0786059367e9493b7e359c615edf0b1bf40c8bd Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 20:59:25 +0000 Subject: [PATCH 25/41] 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 or 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. --- studio/backend/core/inference/llama_cpp.py | 58 ++++++++++++++----- .../tests/test_llama_cpp_reprompt_guard.py | 34 +++++++++++ 2 files changed, 77 insertions(+), 15 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index b82c262485..fdd24daf40 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -72,7 +72,8 @@ _TOOL_ACTION_VERBS = ( r"(?:web|internet|online(?: sources?)?)|" r"fetch (?:the |a |an )?" rf"{_TOOL_LOOKUP_TARGET}|" - r"(?:research|investigate|find|check|verify) (?:for )?(?:the |a |an )?" + 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|" @@ -228,6 +229,24 @@ def _has_unclosed_code_fence(text: str) -> bool: return active_char is not None +def _has_unclosed_markup_block(text: str) -> bool: + """True if ``text`` opens an / block without closing it. + + `_HAS_ANSWER_ARTIFACT` already requires a matching `` / + `` for the artifact branch. This helper exists to disqualify + the numbered-list fallback when partial markup is present: a list + inside an unfinished `...` body must not look like a final + answer. + """ + return bool( + re.search(r"", text, re.IGNORECASE) + ) or bool( + re.search(r"", text, re.IGNORECASE) + ) + + def _has_answer_artifact(text: str) -> bool: """True if ``text`` looks like a completed answer artifact. @@ -238,14 +257,16 @@ def _has_answer_artifact(text: str) -> bool: 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. An unclosed - fence anywhere in the response (including after an earlier complete - fence) disqualifies the answer-artifact path so half-finished code - does not look like a final answer. + fence or unclosed `` / `` block anywhere in the response + disqualifies the answer-artifact path so half-finished output does + not look like a final answer. """ if _has_unclosed_code_fence(text): return False if _HAS_ANSWER_ARTIFACT.search(text): return True + if _has_unclosed_markup_block(text): + return False if _NUMBERED_LIST_ARTIFACT.search(text): if _EXPLICIT_PLAN_HEADER.search(text): return False @@ -5004,21 +5025,28 @@ class LlamaCppBackend: _visible = content_accum.strip() _reasoning = reasoning_accum.strip() _stripped = _visible if _visible else _reasoning - _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) - if ( + # 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) - and not _visible_has_artifact - ): + ) + 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}: " diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 7ec3464cad..6c0bf75539 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -913,6 +913,40 @@ def test_reprompts_on_first_step_numbered_compute_plan(): assert _would_reprompt(content), content +def test_reprompts_on_incomplete_html_with_inner_numbered_list(): + """Partial markup (open with no ) plus a numbered + list must NOT be treated as a final answer; the markup is still + being streamed.""" + samples = [ + ( + "First, I'll draft a page.\n" + "\n" + "1. Section one.\n" + "2. Section two.\n" + ), + ( + "Let me design a chart.\n" + "\n" + "1. circle.\n" + "2. rect." + ), + ] + for content in samples: + assert not _has_answer_artifact(content), content + assert _would_reprompt(content), content + + +def test_reprompts_on_numbered_compare_or_review_lookup_plan(): + """Freshness-gated ``compare`` / ``review`` lookups read as tool + plans and STILL re-prompt as numbered plans.""" + samples = [ + "Here's my plan:\n1. Compare the latest release sources.\n2. Summarise.", + "First, I'll do this:\n1. Review the current documentation.\n2. Answer.", + ] + for s in samples: + assert _would_reprompt(s), s + + def test_no_reprompt_on_first_use_binary_search_answer(): """``First, use binary search:`` is an ordinary algorithm answer. ``use`` is not in the direct-numbered-plan verb whitelist so the From aba4824de2baf5d6413e9b2a5b4741813e762ada Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 20:59:37 +0000 Subject: [PATCH 26/41] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/tests/test_llama_cpp_reprompt_guard.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 6c0bf75539..8f9dd04ff1 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -924,12 +924,7 @@ def test_reprompts_on_incomplete_html_with_inner_numbered_list(): "1. Section one.\n" "2. Section two.\n" ), - ( - "Let me design a chart.\n" - "\n" - "1. circle.\n" - "2. rect." - ), + ("Let me design a chart.\n" "\n" "1. circle.\n" "2. rect."), ] for content in samples: assert not _has_answer_artifact(content), content From 4809503cce9821a19f30c9ae00ed171e10a78c8f Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 21:10:59 +0000 Subject: [PATCH 27/41] 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 followed by a second still-open (multi-page mid-stream) or 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 / skeletons. Plan-only mentions ("First, I'll create an 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. --- studio/backend/core/inference/llama_cpp.py | 94 +++++++++++++++---- .../tests/test_llama_cpp_reprompt_guard.py | 43 +++++++++ 2 files changed, 119 insertions(+), 18 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index fdd24daf40..e6e0263180 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -232,19 +232,67 @@ def _has_unclosed_code_fence(text: str) -> bool: def _has_unclosed_markup_block(text: str) -> bool: """True if ``text`` opens an / block without closing it. - `_HAS_ANSWER_ARTIFACT` already requires a matching `` / - `` for the artifact branch. This helper exists to disqualify - the numbered-list fallback when partial markup is present: a list - inside an unfinished `...` body must not look like a final - answer. + 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. """ - return bool( - re.search(r"", text, re.IGNORECASE) - ) or bool( - re.search(r"", text, re.IGNORECASE) - ) + opens_html = len(re.findall(r"", text, re.IGNORECASE)) + if opens_html > closes_html: + return True + opens_svg = len(re.findall(r"", text, re.IGNORECASE)) + return opens_svg > closes_svg + + +# Matches the full span of an empty or +# skeleton. Plan-only mentions ("First, I'll create an +# skeleton") would otherwise look like a complete page. +_EMPTY_MARKUP_SKELETON = re.compile( + r"<(html|svg)\b[^>]*>\s*", + re.IGNORECASE, +) + + +# 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" +) +_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, +) + + +def _looks_like_real_artifact(text: str) -> bool: + """Match _HAS_ANSWER_ARTIFACT but reject empty markup skeletons.""" + m = _HAS_ANSWER_ARTIFACT.search(text) + if not m: + return False + matched = m.group(0).strip() + if _EMPTY_MARKUP_SKELETON.fullmatch(matched): + return False + return True def _has_answer_artifact(text: str) -> bool: @@ -256,22 +304,32 @@ def _has_answer_artifact(text: str) -> bool: 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. An unclosed - fence or unclosed `` / `` block anywhere in the response - disqualifies the answer-artifact path so half-finished output does - not look like a final answer. + when no narrow tool-action verb appears in the items. Any unclosed + fence or unclosed `` / `` 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 `` / `` skeletons do not count. """ if _has_unclosed_code_fence(text): return False - if _HAS_ANSWER_ARTIFACT.search(text): - return True if _has_unclosed_markup_block(text): return False + if _looks_like_real_artifact(text): + 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 + # 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 diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 8f9dd04ff1..00ba9f5ba1 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -931,6 +931,49 @@ def test_reprompts_on_incomplete_html_with_inner_numbered_list(): assert _would_reprompt(content), content +def test_reprompts_when_complete_html_is_followed_by_open_html(): + """A response with one closed followed by a second + that is still open must re-prompt. The unbalanced-tag count makes + the artifact path fail even though an earlier artifact exists.""" + samples = [ + "Here is the first page:\n1\nNow the next:\n", + "First page done:\n\nNow:\n", + ] + for content in samples: + assert not _has_answer_artifact(content), content + assert _would_reprompt(content), content + + +def test_reprompts_on_empty_html_or_svg_skeleton_mention(): + """```` / ```` with no body content is a + plan-only mention, not a substantive answer.""" + samples = [ + "First, I'll create an skeleton, then add CSS.", + "First, I'll draft a icon, then add shapes.", + ] + for content in samples: + assert not _has_answer_artifact(content), content + assert _would_reprompt(content), content + + +def test_reprompts_on_intent_plus_numbered_action_items(): + """``First, I'll:\\n1. Load CSV\\n2. Compute total`` is a plan stall: + the work verbs are in the list ITEMS even though no work verb + appears between the intent phrase and the list. Verbs are taken + from the narrow _LOCAL_ACTION_VERBS set (load, parse, calculate, + compute, analyze, run, execute, fetch, download, query, inspect, + extract). Bare ``read`` / ``search`` / ``check`` are NOT in the + set because they appear in non-plan answers too.""" + samples = [ + "First, I'll:\n1. Load the uploaded CSV.\n2. Compute the total revenue.", + "Let me:\n1. Parse the JSON.\n2. Calculate the average.", + "Now I:\n1. Inspect the file.\n2. Analyze the rows.", + "I'll:\n1. Fetch the latest data.\n2. Compute the totals.", + ] + for content in samples: + assert _would_reprompt(content), content + + def test_reprompts_on_numbered_compare_or_review_lookup_plan(): """Freshness-gated ``compare`` / ``review`` lookups read as tool plans and STILL re-prompt as numbered plans.""" From 7c08ade3a4820be734464397031faa7074949fe4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 21:11:10 +0000 Subject: [PATCH 28/41] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/llama_cpp.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index e6e0263180..ff7b1eb598 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -325,9 +325,8 @@ def _has_answer_artifact(text: str) -> bool: # 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) + if _STRONG_INTENT_BEFORE_LIST.search(text) and _NUMBERED_ACTION_ITEM.search( + text ): return False return _PLAN_LIST_FRAMING.search(text) is None From 2ea2f3519a667e750b879f63974d7d0f62a4e6e8 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 21:22:26 +0000 Subject: [PATCH 29/41] 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 "" / "" 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 / skeleton followed by a real complete page no longer hides the real artifact. - _is_empty_markup_skeleton() strips an optional prefix before testing the empty-skeleton pattern, so "" 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. --- studio/backend/core/inference/llama_cpp.py | 69 ++++++++++++--- .../tests/test_llama_cpp_reprompt_guard.py | 84 +++++++++++++++++++ 2 files changed, 143 insertions(+), 10 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index ff7b1eb598..981df074eb 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -119,6 +119,16 @@ _MAX_REPROMPTS = 3 # `` or `` do not bypass the re-prompt. # * All `[\s\S]{...}?` runs are length-bounded so the search stays # linear on adversarial input (CRLF spam, repeated `` etc.). +_CLOSED_CODE_FENCE = re.compile( + r"(?`{3,})(?!`)[^\r\n]{0,200}\r?\n[\s\S]{1,4000}?\r?\n[ \t]*(?P=bf)`*[ \t]*(?:\r?\n|\Z)" + r"|(?~{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"(?:" + r"|", + 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 @@ -254,6 +264,18 @@ _EMPTY_MARKUP_SKELETON = re.compile( r"<(html|svg)\b[^>]*>\s*", re.IGNORECASE, ) +_DOCTYPE_PREFIX = re.compile( + r"^", + re.IGNORECASE, +) + + +def _is_empty_markup_skeleton(matched: str) -> bool: + """True if ``matched`` is just an empty / + (optionally with a `` 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 @@ -283,16 +305,34 @@ _STRONG_INTENT_BEFORE_LIST = re.compile( 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|look up|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)\b", + re.IGNORECASE, +) + def _looks_like_real_artifact(text: str) -> bool: - """Match _HAS_ANSWER_ARTIFACT but reject empty markup skeletons.""" - m = _HAS_ANSWER_ARTIFACT.search(text) - if not m: - return False - matched = m.group(0).strip() - if _EMPTY_MARKUP_SKELETON.fullmatch(matched): - return False - return True + """Match _HAS_ANSWER_ARTIFACT but reject empty markup skeletons. + + Iterates every artifact match in ``text`` so an empty + 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: @@ -310,9 +350,16 @@ def _has_answer_artifact(text: str) -> bool: answer, even when an earlier complete artifact is also present. Empty `` / `` skeletons do not count. """ - if _has_unclosed_code_fence(text): + # Cross-strip closed artifacts before the unclosed-state checks so + # delimiter-like content INSIDE a complete code fence (e.g. + # `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) + if _has_unclosed_code_fence(text_without_closed_markup): return False - if _has_unclosed_markup_block(text): + if _has_unclosed_markup_block(text_without_closed_fences): return False if _looks_like_real_artifact(text): return True @@ -321,6 +368,8 @@ def _has_answer_artifact(text: str) -> bool: 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 diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 00ba9f5ba1..683e433e64 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -956,6 +956,90 @@ def test_reprompts_on_empty_html_or_svg_skeleton_mention(): assert _would_reprompt(content), content +def test_no_reprompt_on_code_fence_containing_markup_literal(): + """A closed code fence whose body contains literal ````, + ````, ```` strings is still a complete code answer. + The unclosed-markup cross-check operates on text with closed + fences stripped out so code literals do not falsely trip it.""" + samples = [ + ( + "First, let me write the scraper.\n" + "```python\n" + "html = ''\n" + "svg = \"\"\n" + "print(html, svg)\n" + "```" + ), + ( + "First, let me write the parser.\n" + "```javascript\n" + "const open = '';\n" + "const fragment = '';\n" + "console.log(open, fragment);\n" + "```" + ), + ] + for content in samples: + assert _has_answer_artifact(content), content + assert not _would_reprompt(content), content + + +def test_no_reprompt_on_html_containing_backtick_literal(): + """A complete answer whose body contains a JS string with + literal backticks is still a complete page. The unclosed-fence + cross-check operates on text with closed markup stripped out.""" + content = ( + "First, here is the page.\n" + "" + ) + assert _has_answer_artifact(content) + assert not _would_reprompt(content) + + +def test_empty_markup_before_real_artifact_still_counts_real_artifact(): + """An empty / skeleton that PRECEDES a + real complete artifact must not hide it. _looks_like_real_artifact + iterates every match.""" + samples = [ + ( + "First, the minimal skeleton is . " + "Here is the full page:

Hello

" + ), + ( + "First, the icon skeleton is . " + "Here is the full SVG: " + "" + ), + ] + for content in samples: + assert _has_answer_artifact(content), content + assert not _would_reprompt(content), content + + +def test_doctype_empty_html_skeleton_still_reprompts(): + """```` is an empty skeleton even with + a doctype prefix; the artifact check must reject it.""" + content = ( + "First, I'll create a skeleton, then add CSS." + ) + assert not _has_answer_artifact(content) + assert _would_reprompt(content) + + +def test_reprompts_on_bare_intent_colon_numbered_plan(): + """Bare ``I'll:`` / ``Let me:`` immediately followed by a numbered + list of work verbs is a tool stall regardless of whether the verbs + sit before or in the list.""" + samples = [ + "I'll:\n1. Open the URL.\n2. Read the page.\n3. Summarize the answer.", + "First, I'll:\n1. Create the Python file.\n2. Build the game loop.\n3. Test it.", + "Let me:\n1. Parse the JSON.\n2. Calculate the average.", + ] + for content in samples: + assert not _has_answer_artifact(content), content + assert _would_reprompt(content), content + + def test_reprompts_on_intent_plus_numbered_action_items(): """``First, I'll:\\n1. Load CSV\\n2. Compute total`` is a plan stall: the work verbs are in the list ITEMS even though no work verb From ae8f70f05e8c6f017789478877aa8376614b3b86 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 21:22:37 +0000 Subject: [PATCH 30/41] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/llama_cpp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 981df074eb..63f26a51e0 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -274,7 +274,7 @@ def _is_empty_markup_skeleton(matched: str) -> bool: """True if ``matched`` is just an empty / (optionally with a `` prefix and surrounding whitespace). These read as plan-only mentions, not substantive answers.""" - candidate = _DOCTYPE_PREFIX.sub("", matched.strip(), count=1).strip() + candidate = _DOCTYPE_PREFIX.sub("", matched.strip(), count = 1).strip() return _EMPTY_MARKUP_SKELETON.fullmatch(candidate) is not None From 6ea922a58d80725b5676522a2c533163312dbf1f Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 21:34:52 +0000 Subject: [PATCH 31/41] Studio: r20 fixes - skip markup-count when real artifact exists When a real complete artifact is already in the response, prose mentions of bare / tags in explanatory text are common (for example "Use the 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 answers that nest children or contain JS string literals like "", since those unmatched markup tokens were being flagged as unclosed. --- studio/backend/core/inference/llama_cpp.py | 12 ++++- .../tests/test_llama_cpp_reprompt_guard.py | 45 +++++++++++++++---- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 63f26a51e0..589ecd2f52 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -357,11 +357,19 @@ def _has_answer_artifact(text: str) -> bool: # 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 - if _has_unclosed_markup_block(text_without_closed_fences): + # 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 ```` / + # ```` 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 _looks_like_real_artifact(text): + if real_artifact: return True if _NUMBERED_LIST_ARTIFACT.search(text): if _EXPLICIT_PLAN_HEADER.search(text): diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 683e433e64..aa51669955 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -931,17 +931,18 @@ def test_reprompts_on_incomplete_html_with_inner_numbered_list(): assert _would_reprompt(content), content -def test_reprompts_when_complete_html_is_followed_by_open_html(): - """A response with one closed followed by a second - that is still open must re-prompt. The unbalanced-tag count makes - the artifact path fail even though an earlier artifact exists.""" +def test_complete_html_with_trailing_prose_tag_still_counts(): + """A complete answer followed by prose that mentions + or tags (explanatory text) stays a complete artifact. The + unbalanced-tag count is skipped once a real artifact exists so + common explanatory prose does not falsely wipe valid answers.""" samples = [ - "Here is the first page:\n1\nNow the next:\n", - "First page done:\n\nNow:\n", + "Here is the page:\n1\nUse the tag for the root.", + "Here is the SVG: Place it inside an page.", ] for content in samples: - assert not _has_answer_artifact(content), content - assert _would_reprompt(content), content + assert _has_answer_artifact(content), content + assert not _would_reprompt(content), content def test_reprompts_on_empty_html_or_svg_skeleton_mention(): @@ -984,6 +985,34 @@ def test_no_reprompt_on_code_fence_containing_markup_literal(): assert not _would_reprompt(content), content +def test_no_reprompt_on_html_with_inner_svg_or_self_closing_tag(): + """Complete answers that contain nested SVG / self-closing + tags are still complete pages. The unbalanced-count cross-check is + skipped when a real artifact already exists.""" + samples = [ + "", + "" + "" + "", + ] + for content in samples: + assert _has_answer_artifact(content), content + assert not _would_reprompt(content), content + + +def test_no_reprompt_on_complete_artifact_with_prose_tag_mention(): + """Complete code/markup artifacts followed by ordinary prose that + mentions ```` or ```` tags are not mid-stream output.""" + samples = [ + "hi\nUse the tag as the root.", + ( + "First, here is the SVG: \n" + "Put it inside an page if needed." + ), + ] + for content in samples: + assert _has_answer_artifact(content), content + assert not _would_reprompt(content), content + + def test_no_reprompt_on_html_containing_backtick_literal(): """A complete answer whose body contains a JS string with literal backticks is still a complete page. The unclosed-fence From 64a400be2d25fef66c07669324dd9bde07073bad Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 21:35:05 +0000 Subject: [PATCH 32/41] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/tests/test_llama_cpp_reprompt_guard.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index aa51669955..5d6d4712ee 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -991,7 +991,9 @@ def test_no_reprompt_on_html_with_inner_svg_or_self_closing_tag(): skipped when a real artifact already exists.""" samples = [ "", - "" + "" + "", + "" + + "" + + "", ] for content in samples: assert _has_answer_artifact(content), content From 834b34c68dce0fb6d7439d4f6c71d3315327da78 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 21:50:39 +0000 Subject: [PATCH 33/41] 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 ``...`` 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. --- studio/backend/core/inference/llama_cpp.py | 35 +++++++++++++++---- .../tests/test_llama_cpp_reprompt_guard.py | 34 ++++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 589ecd2f52..eb548606b9 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -185,20 +185,31 @@ _EXPLICIT_PLAN_HEADER = re.compile( # 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 follow-up verb list -# stays narrow to "do/proceed/build/run" style words so common -# answer prose like "Let me explain", "Let me show", "Let me draft a -# poem" is not misclassified as a stall. +# 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|" - r"first|step \d+:?)\b" + 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"do (?:this|these|the following|it)|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, @@ -5136,7 +5147,17 @@ class LlamaCppBackend: # case); otherwise reasoning stays hidden and # an artifact inside it must NOT suppress the # re-prompt. - _visible = content_accum.strip() + # Strip orphan tool-call XML before measuring + # the visible answer. An ``...`` + # 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 diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 5d6d4712ee..8a6087acb0 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -985,6 +985,40 @@ def test_no_reprompt_on_code_fence_containing_markup_literal(): assert not _would_reprompt(content), content +def test_reprompts_on_direct_first_person_read_check_open_plan(): + """Direct first-person intent + open/read/check/review/inspect verbs + + numbered list is a tool stall. The broader verb set applies to + first-person intent only; bare ``First, ...`` and ``Step N: ...`` + keep their narrower verb whitelist.""" + samples = [ + ( + "Let me read the uploaded file:\n" + "1. Identify the columns.\n" + "2. Return the total." + ), + ( + "I will check the docs:\n" + "1. Gather relevant sections.\n" + "2. Answer." + ), + ( + "First, I'll review the repository:\n" + "1. Open the relevant file.\n" + "2. Read the implementation.\n" + "3. Suggest a fix." + ), + ( + "Let me examine the log file:\n" + "1. Open the log.\n" + "2. Read the errors.\n" + "3. Summarize." + ), + ] + for content in samples: + assert not _has_answer_artifact(content), content + assert _would_reprompt(content), content + + def test_no_reprompt_on_html_with_inner_svg_or_self_closing_tag(): """Complete answers that contain nested SVG / self-closing tags are still complete pages. The unbalanced-count cross-check is From 394cdf48ea6891259c9e3a29177b44fea7e81522 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 21:51:13 +0000 Subject: [PATCH 34/41] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/llama_cpp.py | 2 +- studio/backend/tests/test_llama_cpp_reprompt_guard.py | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index eb548606b9..c2652a683b 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -5154,7 +5154,7 @@ class LlamaCppBackend: # artifact check. _visible_raw = content_accum.strip() _visible = ( - _strip_tool_markup(content_accum, final=True).strip() + _strip_tool_markup(content_accum, final = True).strip() if _visible_raw else "" ) diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 8a6087acb0..b0a64bf0bc 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -996,11 +996,7 @@ def test_reprompts_on_direct_first_person_read_check_open_plan(): "1. Identify the columns.\n" "2. Return the total." ), - ( - "I will check the docs:\n" - "1. Gather relevant sections.\n" - "2. Answer." - ), + ("I will check the docs:\n" "1. Gather relevant sections.\n" "2. Answer."), ( "First, I'll review the repository:\n" "1. Open the relevant file.\n" From 32b64aef972bdc3f5c3fd39c215856e9970e8a51 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 22:01:57 +0000 Subject: [PATCH 35/41] 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. --- studio/backend/core/inference/llama_cpp.py | 26 ++++++++----- .../tests/test_llama_cpp_reprompt_guard.py | 37 +++++++++++++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index c2652a683b..75aeaf3859 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -198,7 +198,11 @@ _DIRECT_NUMBERED_PLAN_FRAMING = re.compile( 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"do (?:this|these|the following|it)|proceed|start|begin|" + r"do (?:this|these|the following|it)|" + r"take (?:these|the following) steps|" + r"follow (?:these|the following) steps|" + r"perform (?:these|the following) 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"|" @@ -224,13 +228,12 @@ _FENCE_RUN_RE = re.compile( def _has_unclosed_code_fence(text: str) -> bool: """True if ``text`` contains a code fence whose closer is missing. - A complete fence answer is already caught by _HAS_ANSWER_ARTIFACT. - This helper exists so that an OPEN fence (model still streaming - code, or stream cut short) does not let an embedded numbered list - inside the fence body masquerade as a final answer. The scan is - done per line and uses ``search`` (not ``match``) so an inline - opener such as ``First, let me write it. \\`\\`\\`python`` is also - tracked. + Each line is scanned so inline openers like ``First. \\`\\`\\`python`` + are tracked. Prose mentions such as ``Use \\`\\`\\` to start a + fence.`` are filtered out by requiring the trailing characters + after the fence run to look like a CommonMark info string: empty, + or starting with a non-space character (so prose with a leading + space disqualifies the run). """ active_char: Optional[str] = None active_len = 0 @@ -239,8 +242,13 @@ def _has_unclosed_code_fence(text: str) -> bool: if not m: continue fence = m.group("backticks") or m.group("tildes") - trailing = line[m.end() :].strip() + raw_trailing = line[m.end():] + trailing = raw_trailing.strip() ch = fence[0] + # Prose mention guard: a real fence line never has a space + # immediately after the delimiters followed by sentence text. + if raw_trailing and raw_trailing[0] == " " and trailing: + continue if active_char is None: active_char = ch active_len = len(fence) diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index b0a64bf0bc..1b408da27f 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -985,6 +985,43 @@ def test_no_reprompt_on_code_fence_containing_markup_literal(): assert not _would_reprompt(content), content +def test_reprompts_on_take_or_follow_steps_numbered_plan(): + """``I'll take these steps:`` / ``I will follow these steps:`` + + numbered list of work items is a plan stall.""" + samples = [ + ( + "I'll take these steps:\n" + "1. Open the URL.\n" + "2. Read the page.\n" + "3. Summarize the answer." + ), + ( + "I will follow these steps:\n" + "1. Open the current docs.\n" + "2. Read the relevant section.\n" + "3. Answer." + ), + ] + for content in samples: + assert not _has_answer_artifact(content), content + assert _would_reprompt(content), content + + +def test_no_reprompt_on_prose_mention_of_triple_backticks_after_code(): + """Closed code fence followed by prose that describes triple- + backtick syntax (with leading space after the ticks) must NOT be + treated as an unclosed fence.""" + content = ( + "Here is the snippet:\n" + "```python\n" + "print(1)\n" + "```\n" + "Use ``` to start a markdown code fence in your reply." + ) + assert _has_answer_artifact(content) + assert not _would_reprompt(content) + + def test_reprompts_on_direct_first_person_read_check_open_plan(): """Direct first-person intent + open/read/check/review/inspect verbs + numbered list is a tool stall. The broader verb set applies to From 9f3c49a7331a1da9b06830aed72c868f06aa8a33 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 22:02:09 +0000 Subject: [PATCH 36/41] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/llama_cpp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 75aeaf3859..c0940530ce 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -242,7 +242,7 @@ def _has_unclosed_code_fence(text: str) -> bool: if not m: continue fence = m.group("backticks") or m.group("tildes") - raw_trailing = line[m.end():] + raw_trailing = line[m.end() :] trailing = raw_trailing.strip() ch = fence[0] # Prose mention guard: a real fence line never has a space From 5564cfa1f4d8b1ff2ad22de1812ab8de368829b7 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 22:14:17 +0000 Subject: [PATCH 37/41] 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. --- studio/backend/core/inference/llama_cpp.py | 40 ++++++++----- .../tests/test_llama_cpp_reprompt_guard.py | 60 +++++++++++++++++++ 2 files changed, 84 insertions(+), 16 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index c0940530ce..d86b0d9b80 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -88,7 +88,7 @@ _INTENT_SIGNAL = re.compile( # Handles both straight and curly apostrophes. # Excludes "I can", "I should", "I want to", "let's" which # 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"\b(i['\u2019](ll|m going to|m gonna)|i am (going to|gonna)|i will|i shall|i need to|let me|allow me)\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))" @@ -194,10 +194,11 @@ _EXPLICIT_PLAN_HEADER = re.compile( _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"i will|i shall|i need to|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 (?:these|the following) steps|" r"follow (?:these|the following) steps|" @@ -228,12 +229,13 @@ _FENCE_RUN_RE = re.compile( def _has_unclosed_code_fence(text: str) -> bool: """True if ``text`` contains a code fence whose closer is missing. - Each line is scanned so inline openers like ``First. \\`\\`\\`python`` - are tracked. Prose mentions such as ``Use \\`\\`\\` to start a - fence.`` are filtered out by requiring the trailing characters - after the fence run to look like a CommonMark info string: empty, - or starting with a non-space character (so prose with a leading - space disqualifies the run). + 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 @@ -245,10 +247,14 @@ def _has_unclosed_code_fence(text: str) -> bool: raw_trailing = line[m.end() :] trailing = raw_trailing.strip() ch = fence[0] - # Prose mention guard: a real fence line never has a space - # immediately after the delimiters followed by sentence text. - if raw_trailing and raw_trailing[0] == " " and trailing: - continue + 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) @@ -308,7 +314,8 @@ def _is_empty_markup_skeleton(matched: str) -> bool: _LOCAL_ACTION_VERBS = ( r"load|inspect|parse|" r"calculate|compute|analy[sz]e|extract|" - r"run|execute|fetch|download|query" + 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", @@ -320,7 +327,7 @@ _NUMBERED_ACTION_ITEM = re.compile( # 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", + r"i will|i shall|i need to|let me|allow me|now i|next i)\b", re.IGNORECASE, ) @@ -333,11 +340,12 @@ _STRONG_INTENT_BEFORE_LIST = re.compile( # 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"i will|i shall|i need to|let me|allow me|now i|next i)\s*:[ \t]*" r"(?:\r?\n)[ \t]*\d+\.[ \t]+" r"(?:open|read|search|look up|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)\b", + r"fetch|download|query|summari[sz]e|implement|generate|draft|write|" + r"visit|access|navigate|gather|collect|identify|update|edit)\b", re.IGNORECASE, ) diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 1b408da27f..746824fd6c 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -985,6 +985,66 @@ def test_no_reprompt_on_code_fence_containing_markup_literal(): assert not _would_reprompt(content), content +def test_reprompts_on_i_need_to_numbered_plan(): + """``First, I need to:`` / ``I need to ...`` numbered plans are + tool stalls and still re-prompt.""" + samples = [ + ( + "First, I need to:\n" + "1. Read the uploaded file.\n" + "2. Summarize it." + ), + ( + "I need to fetch the latest data:\n" + "1. Query the price.\n" + "2. Format the answer." + ), + ( + "I'll:\n" + "1. Gather the relevant files.\n" + "2. Identify the issue." + ), + ] + for content in samples: + assert not _has_answer_artifact(content), content + assert _would_reprompt(content), content + + +def test_reprompts_on_visit_or_access_numbered_plan(): + """First-person + browser/navigation verbs (visit/access/navigate) + + numbered list is a tool stall.""" + samples = [ + ( + "I'll visit the official site:\n" + "1. Open the homepage.\n" + "2. Read the release notes.\n" + "3. Summarize." + ), + ( + "Let me access the GitHub repo:\n" + "1. Open the README.\n" + "2. Identify the install instructions." + ), + ] + for content in samples: + assert not _has_answer_artifact(content), content + assert _would_reprompt(content), content + + +def test_no_reprompt_on_inline_backtick_python_prose_after_code(): + """``Use ```python to start a Python fence.`` is prose after a + completed answer; it must NOT be treated as an unclosed fence.""" + content = ( + "Here is the snippet:\n" + "```python\n" + "print(1)\n" + "```\n" + "Use ```python to start a Python block in your reply." + ) + assert _has_answer_artifact(content) + assert not _would_reprompt(content) + + def test_reprompts_on_take_or_follow_steps_numbered_plan(): """``I'll take these steps:`` / ``I will follow these steps:`` + numbered list of work items is a plan stall.""" From b6477eddb1991ada8959b9d7e4941b30c3450f94 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 22:14:28 +0000 Subject: [PATCH 38/41] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../backend/tests/test_llama_cpp_reprompt_guard.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 746824fd6c..96e2b5ad45 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -989,21 +989,13 @@ def test_reprompts_on_i_need_to_numbered_plan(): """``First, I need to:`` / ``I need to ...`` numbered plans are tool stalls and still re-prompt.""" samples = [ - ( - "First, I need to:\n" - "1. Read the uploaded file.\n" - "2. Summarize it." - ), + ("First, I need to:\n" "1. Read the uploaded file.\n" "2. Summarize it."), ( "I need to fetch the latest data:\n" "1. Query the price.\n" "2. Format the answer." ), - ( - "I'll:\n" - "1. Gather the relevant files.\n" - "2. Identify the issue." - ), + ("I'll:\n" "1. Gather the relevant files.\n" "2. Identify the issue."), ] for content in samples: assert not _has_answer_artifact(content), content From 6cf6797fb342b6d2d476ba8dcf77969859b5205a Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 22:24:52 +0000 Subject: [PATCH 39/41] 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". --- studio/backend/core/inference/llama_cpp.py | 9 ++-- .../tests/test_llama_cpp_reprompt_guard.py | 42 ++++++++++++------- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index d86b0d9b80..9552e7af4c 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -88,7 +88,7 @@ _INTENT_SIGNAL = re.compile( # Handles both straight and curly apostrophes. # Excludes "I can", "I should", "I want to", "let's" which # 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|i need to|let me|allow me)\b" + 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". r"\b(?:first\b|step \d+:?|here['\u2019]?s (?:my |the |a )?(?:plan|approach))" @@ -194,7 +194,7 @@ _EXPLICIT_PLAN_HEADER = re.compile( _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|i need to|let me|allow me|now i|next i)\b" + 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|" @@ -202,6 +202,7 @@ _DIRECT_NUMBERED_PLAN_FRAMING = re.compile( r"do (?:this|these|the following|it)|" r"take (?:these|the following) steps|" r"follow (?:these|the following) steps|" + r"complete (?:these|the following) steps|" r"perform (?:these|the following) actions|" r"proceed|start|begin|" r"create|build|implement|set up|add|calculate|compute|analy[sz]e|" @@ -327,7 +328,7 @@ _NUMBERED_ACTION_ITEM = re.compile( # 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|i need to|let me|allow me|now i|next i)\b", + r"i will|i shall|let me|allow me|now i|next i)\b", re.IGNORECASE, ) @@ -340,7 +341,7 @@ _STRONG_INTENT_BEFORE_LIST = re.compile( # 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|i need to|let me|allow me|now i|next i)\s*:[ \t]*" + 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|look up|check|verify|create|build|add|set up|" r"load|inspect|parse|calculate|compute|analy[sz]e|extract|run|execute|" diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 96e2b5ad45..73e88637e2 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -985,21 +985,28 @@ def test_no_reprompt_on_code_fence_containing_markup_literal(): assert not _would_reprompt(content), content -def test_reprompts_on_i_need_to_numbered_plan(): - """``First, I need to:`` / ``I need to ...`` numbered plans are - tool stalls and still re-prompt.""" +def test_reprompts_on_i_will_gather_identify_numbered_plan(): + """First-person + gather/identify verbs in list items is a tool + stall when the intent appears directly before the list.""" + content = ( + "I'll:\n" + "1. Gather the relevant files.\n" + "2. Identify the issue." + ) + assert not _has_answer_artifact(content), content + assert _would_reprompt(content), content + + +def test_no_reprompt_on_bare_i_need_to_clarification(): + """Bare ``I need to`` clarification or prose answers must NOT + trigger the re-prompt. The phrase is too common in plain answers.""" samples = [ - ("First, I need to:\n" "1. Read the uploaded file.\n" "2. Summarize it."), - ( - "I need to fetch the latest data:\n" - "1. Query the price.\n" - "2. Format the answer." - ), - ("I'll:\n" "1. Gather the relevant files.\n" "2. Identify the issue."), + "I need to know your operating system before giving the install command.", + "I need to be clear: the answer is Paris.", + 'The sentence is: "I need to leave early today."', ] for content in samples: - assert not _has_answer_artifact(content), content - assert _would_reprompt(content), content + assert not _would_reprompt(content), content def test_reprompts_on_visit_or_access_numbered_plan(): @@ -1037,9 +1044,9 @@ def test_no_reprompt_on_inline_backtick_python_prose_after_code(): assert not _would_reprompt(content) -def test_reprompts_on_take_or_follow_steps_numbered_plan(): - """``I'll take these steps:`` / ``I will follow these steps:`` + - numbered list of work items is a plan stall.""" +def test_reprompts_on_take_follow_complete_steps_numbered_plan(): + """``I'll take/follow/complete these steps:`` + numbered list of + work items is a plan stall.""" samples = [ ( "I'll take these steps:\n" @@ -1053,6 +1060,11 @@ def test_reprompts_on_take_or_follow_steps_numbered_plan(): "2. Read the relevant section.\n" "3. Answer." ), + ( + "Let me complete these steps:\n" + "1. Read the uploaded CSV.\n" + "2. Check the totals." + ), ] for content in samples: assert not _has_answer_artifact(content), content From d77b38d51d353c513e30a4d07c46cd8fee468ce5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 22:25:43 +0000 Subject: [PATCH 40/41] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/tests/test_llama_cpp_reprompt_guard.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/studio/backend/tests/test_llama_cpp_reprompt_guard.py b/studio/backend/tests/test_llama_cpp_reprompt_guard.py index 73e88637e2..c709ca0212 100644 --- a/studio/backend/tests/test_llama_cpp_reprompt_guard.py +++ b/studio/backend/tests/test_llama_cpp_reprompt_guard.py @@ -988,11 +988,7 @@ def test_no_reprompt_on_code_fence_containing_markup_literal(): def test_reprompts_on_i_will_gather_identify_numbered_plan(): """First-person + gather/identify verbs in list items is a tool stall when the intent appears directly before the list.""" - content = ( - "I'll:\n" - "1. Gather the relevant files.\n" - "2. Identify the issue." - ) + content = "I'll:\n" "1. Gather the relevant files.\n" "2. Identify the issue." assert not _has_answer_artifact(content), content assert _would_reprompt(content), content From 7f317b1e3a490e800b5f40d19a40462323e36f15 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 22:35:29 +0000 Subject: [PATCH 41/41] 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. --- studio/backend/core/inference/llama_cpp.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 9552e7af4c..16c21d2239 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -200,10 +200,7 @@ _DIRECT_NUMBERED_PLAN_FRAMING = re.compile( r"google|find|check|verify|compare|review|inspect|examine|" r"visit|access|navigate|gather|collect|" r"do (?:this|these|the following|it)|" - r"take (?:these|the following) steps|" - r"follow (?:these|the following) steps|" - r"complete (?:these|the following) steps|" - r"perform (?:these|the following) actions|" + 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" @@ -343,7 +340,9 @@ _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|look up|check|verify|create|build|add|set up|" + 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",