Compare commits
11 commits
main
...
studio/aut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5afd6c1841 | ||
|
|
a1d79b761e | ||
|
|
bb33346642 | ||
|
|
0cc69de210 | ||
|
|
7dbe18ab51 | ||
|
|
66d59e01d0 | ||
|
|
fd53dab931 | ||
|
|
25acc2062e | ||
|
|
8503fb33ae | ||
|
|
af31c54d02 | ||
|
|
3a118e5df0 |
5 changed files with 360 additions and 31 deletions
|
|
@ -253,9 +253,28 @@ class AnthropicStreamEmitter:
|
||||||
elif etype == "metadata":
|
elif etype == "metadata":
|
||||||
self._usage = event.get("usage", {})
|
self._usage = event.get("usage", {})
|
||||||
return []
|
return []
|
||||||
# status events — no Anthropic equivalent
|
elif etype == "status" and event.get("boundary"):
|
||||||
|
# Iteration-boundary marker (auto-continue reprompt). Close
|
||||||
|
# the open text block + reset _prev_text so the next content
|
||||||
|
# event diffs against zero. Non-boundary status events (UI
|
||||||
|
# badge clears) don't reach this branch.
|
||||||
|
return self._handle_boundary()
|
||||||
|
# Other status events have no Anthropic equivalent.
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def _handle_boundary(self) -> list[str]:
|
||||||
|
# Close the current text block + reset the cumulative cursor.
|
||||||
|
# Do NOT pre-open a new text block here -- if the next event is
|
||||||
|
# a tool_start (not text), the eager-open would emit a zero-length
|
||||||
|
# text content block between intent text and the tool_use.
|
||||||
|
# _handle_content lazy-opens when real text arrives.
|
||||||
|
events = []
|
||||||
|
if self._text_block_open:
|
||||||
|
events.append(self._close_block())
|
||||||
|
self.block_index += 1
|
||||||
|
self._prev_text = ""
|
||||||
|
return events
|
||||||
|
|
||||||
def finish(self, stop_reason: str = "end_turn") -> list[str]:
|
def finish(self, stop_reason: str = "end_turn") -> list[str]:
|
||||||
"""Close any open block and emit message_delta + message_stop."""
|
"""Close any open block and emit message_delta + message_stop."""
|
||||||
events = []
|
events = []
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ through its OpenAI-compatible /v1/chat/completions endpoint.
|
||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import itertools as _itertools
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
@ -56,6 +57,51 @@ _INTENT_SIGNAL = re.compile(
|
||||||
)
|
)
|
||||||
_MAX_REPROMPTS = 3
|
_MAX_REPROMPTS = 3
|
||||||
|
|
||||||
|
# Mid-plan EOS detectors. Three shapes: trailing intent, list under a
|
||||||
|
# "Let me ...:" header, bare trailing colon. "let me know" is a closer,
|
||||||
|
# not a plan signal -- excluded via negative lookahead.
|
||||||
|
_TRAILING_PLAN_INTENT = re.compile(
|
||||||
|
r"(?i)("
|
||||||
|
r"(?:now\s+)?let me(?!\s+know\b)|i['’]ll now|next,?\s*i['’]ll|"
|
||||||
|
r"i['’]m going to|i will now|let['’]s now"
|
||||||
|
r")[^.!?\n]*[.!?]?\s*$"
|
||||||
|
)
|
||||||
|
_TRAILING_PLAN_LIST = re.compile(
|
||||||
|
# `\Z` (not `$`) so the regex only fires when the list is the last
|
||||||
|
# thing in the buffer, not when a closing paragraph follows.
|
||||||
|
# `[ \t]*` before each marker keeps it line-anchored so a mid-prose
|
||||||
|
# "42." cannot pose as a phantom list item.
|
||||||
|
r"(?i)"
|
||||||
|
r"(?:let me|i['’]ll|i will|i['’]m going to|i am going to|"
|
||||||
|
r"here['’]?s (?:my |the |a )?(?:plan|approach|steps?)|"
|
||||||
|
r"as follows|the (?:plan|steps?) (?:is|are))"
|
||||||
|
r"[^:\n]{0,160}:\s*\n"
|
||||||
|
r"(?:[ \t]*(?:[-*•]|\d+\.)[ \t]+[^\n]+(?:\n|\Z))+"
|
||||||
|
r"\s*\Z"
|
||||||
|
)
|
||||||
|
_TRAILING_PLAN_COLON = re.compile(
|
||||||
|
r"(?i)(?:let me|i['’]ll|i will|i['’]m going to|i am going to|"
|
||||||
|
r"now i['’]ll|now i will)"
|
||||||
|
r"[^\n:]{0,200}:\s*$"
|
||||||
|
)
|
||||||
|
_TRAILING_PLAN_WINDOW = 600
|
||||||
|
_MAX_CONTINUES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _trailing_plan_hit(stripped: str) -> bool:
|
||||||
|
"""True if the last `_TRAILING_PLAN_WINDOW` chars look mid-plan."""
|
||||||
|
if not stripped:
|
||||||
|
return False
|
||||||
|
tail = stripped[-_TRAILING_PLAN_WINDOW:]
|
||||||
|
if _TRAILING_PLAN_INTENT.search(tail) is not None:
|
||||||
|
return True
|
||||||
|
if _TRAILING_PLAN_LIST.search(tail) is not None:
|
||||||
|
return True
|
||||||
|
if _TRAILING_PLAN_COLON.search(tail) is not None:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# Without max_tokens, llama-server defaults to n_predict = n_ctx (up to
|
# Without max_tokens, llama-server defaults to n_predict = n_ctx (up to
|
||||||
# 262144 for Qwen3.5), producing many-minute zombie decodes when cancel
|
# 262144 for Qwen3.5), producing many-minute zombie decodes when cancel
|
||||||
# fails. t_max_predict_ms is a wall-clock backstop applied unconditionally,
|
# fails. t_max_predict_ms is a wall-clock backstop applied unconditionally,
|
||||||
|
|
@ -4015,12 +4061,16 @@ class LlamaCppBackend:
|
||||||
# direct answer like "4" or "Hello!" will not match.
|
# direct answer like "4" or "Hello!" will not match.
|
||||||
# Pattern is compiled once at module level (_INTENT_SIGNAL).
|
# Pattern is compiled once at module level (_INTENT_SIGNAL).
|
||||||
_reprompt_count = 0
|
_reprompt_count = 0
|
||||||
|
# Separate counter so auto-continue doesn't steal reprompt budget.
|
||||||
|
_continue_count = 0
|
||||||
|
|
||||||
# Reserve extra iterations for re-prompts so they don't
|
# Dynamic cap: caller's max_tool_iterations is honored exactly
|
||||||
# consume the caller's tool-call budget. Only add the
|
# until a reprompt/continue actually fires; each consumed event
|
||||||
# extra slot when tool iterations are actually allowed.
|
# earns its own slot back. itertools.count preserves loop-body
|
||||||
_extra = _MAX_REPROMPTS if max_tool_iterations > 0 else 0
|
# `continue` semantics.
|
||||||
for iteration in range(max_tool_iterations + _extra):
|
for iteration in _itertools.count():
|
||||||
|
if iteration >= (max_tool_iterations + _reprompt_count + _continue_count):
|
||||||
|
break
|
||||||
if cancel_event is not None and cancel_event.is_set():
|
if cancel_event is not None and cancel_event.is_set():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -4353,18 +4403,41 @@ class LlamaCppBackend:
|
||||||
_stripped = content_accum.strip()
|
_stripped = content_accum.strip()
|
||||||
if not _stripped:
|
if not _stripped:
|
||||||
_stripped = reasoning_accum.strip()
|
_stripped = reasoning_accum.strip()
|
||||||
if (
|
|
||||||
|
# Tool-coercive reprompt: intent text without a tool call.
|
||||||
|
_tool_intent_hit = (
|
||||||
tools
|
tools
|
||||||
and _reprompt_count < _MAX_REPROMPTS
|
and _reprompt_count < _MAX_REPROMPTS
|
||||||
and 0 < len(_stripped) < _REPROMPT_MAX_CHARS
|
and 0 < len(_stripped) < _REPROMPT_MAX_CHARS
|
||||||
and _INTENT_SIGNAL.search(_stripped)
|
and _INTENT_SIGNAL.search(_stripped) is not None
|
||||||
):
|
)
|
||||||
_reprompt_count += 1
|
# Neutral auto-continue on mid-plan EOS. Works without tools.
|
||||||
logger.info(
|
_trailing_hit = (
|
||||||
f"Re-prompt {_reprompt_count}/{_MAX_REPROMPTS}: "
|
_continue_count < _MAX_CONTINUES
|
||||||
f"model responded without calling tools "
|
and _trailing_plan_hit(_stripped)
|
||||||
f"({len(_stripped)} chars)"
|
)
|
||||||
)
|
|
||||||
|
if _tool_intent_hit or _trailing_hit:
|
||||||
|
if _tool_intent_hit:
|
||||||
|
_reprompt_count += 1
|
||||||
|
logger.info(
|
||||||
|
f"Re-prompt {_reprompt_count}/{_MAX_REPROMPTS}: "
|
||||||
|
f"model responded without calling tools "
|
||||||
|
f"({len(_stripped)} chars)"
|
||||||
|
)
|
||||||
|
_nudge = (
|
||||||
|
"STOP. Do NOT write code or explain. "
|
||||||
|
"You MUST call a tool NOW. "
|
||||||
|
"Call web_search or python immediately."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_continue_count += 1
|
||||||
|
logger.info(
|
||||||
|
f"Auto-continue {_continue_count}/{_MAX_CONTINUES}: "
|
||||||
|
f"model ended turn mid-plan "
|
||||||
|
f"({len(_stripped)} chars)"
|
||||||
|
)
|
||||||
|
_nudge = "Continue."
|
||||||
conversation.append(
|
conversation.append(
|
||||||
{
|
{
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
|
|
@ -4374,11 +4447,7 @@ class LlamaCppBackend:
|
||||||
conversation.append(
|
conversation.append(
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": (
|
"content": _nudge,
|
||||||
"STOP. Do NOT write code or explain. "
|
|
||||||
"You MUST call a tool NOW. "
|
|
||||||
"Call web_search or python immediately."
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
# Accumulate tokens and timing from this iteration
|
# Accumulate tokens and timing from this iteration
|
||||||
|
|
@ -4389,7 +4458,9 @@ class LlamaCppBackend:
|
||||||
_it_r = _iter_timings or {}
|
_it_r = _iter_timings or {}
|
||||||
_accumulated_predicted_ms += _it_r.get("predicted_ms", 0)
|
_accumulated_predicted_ms += _it_r.get("predicted_ms", 0)
|
||||||
_accumulated_predicted_n += _it_r.get("predicted_n", 0)
|
_accumulated_predicted_n += _it_r.get("predicted_n", 0)
|
||||||
yield {"type": "status", "text": ""}
|
# boundary=True: next iter starts a fresh
|
||||||
|
# turn, so adapters must reset their cursor.
|
||||||
|
yield {"type": "status", "text": "", "boundary": True}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Content was already streamed. Yield metadata.
|
# Content was already streamed. Yield metadata.
|
||||||
|
|
@ -4657,9 +4728,10 @@ class LlamaCppBackend:
|
||||||
tool_msg["tool_call_id"] = tool_call_id
|
tool_msg["tool_call_id"] = tool_call_id
|
||||||
conversation.append(tool_msg)
|
conversation.append(tool_msg)
|
||||||
|
|
||||||
# Clear tool status badge before next generation iteration
|
# UI badge clear. NOT a boundary: tool_end already
|
||||||
|
# reset adapter cursors (would emit a spurious empty
|
||||||
|
# block if we flagged it).
|
||||||
yield {"type": "status", "text": ""}
|
yield {"type": "status", "text": ""}
|
||||||
# Continue the loop to let model respond with context
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
except httpx.ConnectError:
|
except httpx.ConnectError:
|
||||||
|
|
|
||||||
|
|
@ -2459,11 +2459,10 @@ async def openai_chat_completions(
|
||||||
break
|
break
|
||||||
|
|
||||||
if event["type"] == "status":
|
if event["type"] == "status":
|
||||||
# Empty status marks an iteration boundary
|
# boundary=True: auto-continue reprompt.
|
||||||
# in the GGUF tool loop (e.g. after a
|
# Reset cursor only then; plain empty-status
|
||||||
# re-prompt). Reset the cumulative cursor
|
# events (badge clears) keep the cursor.
|
||||||
# so the next assistant turn streams cleanly.
|
if event.get("boundary"):
|
||||||
if not event["text"]:
|
|
||||||
prev_text = ""
|
prev_text = ""
|
||||||
# Emit tool status as a custom SSE event
|
# Emit tool status as a custom SSE event
|
||||||
# (including empty ones to clear UI badges)
|
# (including empty ones to clear UI badges)
|
||||||
|
|
@ -2477,8 +2476,10 @@ async def openai_chat_completions(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if event["type"] in ("tool_start", "tool_end"):
|
if event["type"] in ("tool_start", "tool_end"):
|
||||||
if event["type"] == "tool_start":
|
# Both edges of a tool call restart cumulative
|
||||||
prev_text = ""
|
# text: tool_start opens a new stream, tool_end
|
||||||
|
# is the cursor reset for the post-tool turn.
|
||||||
|
prev_text = ""
|
||||||
yield f"data: {json.dumps(event)}\n\n"
|
yield f"data: {json.dumps(event)}\n\n"
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -4370,6 +4371,9 @@ async def _anthropic_tool_non_streaming(run_gen, message_id, model_name):
|
||||||
)
|
)
|
||||||
elif etype == "tool_end":
|
elif etype == "tool_end":
|
||||||
prev_text = ""
|
prev_text = ""
|
||||||
|
elif etype == "status" and event.get("boundary"):
|
||||||
|
# Iteration-boundary marker: reset like tool_end does.
|
||||||
|
prev_text = ""
|
||||||
elif etype == "metadata":
|
elif etype == "metadata":
|
||||||
usage = event.get("usage", {})
|
usage = event.get("usage", {})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -629,6 +629,90 @@ class TestAnthropicStreamEmitter:
|
||||||
parsed = json.loads(events[0].split("data: ")[1])
|
parsed = json.loads(events[0].split("data: ")[1])
|
||||||
assert parsed["delta"]["text"] == "After tool"
|
assert parsed["delta"]["text"] == "After tool"
|
||||||
|
|
||||||
|
def test_boundary_flag_closes_block_and_resets_cursor(self):
|
||||||
|
"""An iteration-boundary status (boundary=True) must close the
|
||||||
|
open text block, open a fresh one, and reset _prev_text so the
|
||||||
|
next content delta starts from zero."""
|
||||||
|
|
||||||
|
e = AnthropicStreamEmitter()
|
||||||
|
e.start("msg_1", "m")
|
||||||
|
e.feed({"type": "content", "text": "first turn"})
|
||||||
|
boundary = e.feed({"type": "status", "text": "", "boundary": True})
|
||||||
|
# Boundary must produce content_block_stop + content_block_start
|
||||||
|
# so the next text lives in a new block.
|
||||||
|
joined = "\n".join(boundary)
|
||||||
|
assert "content_block_stop" in joined
|
||||||
|
assert "content_block_start" in joined
|
||||||
|
# Next content delta must include the full "second turn", not a
|
||||||
|
# diff against the previous turn's length.
|
||||||
|
nxt = e.feed({"type": "content", "text": "second turn"})
|
||||||
|
parsed = json.loads(nxt[0].split("data: ")[1])
|
||||||
|
assert parsed["delta"]["text"] == "second turn"
|
||||||
|
|
||||||
|
def test_post_tool_empty_status_does_not_double_close(self):
|
||||||
|
"""After tool_end already opens a fresh text block, the post-tool
|
||||||
|
empty-status event emitted by llama_cpp.py (line 4766) must NOT
|
||||||
|
close that fresh block. Otherwise every tool call produces a
|
||||||
|
spurious empty content_block_stop + content_block_start pair
|
||||||
|
before the model's post-tool text arrives. Regression test for
|
||||||
|
PR 5549 codex 02:42Z."""
|
||||||
|
|
||||||
|
e = AnthropicStreamEmitter()
|
||||||
|
e.start("msg_1", "m")
|
||||||
|
e.feed({"type": "content", "text": "pre"})
|
||||||
|
e.feed(
|
||||||
|
{
|
||||||
|
"type": "tool_start",
|
||||||
|
"tool_name": "t",
|
||||||
|
"tool_call_id": "tc_1",
|
||||||
|
"arguments": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
e.feed(
|
||||||
|
{
|
||||||
|
"type": "tool_end",
|
||||||
|
"tool_name": "t",
|
||||||
|
"tool_call_id": "tc_1",
|
||||||
|
"result": "ok",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
block_after_tool = e.block_index
|
||||||
|
# Post-tool empty status (no boundary flag): should produce zero
|
||||||
|
# SSE events and leave block_index unchanged. The previous
|
||||||
|
# behaviour was to close+reopen, which produced a duplicate
|
||||||
|
# empty content block.
|
||||||
|
out = e.feed({"type": "status", "text": ""})
|
||||||
|
assert out == []
|
||||||
|
assert e.block_index == block_after_tool
|
||||||
|
# Next content delta lands in the same fresh text block that
|
||||||
|
# tool_end opened.
|
||||||
|
nxt = e.feed({"type": "content", "text": "post"})
|
||||||
|
parsed = json.loads(nxt[0].split("data: ")[1])
|
||||||
|
assert parsed["delta"]["text"] == "post"
|
||||||
|
|
||||||
|
def test_empty_status_without_boundary_does_not_close_block(self):
|
||||||
|
"""A non-boundary empty-status event (UI badge clear at normal
|
||||||
|
stream end, draining fallbacks, final status yields in
|
||||||
|
llama_cpp.py at lines 4501, 4584, 4794) must NOT close the
|
||||||
|
current text block or reset _prev_text - otherwise every normal
|
||||||
|
Anthropic response gets extra content_block_start/stop pairs
|
||||||
|
around its final text. Regression test for PR 5549 codex P2."""
|
||||||
|
|
||||||
|
e = AnthropicStreamEmitter()
|
||||||
|
e.start("msg_1", "m")
|
||||||
|
block_before = e.block_index
|
||||||
|
e.feed({"type": "content", "text": "hello "})
|
||||||
|
# Plain empty status (no boundary flag) -> no extra SSE events.
|
||||||
|
out = e.feed({"type": "status", "text": ""})
|
||||||
|
assert out == []
|
||||||
|
# block_index must not have advanced (no close+reopen happened).
|
||||||
|
assert e.block_index == block_before
|
||||||
|
# Next content delta is diffed against "hello ", so we only emit
|
||||||
|
# " world" (the new suffix).
|
||||||
|
nxt = e.feed({"type": "content", "text": "hello world"})
|
||||||
|
parsed = json.loads(nxt[0].split("data: ")[1])
|
||||||
|
assert parsed["delta"]["text"] == "world"
|
||||||
|
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
# Pass-through emitter tests (client-side tool execution path)
|
# Pass-through emitter tests (client-side tool execution path)
|
||||||
|
|
|
||||||
150
studio/backend/tests/test_trailing_plan.py
Normal file
150
studio/backend/tests/test_trailing_plan.py
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
# 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 mid-plan auto-continue regexes and ``_trailing_plan_hit``.
|
||||||
|
|
||||||
|
The trailing-plan detector lives in ``core.inference.llama_cpp`` and decides
|
||||||
|
whether the model just stopped mid-plan (and therefore deserves a neutral
|
||||||
|
``Continue.`` re-prompt). False positives cost real tool calls and latency,
|
||||||
|
so the patterns get explicit coverage here.
|
||||||
|
|
||||||
|
Bug history pinned by these tests:
|
||||||
|
|
||||||
|
* ``"If you need anything else, let me know."`` matched ``_TRAILING_PLAN_INTENT``
|
||||||
|
before the negative lookahead landed.
|
||||||
|
* ``"Here's my plan:\\n- a\\n- b\\n\\nDone, that should work."`` matched
|
||||||
|
``_TRAILING_PLAN_LIST`` before the regex was switched off the ``m`` flag
|
||||||
|
and re-anchored with ``\\Z``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.inference.llama_cpp import (
|
||||||
|
_TRAILING_PLAN_COLON,
|
||||||
|
_TRAILING_PLAN_INTENT,
|
||||||
|
_TRAILING_PLAN_LIST,
|
||||||
|
_trailing_plan_hit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----- _TRAILING_PLAN_INTENT -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text,expected",
|
||||||
|
[
|
||||||
|
# Closing phrases must NOT match (regression: "let me know")
|
||||||
|
("If you need anything else, let me know.", False),
|
||||||
|
("Let me know if I can help further.", False),
|
||||||
|
("let me know!", False),
|
||||||
|
# Genuine mid-plan intent SHOULD match
|
||||||
|
("Let me clone the repo.", True),
|
||||||
|
("Let me check the file.", True),
|
||||||
|
("Now let me run the tests.", True),
|
||||||
|
("I'll now run the analyzer.", True),
|
||||||
|
("I’ll now run the analyzer.", True), # curly apostrophe
|
||||||
|
("I will now begin.", True),
|
||||||
|
# Unrelated trailing text must NOT match
|
||||||
|
("Hello world.", False),
|
||||||
|
("The answer is 42.", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_trailing_plan_intent(text: str, expected: bool) -> None:
|
||||||
|
assert bool(_TRAILING_PLAN_INTENT.search(text)) is expected
|
||||||
|
|
||||||
|
|
||||||
|
# ----- _TRAILING_PLAN_LIST ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text,expected",
|
||||||
|
[
|
||||||
|
# List block at end of buffer SHOULD match
|
||||||
|
("Let me do this:\n- step one\n- step two\n", True),
|
||||||
|
("Here's my plan:\n1. one\n2. two\n", True),
|
||||||
|
("Here's my plan:\n1. one\n2. two\n \n", True), # trailing whitespace
|
||||||
|
# Unicode bullet at end of buffer SHOULD match
|
||||||
|
("Let me try:\n• first\n• second\n", True),
|
||||||
|
# List followed by a closing sentence MUST NOT match (regression:
|
||||||
|
# the `m` flag in `(?ims)` previously let `\s*$` match end-of-line)
|
||||||
|
(
|
||||||
|
"Here's my plan:\n- step one\n- step two\n\nDone, hope that helps.",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Let me walk through it:\n1. first\n2. second\n\nThat's everything.",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
# Single-item numbered list followed by closing prose MUST NOT match
|
||||||
|
(
|
||||||
|
"Let me explain:\n1. The function returns 42.\n\nThat's the answer.",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
# List embedded mid-text (not trailing) MUST NOT match
|
||||||
|
(
|
||||||
|
"Here's my plan:\n- step one\n- step two\nNow the conclusion follows.",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_trailing_plan_list(text: str, expected: bool) -> None:
|
||||||
|
assert bool(_TRAILING_PLAN_LIST.search(text)) is expected
|
||||||
|
|
||||||
|
|
||||||
|
# ----- _TRAILING_PLAN_COLON --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text,expected",
|
||||||
|
[
|
||||||
|
# Bare trailing colon SHOULD match
|
||||||
|
("Let me check the repo:", True),
|
||||||
|
("I'll now look at this:", True),
|
||||||
|
# Colon mid-sentence MUST NOT match
|
||||||
|
("Let me check this: it should work fine.", False),
|
||||||
|
# Colon not in an intent-cue clause MUST NOT match
|
||||||
|
("The result is:", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_trailing_plan_colon(text: str, expected: bool) -> None:
|
||||||
|
assert bool(_TRAILING_PLAN_COLON.search(text)) is expected
|
||||||
|
|
||||||
|
|
||||||
|
# ----- _trailing_plan_hit composite -----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text,expected",
|
||||||
|
[
|
||||||
|
# Any of the three sub-patterns triggers a hit
|
||||||
|
("Now let me run the tests.", True),
|
||||||
|
("Let me do this:\n- step one\n- step two\n", True),
|
||||||
|
("Let me check the repo:", True),
|
||||||
|
# Negative cases that previously misfired
|
||||||
|
("If you need anything else, let me know.", False),
|
||||||
|
(
|
||||||
|
"Here's my plan:\n- step one\n- step two\n\nDone, hope that helps.",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
# Short empty string is a no-op
|
||||||
|
("", False),
|
||||||
|
(" ", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_trailing_plan_hit(text: str, expected: bool) -> None:
|
||||||
|
assert _trailing_plan_hit(text) is expected
|
||||||
|
|
||||||
|
|
||||||
|
# ----- window slicing --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_trailing_plan_hit_respects_window() -> None:
|
||||||
|
"""An intent cue further back than ``_TRAILING_PLAN_WINDOW`` must NOT
|
||||||
|
trigger a hit; only the tail of the response is inspected."""
|
||||||
|
|
||||||
|
# 800-char prefix of unrelated text, then a finalising sentence.
|
||||||
|
prefix = "lorem ipsum " * 80 # ~960 chars
|
||||||
|
text = f"Let me check the repo. {prefix}The result is 42."
|
||||||
|
assert _trailing_plan_hit(text) is False
|
||||||
Loading…
Add table
Add a link
Reference in a new issue