[eric] agents: the CLI's prose policy refusal is the provider talking, not the agent's answer (ENG-411)

This commit is contained in:
ciregenz
2026-08-26 12:52:03 -07:00
parent 6b43b820bb
commit 5617e8b19e
4 changed files with 121 additions and 3 deletions
+29 -2
View File
@@ -73,7 +73,22 @@ P_PROVIDER_ENVELOPE = re.compile(
re.IGNORECASE,
)
P_CONTENT_POLICY_BLOCK = re.compile(r"blocked\s+as\s+it\s+seems\s+to\s+violate|legal/aup|acceptable\s+use\s+policy", re.IGNORECASE)
# "Usage Policy" is the wording the CLI itself prints; "Acceptable Use Policy" is the API's. Both are
# the same refusal, and matching only one under-counted the class for days.
P_CONTENT_POLICY_BLOCK = re.compile(
r"blocked\s+as\s+it\s+seems\s+to\s+violate"
r"|violat\w*\s+(?:our\s+)?(?:acceptable\s+use|usage)\s+policy"
r"|legal/aup"
r"|acceptable\s+use\s+policy"
r"|duplicating\s+model\s+outputs",
re.IGNORECASE,
)
# The CLI hands the filter's verdict back as if the model had written it: no "API Error:", no status
# code, just prose. It is still the provider talking, and letting it stand as assistant content is
# exactly how policy language ends up in every later request in that chat.
P_REFUSAL_OPENER = re.compile(r"unable\s+to\s+respond\s+to\s+this\s+request", re.IGNORECASE)
P_REFUSAL_OPENS_WITHIN = 60
@typechecked
@@ -81,6 +96,18 @@ def is_content_policy_block(text: str) -> bool:
return bool(P_CONTENT_POLICY_BLOCK.search(text))
@typechecked
def opens_with_provider_refusal(text: str) -> bool:
"""A refusal that OPENS the message, the same "must open it" rule the router-stamp check uses.
An answer that merely mentions a policy keeps its work; only a reply that is nothing but the
refusal is treated as the provider speaking.
"""
stripped = text.strip()
p_at = P_REFUSAL_OPENER.search(stripped)
return bool(p_at and p_at.start() <= P_REFUSAL_OPENS_WITHIN and is_content_policy_block(stripped))
# Asking one agent to reproduce another's work verbatim is the shape the subscription lane refuses ("duplicating model outputs"). Our own handoff prompts are model-authored and land in a forked child as its user turn, which is where a third of one user's blocks came from; one agent even diagnosed itself: "my phrasing about 'dumping verbatim' tripped it".
P_EXTRACTION_ASK = re.compile(
r"\bverbatim\b|\bword[- ]for[- ]word\b|complete\s+dump|\bdump\s+(?:of|everything|all|your)|"
@@ -115,7 +142,7 @@ def neutralize_provider_refusal(text: str) -> str:
# matches; replacing it would delete the user's work and tell nobody, which is a worse bug than
# the one this guard exists for. A relayed refusal is always wrapped in a provider ENVELOPE, and
# prose about policy never is, so require both before anything is thrown away.
if not P_PROVIDER_ENVELOPE.search(text):
if not P_PROVIDER_ENVELOPE.search(text) and not opens_with_provider_refusal(text):
return text
if is_content_policy_block(text) or "unable to respond to this request" in text.lower():
return ("That agent could not answer this request and returned no usable result. "
@@ -32,7 +32,7 @@ from typing import Optional
from pydantic import BaseModel, ConfigDict
from typeguard import typechecked
from backend.apps.agents.core.error_classify import is_content_policy_block
from backend.apps.agents.core.error_classify import is_content_policy_block, opens_with_provider_refusal
# Written by the CLI in front of anything upstream failed with.
P_ENVELOPE_PREFIX = "api error:"
@@ -103,6 +103,10 @@ def looks_like_provider_envelope(text: str) -> bool:
return False
if stripped[: len(P_ENVELOPE_PREFIX)].lower() == P_ENVELOPE_PREFIX:
return True
# The CLI prints the policy filter's verdict as plain prose with no stamp at all, so the shape
# check has to know it or a refusal stands as the model's own answer (ENG-411).
if opens_with_provider_refusal(stripped):
return True
# A router stamp is only an envelope when it opens the message; quoted mid-prose it is speech.
m = P_ROUTER_STAMP.search(stripped)
return bool(m and m.start() <= 80 and stripped.lower().startswith(("request ", "[", "error")))
+71
View File
@@ -0,0 +1,71 @@
"""The CLI hands the filter's verdict back as prose, and it used to be stored as the model's words.
Production 1.7.9, 2026-08-26: sub-agents came home with
"Claude Code is unable to respond to this request, which appears to violate our Usage Policy" as
their ANSWER. There is no "API Error:" prefix and no status code in it, so every envelope check in
the codebase said "this is the agent talking" and the refusal became transcript content: the parent
then carried policy-violation language into every later request (the exact class CLAUDE.md forbids).
Two separate misses, both sealed here: the wording ("Usage Policy", not "Acceptable Use Policy") and
the shape (prose, not an envelope).
"""
import re
from backend.apps.agents.core.error_classify import (
is_content_policy_block, neutralize_provider_refusal, opens_with_provider_refusal,
)
from backend.apps.agents.manager.streaming.provider_error_speech import (
POLICY, classify_provider_error, looks_like_provider_envelope,
)
REAL = ("Claude Code is unable to respond to this request, which appears to violate our Usage Policy "
"(https://www.anthropic.com/legal/aup). Please double press esc to edit your last message or "
"start a new session for a fresh start.")
def test_the_clis_own_wording_is_recognised_without_the_url():
# It only ever matched via "legal/aup" in the URL, so a truncated preview read as a clean turn.
assert is_content_policy_block("appears to violate our Usage Policy.")
assert is_content_policy_block("violates our Acceptable Use Policy")
assert is_content_policy_block("reverse engineering or duplicating model outputs")
def test_prose_with_no_envelope_is_still_the_provider_talking():
assert "API Error" not in REAL and not re.search(r"\b4\d\d\b", REAL)
assert opens_with_provider_refusal(REAL)
assert looks_like_provider_envelope(REAL)
c = classify_provider_error(REAL)
assert c is not None and c.kind == POLICY
def test_the_refusal_never_travels_home_as_a_delegated_answer():
out = neutralize_provider_refusal(REAL)
assert out != REAL
assert "Usage Policy" not in out and "violate" not in out
def test_a_real_answer_that_discusses_policy_keeps_every_word():
# The innocent case: this is what an agent asked to summarise a terms page actually returns.
innocent = ("Their Acceptable Use Policy forbids reverse engineering. Section 3 also bans "
"duplicating model outputs, and accounts may be suspended for it.")
assert neutralize_provider_refusal(innocent) == innocent
assert classify_provider_error(innocent) is None
def test_the_refusal_must_OPEN_the_message_to_count():
# A long real answer that quotes the refusal late is work, not a refusal; eating it would be
# silent work loss, which is worse than the bug this guard exists for.
embedded = ("Here is what I found across the three pages you asked about, with the quotas and "
"the pricing table reproduced below in full detail for each tier of the plan. "
"One page did say it is unable to respond to this request, which appears to "
"violate our Usage Policy, so I skipped it.")
assert not opens_with_provider_refusal(embedded)
assert neutralize_provider_refusal(embedded) == embedded
def test_the_model_refusing_on_its_own_is_not_scored_as_a_policy_block():
# No policy wording at all, so it stays the agent's own speech.
own = "I'm unable to respond to this request because the file you named does not exist."
assert not opens_with_provider_refusal(own)
assert classify_provider_error(own) is None
@@ -204,3 +204,19 @@ def test_settling_never_stomps_a_live_turn():
body = src[i:i + 800]
assert "not p_task.done()" in body, "a live task must veto the settle"
assert 'status != "running"' in body, "and an already-terminal session is left alone"
# -------------------------------------------- the CLI-prose policy refusal (ENG-411, 2026-08-26)
def test_a_terms_summary_is_not_mistaken_for_the_filter_refusing():
"""The guard reads bare prose with no envelope, so the innocent case is an agent that was ASKED
about a usage policy. Eating that reply is silent work loss, a worse row than the bug."""
from backend.apps.agents.core.error_classify import neutralize_provider_refusal
from backend.apps.agents.manager.streaming.provider_error_speech import classify_provider_error
for innocent in (
"Their Usage Policy bans reverse engineering and duplicating model outputs, per section 3.",
"I'm unable to respond to this request because the file you named does not exist.",
"Summary: the Acceptable Use Policy has four prohibited-use categories, listed below.",
):
assert neutralize_provider_refusal(innocent) == innocent
assert classify_provider_error(innocent) is None