Skip to content

Commit 7912321

Browse files
BunpGhostdoramirdorclaude
authored
feat: add NADIRCLAW_CLASSIFIER_STRIP_PATTERNS env var for classifier input cleaning (#77)
* feat: add NADIRCLAW_CLASSIFIER_STRIP_PATTERNS env var for classifier input cleaning Agent frameworks (OpenClaw, Claude Code, NanoBot, Hermes, etc.) often wrap the human's actual prompt in a structured envelope - metadata blocks, memory context, system notes - that does not reflect the complexity of the request. The classifier sees JSON schemas, memory dumps, and tool definitions, inflating the score even for trivial requests like "hello". Changes: - settings.py: new CLASSIFIER_STRIP_PATTERNS property (env var, default empty = off) - server.py: _strip_classifier_input() function + call sites in _smart_route_full, /v1/classify, and /v1/classify/batch - Invalid regex logs a warning and is silently ignored (no crash) - Zero overhead when unset: regex is None, function is a no-op * Guard over-broad strip patterns + add tests (review #77) Addresses the one pre-merge blocker from review: an over-broad NADIRCLAW_CLASSIFIER_STRIP_PATTERNS could consume the entire prompt, leaving the classifier an empty string and silently routing everything to the cheapest tier. _strip_classifier_input now returns the original text when stripping empties it. Adds tests/test_classifier_strip.py covering the contract: unset => identity, pattern strips envelope (incl. DOTALL across newlines), invalid regex => no-op + warning, and the over-broad => fall-back-to-original guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: BunpGhost <bunpghost@users.noreply.github.com> Co-authored-by: Nadir <amirdor@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a7f46e8 commit 7912321

3 files changed

Lines changed: 147 additions & 2 deletions

File tree

nadirclaw/server.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,48 @@
2424
from pydantic import BaseModel, model_validator
2525
from sse_starlette.sse import EventSourceResponse
2626

27+
28+
# ---------------------------------------------------------------------------
29+
# Classifier input cleaner
30+
# ---------------------------------------------------------------------------
31+
# Strips configured regex patterns from user prompts *before* classification
32+
# so agent metadata envelopes (memory context, system notes, etc.) do not
33+
# inflate the complexity score. The LLM still sees the full text --- this
34+
# only affects the classifier.
35+
# Set NADIRCLAW_CLASSIFIER_STRIP_PATTERNS in the environment.
36+
# ---------------------------------------------------------------------------
37+
_strip_regex: Optional[re.Pattern] = None
38+
39+
def _compile_strip_regex() -> Optional[re.Pattern]:
40+
"""Compile the classifier strip pattern from settings, or None if empty."""
41+
from nadirclaw.settings import settings as _s
42+
raw = _s.CLASSIFIER_STRIP_PATTERNS
43+
if not raw:
44+
return None
45+
try:
46+
return re.compile(raw, re.DOTALL)
47+
except re.error:
48+
logger = logging.getLogger(__name__)
49+
logger.warning(
50+
"Invalid NADIRCLAW_CLASSIFIER_STRIP_PATTERNS=%r - ignoring. "
51+
"Check your regex syntax.",
52+
raw,
53+
)
54+
return None
55+
56+
def _strip_classifier_input(text: str) -> str:
57+
"""Strip configured patterns from classifier input text."""
58+
global _strip_regex
59+
if _strip_regex is None:
60+
_strip_regex = _compile_strip_regex()
61+
if not _strip_regex or not text:
62+
return text
63+
stripped = _strip_regex.sub('', text).strip()
64+
# Guard against an over-broad pattern consuming the whole prompt: an empty
65+
# classifier input would silently route everything to the cheapest tier.
66+
return stripped or text
67+
# ---------------------------------------------------------------------------
68+
2769
import os
2870

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

@@ -547,8 +591,9 @@ async def classify_prompt(
547591
current_user: UserSession = Depends(validate_local_auth),
548592
) -> Dict[str, Any]:
549593
"""Classify a prompt without calling any LLM."""
594+
clean_prompt = _strip_classifier_input(request.prompt)
550595
_, analysis = await _smart_route_analysis(
551-
request.prompt, request.system_message or "", current_user
596+
clean_prompt, request.system_message or "", current_user
552597
)
553598

554599
_log_request({
@@ -571,7 +616,8 @@ async def classify_batch(
571616
"""Classify multiple prompts at once."""
572617
results = []
573618
for prompt in request.prompts:
574-
_, analysis = await _smart_route_analysis(prompt, "", current_user)
619+
clean_prompt = _strip_classifier_input(prompt)
620+
_, analysis = await _smart_route_analysis(clean_prompt, "", current_user)
575621
results.append({
576622
"prompt": prompt,
577623
"selected_model": analysis.get("selected_model"),

nadirclaw/settings.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,5 +553,25 @@ def CENTROID_DIR(self) -> "Path | None":
553553
return Path(val).expanduser()
554554
return None
555555

556+
@property
557+
def CLASSIFIER_STRIP_PATTERNS(self) -> str:
558+
"""Regex patterns to strip from user prompts *before* classification.
559+
560+
Agent frameworks often wrap the human's actual prompt in a structured
561+
envelope (metadata, memory context, system notes, etc.) that does not
562+
reflect the complexity of the request. Stripping these blocks lets
563+
the classifier see the user's actual intent.
564+
565+
Set this to a regex using Python's ``re`` syntax; ``re.DOTALL`` is
566+
applied internally. Example for typical envelope patterns::
567+
568+
<envelope>.*?(?:</envelope>|\\Z)|\\[system note:.*?\\]
569+
570+
Default: empty string = no stripping.
571+
"""
572+
return os.getenv("NADIRCLAW_CLASSIFIER_STRIP_PATTERNS", "")
573+
574+
575+
556576

557577
settings = Settings()

tests/test_classifier_strip.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Tests for the classifier input cleaner (NADIRCLAW_CLASSIFIER_STRIP_PATTERNS).
2+
3+
Locks in the contract for ``_strip_classifier_input`` / ``_compile_strip_regex``
4+
in ``nadirclaw.server``:
5+
6+
(a) unset env var -> identity (no stripping)
7+
(b) a configured pattern -> the matched envelope is removed
8+
(c) an invalid regex -> no-op + warning, never a crash
9+
(d) an over-broad pattern -> falls back to the original prompt rather than
10+
emptying the classifier input
11+
12+
The module-level ``_strip_regex`` cache is reset before each case so the
13+
pattern is recompiled from the (monkeypatched) environment.
14+
"""
15+
16+
import pytest
17+
18+
import nadirclaw.server as server
19+
20+
21+
@pytest.fixture(autouse=True)
22+
def _reset_strip_cache(monkeypatch):
23+
"""Clear the compiled-regex cache and env var before/after each test."""
24+
monkeypatch.delenv("NADIRCLAW_CLASSIFIER_STRIP_PATTERNS", raising=False)
25+
server._strip_regex = None
26+
yield
27+
server._strip_regex = None
28+
29+
30+
def _set_pattern(monkeypatch, pattern: str):
31+
monkeypatch.setenv("NADIRCLAW_CLASSIFIER_STRIP_PATTERNS", pattern)
32+
server._strip_regex = None # force recompile from the new env value
33+
34+
35+
def test_unset_is_identity(monkeypatch):
36+
text = "<envelope>meta</envelope>What is the capital of France?"
37+
assert server._strip_classifier_input(text) == text
38+
39+
40+
def test_empty_text_is_returned_unchanged(monkeypatch):
41+
_set_pattern(monkeypatch, r"<envelope>.*?</envelope>")
42+
assert server._strip_classifier_input("") == ""
43+
44+
45+
def test_pattern_strips_envelope(monkeypatch):
46+
_set_pattern(monkeypatch, r"<envelope>.*?</envelope>")
47+
text = "<envelope>memory: 42 facts</envelope>Summarize this."
48+
assert server._strip_classifier_input(text) == "Summarize this."
49+
50+
51+
def test_pattern_strips_across_newlines_dotall(monkeypatch):
52+
# re.DOTALL is applied internally, so '.' spans newlines.
53+
_set_pattern(monkeypatch, r"\[system note:.*?\]")
54+
text = "[system note:\nremember the user prefers\nterse answers]Hi"
55+
assert server._strip_classifier_input(text) == "Hi"
56+
57+
58+
def test_invalid_regex_is_noop_and_warns(monkeypatch, caplog):
59+
_set_pattern(monkeypatch, r"<envelope>(unclosed") # invalid: unbalanced (
60+
text = "<envelope>(unclosed keep me intact"
61+
with caplog.at_level("WARNING"):
62+
out = server._strip_classifier_input(text)
63+
assert out == text # never crashes, returns input untouched
64+
assert any("NADIRCLAW_CLASSIFIER_STRIP_PATTERNS" in r.message for r in caplog.records)
65+
66+
67+
def test_overbroad_pattern_falls_back_to_original(monkeypatch):
68+
# A greedy pattern that consumes the whole prompt must not empty the
69+
# classifier input (which would route everything to the cheapest tier).
70+
_set_pattern(monkeypatch, r".*")
71+
text = "Implement a distributed consensus algorithm."
72+
assert server._strip_classifier_input(text) == text
73+
74+
75+
def test_partial_strip_leaving_content_is_kept(monkeypatch):
76+
# When stripping still leaves real content, that content is returned.
77+
_set_pattern(monkeypatch, r"<sys>.*?</sys>")
78+
text = "<sys>tooling</sys> real question <sys>more</sys>"
79+
assert server._strip_classifier_input(text) == "real question"

0 commit comments

Comments
 (0)