diff --git a/nadirclaw/server.py b/nadirclaw/server.py
index 3373f86..f8d447d 100644
--- a/nadirclaw/server.py
+++ b/nadirclaw/server.py
@@ -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__
@@ -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)
@@ -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({
@@ -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"),
diff --git a/nadirclaw/settings.py b/nadirclaw/settings.py
index 604b608..3c7b092 100644
--- a/nadirclaw/settings.py
+++ b/nadirclaw/settings.py
@@ -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::
+
+ .*?(?:|\\Z)|\\[system note:.*?\\]
+
+ Default: empty string = no stripping.
+ """
+ return os.getenv("NADIRCLAW_CLASSIFIER_STRIP_PATTERNS", "")
+
+
+
settings = Settings()
diff --git a/tests/test_classifier_strip.py b/tests/test_classifier_strip.py
new file mode 100644
index 0000000..a34ced7
--- /dev/null
+++ b/tests/test_classifier_strip.py
@@ -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 = "metaWhat is the capital of France?"
+ assert server._strip_classifier_input(text) == text
+
+
+def test_empty_text_is_returned_unchanged(monkeypatch):
+ _set_pattern(monkeypatch, r".*?")
+ assert server._strip_classifier_input("") == ""
+
+
+def test_pattern_strips_envelope(monkeypatch):
+ _set_pattern(monkeypatch, r".*?")
+ text = "memory: 42 factsSummarize 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"(unclosed") # invalid: unbalanced (
+ text = "(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".*?")
+ text = "tooling real question more"
+ assert server._strip_classifier_input(text) == "real question"