mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-02 14:28:59 +02:00
[eric] agents: the CLI's prose policy refusal is the provider talking, not the agent's answer (ENG-411)
This commit is contained in:
@@ -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")))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user