From 5d59d35c8d83cf2d0d473a26fcd365094b700dbf Mon Sep 17 00:00:00 2001 From: Mickael Farina Date: Fri, 24 Jul 2026 17:47:47 +0200 Subject: [PATCH] fix(chat): claim-check silently degraded when tool-calling was off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request-intent half of the claim check needs the user's message. But last_user_text was populated only inside the use_tools gate, so any request with tools disabled left it empty and the check fell back to reply-patterns alone — exactly the half that rephrasing can evade, and the half three rounds of pattern widening already proved insufficient. Found by verifying the previous fix live instead of trusting it: the module flagged the case correctly in isolation, but the running dashboard produced no correction. The logic was right; the wiring starved it. A safety check must not depend on an unrelated feature flag. last_user_text is now resolved unconditionally, before the gate. Co-Authored-By: Claude Opus 4.8 --- routes/chat.py | 13 +++++++++---- tests/test_claim_check.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/routes/chat.py b/routes/chat.py index bf72d11..7a6210a 100644 --- a/routes/chat.py +++ b/routes/chat.py @@ -934,16 +934,21 @@ async def chat_completion(request: Request): # below never hit an UnboundLocalError when a client sends {"tools": false} # (re-audit J1: was a silent opaque 500 — _build_chat_system_prompt is # called with both names regardless of the tools flag). + # Resolve the user's message UNCONDITIONALLY. It used to be populated only + # inside the use_tools gate, so with tools disabled it stayed empty — and the + # claim-check, which needs the request to spot an unbacked persistence ask, + # silently degraded to reply-pattern-only. A safety check must not depend on + # an unrelated feature flag. last_user_text = "" + for _m in reversed(messages): + if _m.get("role") == "user" and isinstance(_m.get("content"), str): + last_user_text = _m["content"] + break has_attachment = False # ── Tool Calling: check if last user message matches a skill ── use_tools = body.get("tools", True) # frontend can disable with tools:false if use_tools: - for m in reversed(messages): - if m.get("role") == "user" and isinstance(m.get("content"), str): - last_user_text = m["content"] - break # ── Slash commands (BEFORE skill check / attachment check) ── # Type /help, /skills, /cost, /version, /status, /who, /clear in chat # to invoke meta-controls without an LLM round-trip. Slash dispatch diff --git a/tests/test_claim_check.py b/tests/test_claim_check.py index 7713e98..7692381 100644 --- a/tests/test_claim_check.py +++ b/tests/test_claim_check.py @@ -264,3 +264,19 @@ def test_no_user_request_falls_back_to_reply_patterns(): """Callers that don't pass the request still get the reply-side checks.""" assert cc.find_unbacked_claims("I have ingested the instruction set.", actions_taken=set()) + + +def test_last_user_text_is_resolved_outside_the_tools_flag(): + """The claim check needs the user's message to spot an unbacked persistence + ask. It used to be populated only inside `if use_tools:`, so a request with + tools disabled left it "" and the check silently degraded to reply-patterns + only — which is exactly the half that phrasing can evade. A safety check must + not depend on an unrelated feature flag.""" + src = (_REPO / "routes" / "chat.py").read_text() + body = src[src.index("async def chat_completion"):] + assign = body.index('last_user_text = ""') + flag = body.index('use_tools = body.get("tools"') + assert assign < flag, "last_user_text must be resolved BEFORE the use_tools branch" + # and the resolution loop itself must sit outside the branch + loop = body.index("for _m in reversed(messages):") + assert loop < flag, "the resolution loop must not live inside `if use_tools:`"