Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions nadirclaw/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,48 @@
from pydantic import BaseModel, model_validator
from sse_starlette.sse import EventSourceResponse


# ---------------------------------------------------------------------------
# Classifier input cleaner
# ---------------------------------------------------------------------------
# Strips configured regex patterns from user prompts *before* classification
# so agent metadata envelopes (memory context, system notes, etc.) do not
# inflate the complexity score. The LLM still sees the full text --- this
# only affects the classifier.
# Set NADIRCLAW_CLASSIFIER_STRIP_PATTERNS in the environment.
# ---------------------------------------------------------------------------
_strip_regex: Optional[re.Pattern] = None

def _compile_strip_regex() -> Optional[re.Pattern]:
"""Compile the classifier strip pattern from settings, or None if empty."""
from nadirclaw.settings import settings as _s
raw = _s.CLASSIFIER_STRIP_PATTERNS
if not raw:
return None
try:
return re.compile(raw, re.DOTALL)
except re.error:
logger = logging.getLogger(__name__)
logger.warning(
"Invalid NADIRCLAW_CLASSIFIER_STRIP_PATTERNS=%r - ignoring. "
"Check your regex syntax.",
raw,
)
return None

def _strip_classifier_input(text: str) -> str:
"""Strip configured patterns from classifier input text."""
global _strip_regex
if _strip_regex is None:
_strip_regex = _compile_strip_regex()
if not _strip_regex or not text:
return text
stripped = _strip_regex.sub('', text).strip()
# Guard against an over-broad pattern consuming the whole prompt: an empty
# classifier input would silently route everything to the cheapest tier.
return stripped or text
# ---------------------------------------------------------------------------

import os

from nadirclaw import __version__
Expand Down Expand Up @@ -533,6 +575,8 @@ async def _smart_route_full(
"""Smart route for full completions."""
user_msgs = [m.text_content() for m in messages if m.role == "user"]
prompt = user_msgs[-1] if user_msgs else ""
# Strip agent metadata so they do not inflate complexity score.
prompt = _strip_classifier_input(prompt)
system_msg = next((m.text_content() for m in messages if m.role in ("system", "developer")), "")
return await _smart_route_analysis(prompt, system_msg, user)

Expand All @@ -547,8 +591,9 @@ async def classify_prompt(
current_user: UserSession = Depends(validate_local_auth),
) -> Dict[str, Any]:
"""Classify a prompt without calling any LLM."""
clean_prompt = _strip_classifier_input(request.prompt)
_, analysis = await _smart_route_analysis(
request.prompt, request.system_message or "", current_user
clean_prompt, request.system_message or "", current_user
)

_log_request({
Expand All @@ -571,7 +616,8 @@ async def classify_batch(
"""Classify multiple prompts at once."""
results = []
for prompt in request.prompts:
_, analysis = await _smart_route_analysis(prompt, "", current_user)
clean_prompt = _strip_classifier_input(prompt)
_, analysis = await _smart_route_analysis(clean_prompt, "", current_user)
results.append({
"prompt": prompt,
"selected_model": analysis.get("selected_model"),
Expand Down
20 changes: 20 additions & 0 deletions nadirclaw/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -523,5 +523,25 @@ def CENTROID_DIR(self) -> "Path | None":
return Path(val).expanduser()
return None

@property
def CLASSIFIER_STRIP_PATTERNS(self) -> str:
"""Regex patterns to strip from user prompts *before* classification.

Agent frameworks often wrap the human's actual prompt in a structured
envelope (metadata, memory context, system notes, etc.) that does not
reflect the complexity of the request. Stripping these blocks lets
the classifier see the user's actual intent.

Set this to a regex using Python's ``re`` syntax; ``re.DOTALL`` is
applied internally. Example for typical envelope patterns::

<envelope>.*?(?:</envelope>|\\Z)|\\[system note:.*?\\]

Default: empty string = no stripping.
"""
return os.getenv("NADIRCLAW_CLASSIFIER_STRIP_PATTERNS", "")




settings = Settings()
79 changes: 79 additions & 0 deletions tests/test_classifier_strip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Tests for the classifier input cleaner (NADIRCLAW_CLASSIFIER_STRIP_PATTERNS).

Locks in the contract for ``_strip_classifier_input`` / ``_compile_strip_regex``
in ``nadirclaw.server``:

(a) unset env var -> identity (no stripping)
(b) a configured pattern -> the matched envelope is removed
(c) an invalid regex -> no-op + warning, never a crash
(d) an over-broad pattern -> falls back to the original prompt rather than
emptying the classifier input

The module-level ``_strip_regex`` cache is reset before each case so the
pattern is recompiled from the (monkeypatched) environment.
"""

import pytest

import nadirclaw.server as server


@pytest.fixture(autouse=True)
def _reset_strip_cache(monkeypatch):
"""Clear the compiled-regex cache and env var before/after each test."""
monkeypatch.delenv("NADIRCLAW_CLASSIFIER_STRIP_PATTERNS", raising=False)
server._strip_regex = None
yield
server._strip_regex = None


def _set_pattern(monkeypatch, pattern: str):
monkeypatch.setenv("NADIRCLAW_CLASSIFIER_STRIP_PATTERNS", pattern)
server._strip_regex = None # force recompile from the new env value


def test_unset_is_identity(monkeypatch):
text = "<envelope>meta</envelope>What is the capital of France?"
assert server._strip_classifier_input(text) == text


def test_empty_text_is_returned_unchanged(monkeypatch):
_set_pattern(monkeypatch, r"<envelope>.*?</envelope>")
assert server._strip_classifier_input("") == ""


def test_pattern_strips_envelope(monkeypatch):
_set_pattern(monkeypatch, r"<envelope>.*?</envelope>")
text = "<envelope>memory: 42 facts</envelope>Summarize this."
assert server._strip_classifier_input(text) == "Summarize this."


def test_pattern_strips_across_newlines_dotall(monkeypatch):
# re.DOTALL is applied internally, so '.' spans newlines.
_set_pattern(monkeypatch, r"\[system note:.*?\]")
text = "[system note:\nremember the user prefers\nterse answers]Hi"
assert server._strip_classifier_input(text) == "Hi"


def test_invalid_regex_is_noop_and_warns(monkeypatch, caplog):
_set_pattern(monkeypatch, r"<envelope>(unclosed") # invalid: unbalanced (
text = "<envelope>(unclosed keep me intact"
with caplog.at_level("WARNING"):
out = server._strip_classifier_input(text)
assert out == text # never crashes, returns input untouched
assert any("NADIRCLAW_CLASSIFIER_STRIP_PATTERNS" in r.message for r in caplog.records)


def test_overbroad_pattern_falls_back_to_original(monkeypatch):
# A greedy pattern that consumes the whole prompt must not empty the
# classifier input (which would route everything to the cheapest tier).
_set_pattern(monkeypatch, r".*")
text = "Implement a distributed consensus algorithm."
assert server._strip_classifier_input(text) == text


def test_partial_strip_leaving_content_is_kept(monkeypatch):
# When stripping still leaves real content, that content is returned.
_set_pattern(monkeypatch, r"<sys>.*?</sys>")
text = "<sys>tooling</sys> real question <sys>more</sys>"
assert server._strip_classifier_input(text) == "real question"
Loading