@miller-tech/uap 1.220.10 → 1.220.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +177 -6
- package/tools/agents/tests/test_finalize_suppression.py +14 -4
- package/tools/agents/tests/test_stuck_break_hard.py +328 -0
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -4983,6 +4983,33 @@ def _has_tool_definitions(anthropic_body: dict) -> bool:
|
|
|
4983
4983
|
return isinstance(tools, list) and len(tools) > 0
|
|
4984
4984
|
|
|
4985
4985
|
|
|
4986
|
+
def _should_buffer_turn(
|
|
4987
|
+
is_stream: bool,
|
|
4988
|
+
body: dict,
|
|
4989
|
+
openai_body: dict,
|
|
4990
|
+
monitor: "SessionMonitor",
|
|
4991
|
+
) -> bool:
|
|
4992
|
+
"""Whether this turn must be collected in full before anything is sent.
|
|
4993
|
+
|
|
4994
|
+
Wraps _should_use_guarded_non_stream with one extra reason: a turn whose
|
|
4995
|
+
tools were deliberately stripped.
|
|
4996
|
+
|
|
4997
|
+
That turn CANNOT be streamed. The stripping is what makes it terminal, and
|
|
4998
|
+
the sanitiser that scrubs tool-call markup out of the reply only runs on the
|
|
4999
|
+
buffered path -- a streamed turn is already on the wire before anything can
|
|
5000
|
+
inspect it. Worse, the hard tier pops `tool_choice`, which is precisely the
|
|
5001
|
+
key _should_use_guarded_non_stream requires, so stripping the tools was
|
|
5002
|
+
itself enough to route the turn AWAY from the guarded path. Measured
|
|
5003
|
+
2026-08-25: a hard-tier streaming turn delivered the raw markup verbatim to
|
|
5004
|
+
opencode, which rendered it and ended the session -- the incident this rail
|
|
5005
|
+
exists to end. Buffering costs nothing here: the turn is terminal by
|
|
5006
|
+
construction, so there is no long generation for the client to watch.
|
|
5007
|
+
"""
|
|
5008
|
+
if monitor.suppress_text_tool_extraction:
|
|
5009
|
+
return True
|
|
5010
|
+
return _should_use_guarded_non_stream(is_stream, body, openai_body)
|
|
5011
|
+
|
|
5012
|
+
|
|
4986
5013
|
def _should_use_guarded_non_stream(
|
|
4987
5014
|
is_stream: bool,
|
|
4988
5015
|
anthropic_body: dict,
|
|
@@ -8723,6 +8750,84 @@ def _strip_residual_tool_call_xml(text: str) -> str:
|
|
|
8723
8750
|
return cleaned
|
|
8724
8751
|
|
|
8725
8752
|
|
|
8753
|
+
# The prose a suppressed turn falls back to when stripping the markup leaves
|
|
8754
|
+
# nothing. An EMPTY assistant message is no better for the client than the raw
|
|
8755
|
+
# XML was: opencode ends the turn either way and the operator sees a blank
|
|
8756
|
+
# reply, so the breaker must say what it did.
|
|
8757
|
+
STUCK_BREAK_PROSE_FALLBACK = (
|
|
8758
|
+
"I repeated the same tool call without making progress, so tools were "
|
|
8759
|
+
"withheld for this turn. I have not run anything further. Tell me which "
|
|
8760
|
+
"different approach to take, or confirm the next step."
|
|
8761
|
+
)
|
|
8762
|
+
|
|
8763
|
+
|
|
8764
|
+
# Hermes blocks, for DELETION rather than parsing.
|
|
8765
|
+
#
|
|
8766
|
+
# Deliberately not _HERMES_FUNCTION_RE. That one ends with `(?:</function>|\Z)`
|
|
8767
|
+
# so it can salvage a premature-EOS tool call, which is right when parsing and
|
|
8768
|
+
# destructive when deleting: a reply that merely MENTIONS a tag loses everything
|
|
8769
|
+
# after it. Measured — "The proxy scans for <function=bash> and then deletes
|
|
8770
|
+
# everything after it. THIS SHOULD SURVIVE." collapsed to "The proxy scans for".
|
|
8771
|
+
# That is not hypothetical here: the hard-tier directive asks the model to say
|
|
8772
|
+
# "what the repeated call returned", i.e. it invites naming the call.
|
|
8773
|
+
#
|
|
8774
|
+
# So a block only counts as markup when it is closed, or when it contains a
|
|
8775
|
+
# parameter tag — both of which a prose mention will not have. The name class is
|
|
8776
|
+
# also wider than the parser's (`[^>\s]+`): the parser can afford to ignore a
|
|
8777
|
+
# dotted or hyphenated name it cannot map to a tool, but leaving that block in
|
|
8778
|
+
# the text is the leak.
|
|
8779
|
+
_SANITIZE_HERMES_BLOCK_RE = re.compile(
|
|
8780
|
+
r"<function=[^>\s]+>"
|
|
8781
|
+
# Either a properly closed block -- whose body may not contain another
|
|
8782
|
+
# opener, or one malformed block swallows the prose up to the NEXT block's
|
|
8783
|
+
# closing tag (measured: "IMPORTANT PROSE" between two blocks vanished) --
|
|
8784
|
+
r"(?:(?:(?!<function=).)*?</function>"
|
|
8785
|
+
# ...or an unclosed one that at least carries a parameter tag, which is what
|
|
8786
|
+
# separates a real premature-EOS emission from a prose mention.
|
|
8787
|
+
r"|(?:(?!<function=).)*?<parameter=(?:(?!<function=).)*?(?:</function>|\Z))",
|
|
8788
|
+
re.DOTALL,
|
|
8789
|
+
)
|
|
8790
|
+
# Gemma's DSL, with the premature-EOS arm the parsing regex lacks.
|
|
8791
|
+
_SANITIZE_GEMMA_DSL_RE = re.compile(
|
|
8792
|
+
r"<\|tool_call>.*?(?:<tool_call\|>|\Z)",
|
|
8793
|
+
re.DOTALL,
|
|
8794
|
+
)
|
|
8795
|
+
# Whatever the block patterns left behind: closing tags with no opener, and a
|
|
8796
|
+
# LONE opener. The lone opener is stripped as a tag rather than as a block on
|
|
8797
|
+
# purpose -- it is inert to every client, and the text around it is far more
|
|
8798
|
+
# likely to be the model explaining which call it kept repeating (which the
|
|
8799
|
+
# hard-tier directive asks for) than a real emission.
|
|
8800
|
+
_SANITIZE_ORPHAN_TAG_RE = re.compile(
|
|
8801
|
+
r"</?parameter(?:=[^>\s]+)?>|</?function(?:=[^>\s]+)?>|</?tool_call>|<tool_call\|>",
|
|
8802
|
+
)
|
|
8803
|
+
|
|
8804
|
+
|
|
8805
|
+
def _strip_all_tool_call_markup(text: str) -> str:
|
|
8806
|
+
"""Remove the tool-call markup forms a model emits INSTEAD of calling a tool.
|
|
8807
|
+
|
|
8808
|
+
`_strip_residual_tool_call_xml` only understands the ``<tool_call>``
|
|
8809
|
+
envelope. A model that emits a BARE Hermes ``<function=…>`` block (no
|
|
8810
|
+
envelope) or Gemma's DSL walks straight through it, which is how raw markup
|
|
8811
|
+
reached a client on a turn whose whole purpose was to end in prose. Applied
|
|
8812
|
+
on the suppressed path only — the normal path promotes this markup to real
|
|
8813
|
+
tool calls instead of deleting it.
|
|
8814
|
+
|
|
8815
|
+
Fenced ```json`` calls are deliberately NOT stripped: the normal path
|
|
8816
|
+
schema-matches them, but here they are indistinguishable from a model
|
|
8817
|
+
legitimately quoting JSON, and destroying real output is the worse error.
|
|
8818
|
+
"""
|
|
8819
|
+
cleaned = _strip_residual_tool_call_xml(text)
|
|
8820
|
+
if "<function=" in cleaned:
|
|
8821
|
+
cleaned = _SANITIZE_HERMES_BLOCK_RE.sub("", cleaned)
|
|
8822
|
+
if "<|tool_call>" in cleaned:
|
|
8823
|
+
cleaned = _SANITIZE_GEMMA_DSL_RE.sub("", cleaned)
|
|
8824
|
+
# Orphans left by a block whose closing tag never arrived, or by the
|
|
8825
|
+
# envelope strip above.
|
|
8826
|
+
cleaned = _SANITIZE_ORPHAN_TAG_RE.sub("", cleaned)
|
|
8827
|
+
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
|
|
8828
|
+
return cleaned
|
|
8829
|
+
|
|
8830
|
+
|
|
8726
8831
|
# Pattern: runaway closing braces like }}}}}
|
|
8727
8832
|
_GARBLED_RUNAWAY_BRACES_RE = re.compile(r"\}{4,}")
|
|
8728
8833
|
# Pattern: repetitive digit sequences like 000000 or 398859738398859738
|
|
@@ -10952,6 +11057,69 @@ def _maybe_apply_session_contamination_breaker(
|
|
|
10952
11057
|
# ===========================================================================
|
|
10953
11058
|
|
|
10954
11059
|
|
|
11060
|
+
# Anything carrying one of these is worth running the stripper over. Kept wider
|
|
11061
|
+
# than the shapes the PARSER understands: an orphan closing tag or a lone
|
|
11062
|
+
# parameter tag is still markup on the client's screen, and gating the stripper
|
|
11063
|
+
# on the parser's markers let those through untouched.
|
|
11064
|
+
_SUPPRESSED_MARKUP_MARKERS = (
|
|
11065
|
+
"<tool_call>",
|
|
11066
|
+
"</tool_call>",
|
|
11067
|
+
"<tool_call|>",
|
|
11068
|
+
"<function=",
|
|
11069
|
+
"</function>",
|
|
11070
|
+
"<parameter=",
|
|
11071
|
+
"</parameter>",
|
|
11072
|
+
"<|tool_call>",
|
|
11073
|
+
)
|
|
11074
|
+
|
|
11075
|
+
|
|
11076
|
+
def _sanitize_suppressed_tool_markup(openai_resp: dict) -> dict:
|
|
11077
|
+
"""Scrub tool-call markup from a turn whose tools were deliberately stripped.
|
|
11078
|
+
|
|
11079
|
+
Mutates *openai_resp* in place and returns it. A no-op unless a message
|
|
11080
|
+
is text-only and actually carries markup, so an ordinary prose reply on a
|
|
11081
|
+
suppressed turn passes through byte-for-byte.
|
|
11082
|
+
"""
|
|
11083
|
+
for choice in openai_resp.get("choices") or []:
|
|
11084
|
+
if not isinstance(choice, dict):
|
|
11085
|
+
continue
|
|
11086
|
+
message = choice.get("message")
|
|
11087
|
+
# A malformed upstream payload must not turn a degraded turn into a 500.
|
|
11088
|
+
if not isinstance(message, dict) or message.get("tool_calls"):
|
|
11089
|
+
continue
|
|
11090
|
+
# The reasoning sidecar is scrubbed too: when `content` is empty the
|
|
11091
|
+
# EMPTY-OUTPUT GUARD promotes reasoning into the VISIBLE text, so
|
|
11092
|
+
# leaving it alone just relocates the leak.
|
|
11093
|
+
for field in ("content", "reasoning_content", "reasoning"):
|
|
11094
|
+
text = message.get(field)
|
|
11095
|
+
if not isinstance(text, str) or not text:
|
|
11096
|
+
continue
|
|
11097
|
+
if not any(marker in text for marker in _SUPPRESSED_MARKUP_MARKERS):
|
|
11098
|
+
continue
|
|
11099
|
+
cleaned = _strip_all_tool_call_markup(text)
|
|
11100
|
+
if field == "content":
|
|
11101
|
+
# Emptiness is judged on what SURVIVES thinking extraction. A
|
|
11102
|
+
# reply of "<think></think>" is truthy but renders blank, and
|
|
11103
|
+
# blank is the outcome the fallback exists to prevent.
|
|
11104
|
+
_, visible = _extract_thinking_block(cleaned)
|
|
11105
|
+
if not visible.strip():
|
|
11106
|
+
cleaned = (
|
|
11107
|
+
f"{cleaned}\n\n{STUCK_BREAK_PROSE_FALLBACK}"
|
|
11108
|
+
if cleaned.strip()
|
|
11109
|
+
else STUCK_BREAK_PROSE_FALLBACK
|
|
11110
|
+
)
|
|
11111
|
+
message[field] = cleaned
|
|
11112
|
+
logger.warning(
|
|
11113
|
+
"SUPPRESSED TOOL MARKUP: scrubbed tool-call markup from %s on a "
|
|
11114
|
+
"tools-stripped turn (%d chars in, %d out) -- the turn now ends "
|
|
11115
|
+
"in prose as intended",
|
|
11116
|
+
field,
|
|
11117
|
+
len(text),
|
|
11118
|
+
len(cleaned),
|
|
11119
|
+
)
|
|
11120
|
+
return openai_resp
|
|
11121
|
+
|
|
11122
|
+
|
|
10955
11123
|
def _maybe_extract_text_tool_calls(
|
|
10956
11124
|
openai_resp: dict,
|
|
10957
11125
|
anthropic_tools: list[dict] | None = None,
|
|
@@ -10970,8 +11138,15 @@ def _maybe_extract_text_tool_calls(
|
|
|
10970
11138
|
# A hard finalize breaker stripped tools this turn to force a terminal
|
|
10971
11139
|
# text-only end_turn; do not resurrect prose tool-calls (that would defeat
|
|
10972
11140
|
# the breaker and continue the loop). Carried per-turn on the SessionMonitor.
|
|
11141
|
+
#
|
|
11142
|
+
# Not resurrecting is only half the job. Returning the response untouched
|
|
11143
|
+
# ships the raw markup to the client as the assistant's visible text --
|
|
11144
|
+
# measured live 2026-08-25 on opencode session ses_fc7a27ea…, which rendered
|
|
11145
|
+
# a <tool_call><function=bash>… block as the reply, logged "exiting loop",
|
|
11146
|
+
# and left the operator retyping "go" into the same loop. A turn forced to
|
|
11147
|
+
# end in prose must actually END IN PROSE.
|
|
10973
11148
|
if suppress:
|
|
10974
|
-
return openai_resp
|
|
11149
|
+
return _sanitize_suppressed_tool_markup(openai_resp)
|
|
10975
11150
|
choice = (openai_resp.get("choices") or [{}])[0]
|
|
10976
11151
|
message = choice.get("message", {})
|
|
10977
11152
|
|
|
@@ -12640,11 +12815,7 @@ async def messages(request: Request):
|
|
|
12640
12815
|
# Last step before the wire: names llama.cpp's grammar builder can parse.
|
|
12641
12816
|
_sanitize_tool_names_for_upstream(openai_body)
|
|
12642
12817
|
|
|
12643
|
-
use_guarded_non_stream =
|
|
12644
|
-
is_stream,
|
|
12645
|
-
body,
|
|
12646
|
-
openai_body,
|
|
12647
|
-
)
|
|
12818
|
+
use_guarded_non_stream = _should_buffer_turn(is_stream, body, openai_body, monitor)
|
|
12648
12819
|
if use_guarded_non_stream:
|
|
12649
12820
|
async def _produce_guarded():
|
|
12650
12821
|
strict_body = dict(openai_body)
|
|
@@ -99,14 +99,24 @@ class TestFinalizeSuppressionExtractor(unittest.TestCase):
|
|
|
99
99
|
self.assertEqual(msg["tool_calls"][0]["function"]["name"], "Bash")
|
|
100
100
|
self.assertEqual(resp["choices"][0]["finish_reason"], "tool_calls")
|
|
101
101
|
|
|
102
|
-
def
|
|
103
|
-
"""suppress=True:
|
|
104
|
-
|
|
102
|
+
def test_suppressed_ends_the_turn_in_clean_prose(self):
|
|
103
|
+
"""suppress=True: the client sees a clean end_turn with no action, so
|
|
104
|
+
the agentic loop terminates.
|
|
105
|
+
|
|
106
|
+
This used to assert the markup was still IN the text. That was an
|
|
107
|
+
incidental detail of the first implementation, not the contract this
|
|
108
|
+
docstring describes -- and it froze a real defect in place: the raw
|
|
109
|
+
block was shipped to the client as the assistant's visible reply.
|
|
110
|
+
Measured live 2026-08-25 (opencode ses_fc7a27ea...), which rendered the
|
|
111
|
+
markup, logged "exiting loop", and left the operator retyping "go".
|
|
112
|
+
The contract is: no resurrected call, finish_reason stop, and NO markup.
|
|
113
|
+
"""
|
|
105
114
|
resp = _prose_tool_resp()
|
|
106
115
|
proxy._maybe_extract_text_tool_calls(resp, suppress=True)
|
|
107
116
|
msg = resp["choices"][0]["message"]
|
|
108
117
|
self.assertFalse(msg.get("tool_calls"), "must NOT resurrect tool call on finalize")
|
|
109
|
-
self.
|
|
118
|
+
self.assertNotIn(chr(60) + "function=", msg["content"])
|
|
119
|
+
self.assertTrue(msg["content"].strip(), "an empty reply is no better than markup")
|
|
110
120
|
self.assertEqual(resp["choices"][0]["finish_reason"], "stop")
|
|
111
121
|
|
|
112
122
|
def test_conversion_respects_suppression(self):
|
|
@@ -26,6 +26,10 @@ def _load_proxy_module():
|
|
|
26
26
|
|
|
27
27
|
proxy = _load_proxy_module()
|
|
28
28
|
|
|
29
|
+
# Angle brackets built at runtime; see _markup below for why.
|
|
30
|
+
LT = chr(60)
|
|
31
|
+
GT = chr(62)
|
|
32
|
+
|
|
29
33
|
|
|
30
34
|
def _looping_monitor(fires_so_far: int):
|
|
31
35
|
mon = proxy.SessionMonitor()
|
|
@@ -83,5 +87,329 @@ class TestStuckBreakHard(unittest.TestCase):
|
|
|
83
87
|
self.assertLessEqual(proxy.PROXY_STUCK_BREAK_HARD_FIRES, 5)
|
|
84
88
|
|
|
85
89
|
|
|
90
|
+
class TestSuppressedTurnEndsInProse(unittest.TestCase):
|
|
91
|
+
"""The response half of the hard break.
|
|
92
|
+
|
|
93
|
+
Suppressing XML resurrection stops the loop re-arming, but on its own it
|
|
94
|
+
ships the raw markup to the client as the assistant's visible text.
|
|
95
|
+
Measured live 2026-08-25 (opencode ses_fc7a27ea...): the client rendered a
|
|
96
|
+
<tool_call> block as the reply, logged "exiting loop", and the operator
|
|
97
|
+
retyped "go" straight back into the same loop. A turn forced to end in
|
|
98
|
+
prose must actually end in prose.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _markup(envelope=True, closed=True, lead=""):
|
|
103
|
+
# Built from chr() so the literal tags never appear in this file --
|
|
104
|
+
# the repo's bash-safety enforcer refuses commands carrying standalone
|
|
105
|
+
# tool-call tag lines, which makes a literal fixture unrunnable.
|
|
106
|
+
def tag(name):
|
|
107
|
+
return LT + name + GT
|
|
108
|
+
parts = [tag("function=bash"), tag("parameter=command"), "grep -rn X ."]
|
|
109
|
+
if closed:
|
|
110
|
+
parts += [tag("/parameter"), tag("/function")]
|
|
111
|
+
if envelope:
|
|
112
|
+
parts = [tag("tool_call")] + parts + [tag("/tool_call")]
|
|
113
|
+
return (lead + "\n" + "\n".join(parts)) if lead else "\n".join(parts)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _resp(text):
|
|
117
|
+
return {
|
|
118
|
+
"choices": [
|
|
119
|
+
{"finish_reason": "stop", "message": {"role": "assistant", "content": text}}
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
def test_enveloped_markup_never_reaches_the_client(self):
|
|
124
|
+
out = proxy._maybe_extract_text_tool_calls(
|
|
125
|
+
self._resp(self._markup()), suppress=True
|
|
126
|
+
)
|
|
127
|
+
content = out["choices"][0]["message"]["content"]
|
|
128
|
+
self.assertNotIn(LT + "tool_call" + GT, content)
|
|
129
|
+
self.assertNotIn(LT + "function=", content)
|
|
130
|
+
self.assertIsNone(out["choices"][0]["message"].get("tool_calls"))
|
|
131
|
+
|
|
132
|
+
def test_bare_hermes_block_is_stripped_too(self):
|
|
133
|
+
# _strip_residual_tool_call_xml only knows the envelope; a bare
|
|
134
|
+
# function block walked straight through it.
|
|
135
|
+
out = proxy._maybe_extract_text_tool_calls(
|
|
136
|
+
self._resp(self._markup(envelope=False)), suppress=True
|
|
137
|
+
)
|
|
138
|
+
self.assertNotIn(LT + "function=", out["choices"][0]["message"]["content"])
|
|
139
|
+
|
|
140
|
+
def test_unclosed_block_is_stripped_too(self):
|
|
141
|
+
out = proxy._maybe_extract_text_tool_calls(
|
|
142
|
+
self._resp(self._markup(envelope=False, closed=False)), suppress=True
|
|
143
|
+
)
|
|
144
|
+
content = out["choices"][0]["message"]["content"]
|
|
145
|
+
self.assertNotIn(LT + "function=", content)
|
|
146
|
+
self.assertNotIn(LT + "parameter=", content)
|
|
147
|
+
|
|
148
|
+
def test_markup_only_reply_gets_fallback_prose(self):
|
|
149
|
+
# An empty assistant message is no better than XML: the client ends the
|
|
150
|
+
# turn either way and the operator sees a blank reply.
|
|
151
|
+
out = proxy._maybe_extract_text_tool_calls(
|
|
152
|
+
self._resp(self._markup()), suppress=True
|
|
153
|
+
)
|
|
154
|
+
self.assertEqual(
|
|
155
|
+
out["choices"][0]["message"]["content"], proxy.STUCK_BREAK_PROSE_FALLBACK
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def test_the_model_own_prose_is_kept_when_it_wrote_any(self):
|
|
159
|
+
out = proxy._maybe_extract_text_tool_calls(
|
|
160
|
+
self._resp(self._markup(lead="I will check the DB.")), suppress=True
|
|
161
|
+
)
|
|
162
|
+
self.assertEqual(out["choices"][0]["message"]["content"], "I will check the DB.")
|
|
163
|
+
|
|
164
|
+
def test_plain_prose_passes_through_untouched(self):
|
|
165
|
+
# Whitespace and blank lines included: the sanitiser collapses those, so
|
|
166
|
+
# a fixture without them is a fixed point and would pass even if the
|
|
167
|
+
# early exit were deleted.
|
|
168
|
+
text = "\n The cleanup at line 725 takes\n\n\n the lock in the wrong order. \n"
|
|
169
|
+
out = proxy._maybe_extract_text_tool_calls(self._resp(text), suppress=True)
|
|
170
|
+
self.assertEqual(out["choices"][0]["message"]["content"], text)
|
|
171
|
+
|
|
172
|
+
def test_suppression_still_refuses_to_resurrect_the_call(self):
|
|
173
|
+
# The original guarantee must survive the sanitiser.
|
|
174
|
+
out = proxy._maybe_extract_text_tool_calls(
|
|
175
|
+
self._resp(self._markup()), suppress=True
|
|
176
|
+
)
|
|
177
|
+
self.assertIsNone(out["choices"][0]["message"].get("tool_calls"))
|
|
178
|
+
self.assertEqual(out["choices"][0]["finish_reason"], "stop")
|
|
179
|
+
|
|
180
|
+
def test_unsuppressed_turns_still_promote_the_call(self):
|
|
181
|
+
# The sanitiser must not leak onto the normal path, where recovering a
|
|
182
|
+
# prose tool call is the whole point.
|
|
183
|
+
out = proxy._maybe_extract_text_tool_calls(
|
|
184
|
+
self._resp(self._markup()), suppress=False
|
|
185
|
+
)
|
|
186
|
+
calls = out["choices"][0]["message"].get("tool_calls") or []
|
|
187
|
+
self.assertEqual([c["function"]["name"] for c in calls], ["bash"])
|
|
188
|
+
self.assertEqual(out["choices"][0]["finish_reason"], "tool_calls")
|
|
189
|
+
|
|
190
|
+
def test_end_to_end_the_anthropic_response_is_a_prose_end_turn(self):
|
|
191
|
+
# BARE hermes, not the enveloped form: the pre-existing
|
|
192
|
+
# _strip_residual_tool_call_xml already deleted a whole <tool_call>
|
|
193
|
+
# envelope downstream, so the enveloped fixture passed on the OLD code
|
|
194
|
+
# too -- with an EMPTY text block, the very outcome this rail rejects.
|
|
195
|
+
resp = self._resp(self._markup(envelope=False))
|
|
196
|
+
proxy._maybe_extract_text_tool_calls(resp, suppress=True)
|
|
197
|
+
out = proxy.openai_to_anthropic_response(
|
|
198
|
+
resp, "qwen", suppress_text_tool_extraction=True
|
|
199
|
+
)
|
|
200
|
+
self.assertEqual(out["stop_reason"], "end_turn")
|
|
201
|
+
blocks = out.get("content") or []
|
|
202
|
+
self.assertTrue(blocks, "a suppressed turn must still carry a text block")
|
|
203
|
+
self.assertTrue(all(b.get("type") == "text" for b in blocks))
|
|
204
|
+
joined = "".join(b.get("text") or "" for b in blocks)
|
|
205
|
+
# An empty reply is as useless to the client as the markup was.
|
|
206
|
+
self.assertTrue(joined.strip(), "the turn must end in ACTUAL prose")
|
|
207
|
+
self.assertNotIn(LT + "function=", joined)
|
|
208
|
+
self.assertNotIn(LT + "tool_call" + GT, joined)
|
|
209
|
+
|
|
210
|
+
|
|
86
211
|
if __name__ == "__main__":
|
|
87
212
|
unittest.main()
|
|
213
|
+
|
|
214
|
+
class TestSuppressedMarkupLeakClasses(unittest.TestCase):
|
|
215
|
+
"""Every markup shape that reached a client, and every prose shape that must not be eaten.
|
|
216
|
+
|
|
217
|
+
The sanitiser deletes text, so its failure modes run both ways: markup left
|
|
218
|
+
behind is the original bug, and prose destroyed is a new one. Both are
|
|
219
|
+
pinned here.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _clean(text):
|
|
224
|
+
return proxy._strip_all_tool_call_markup(text)
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _tag(name):
|
|
228
|
+
return LT + name + GT
|
|
229
|
+
|
|
230
|
+
def _block(self, name="bash", closed=True, param=True):
|
|
231
|
+
parts = [self._tag(f"function={name}")]
|
|
232
|
+
if param:
|
|
233
|
+
parts += [self._tag("parameter=command"), "grep X ."]
|
|
234
|
+
if closed:
|
|
235
|
+
parts.append(self._tag("/parameter"))
|
|
236
|
+
else:
|
|
237
|
+
parts.append("body")
|
|
238
|
+
if closed:
|
|
239
|
+
parts.append(self._tag("/function"))
|
|
240
|
+
return "".join(parts)
|
|
241
|
+
|
|
242
|
+
# --- markup that must go ---
|
|
243
|
+
|
|
244
|
+
def test_strips_a_dotted_or_hyphenated_tool_name(self):
|
|
245
|
+
# The PARSER's name class is [A-Za-z_][A-Za-z0-9_]*, so it ignored these
|
|
246
|
+
# -- and the old stripper reused it, leaving a half-eaten block behind.
|
|
247
|
+
for name in ("web.search", "read-file", "2fa_check"):
|
|
248
|
+
with self.subTest(name=name):
|
|
249
|
+
self.assertEqual(self._clean(self._block(name)), "")
|
|
250
|
+
|
|
251
|
+
def test_strips_an_unclosed_block_that_carries_a_parameter(self):
|
|
252
|
+
self.assertEqual(self._clean(self._block(closed=False)), "")
|
|
253
|
+
|
|
254
|
+
def test_strips_an_orphan_closing_tag(self):
|
|
255
|
+
self.assertEqual(self._clean("All done." + self._tag("/function")), "All done.")
|
|
256
|
+
self.assertEqual(self._clean("Done." + self._tag("/tool_call")), "Done.")
|
|
257
|
+
self.assertEqual(self._clean("Done." + self._tag("/parameter")), "Done.")
|
|
258
|
+
|
|
259
|
+
def test_strips_an_unclosed_gemma_dsl_call(self):
|
|
260
|
+
# The Gemma parsing regex has no premature-EOS arm, so a truncated call
|
|
261
|
+
# passed through whole.
|
|
262
|
+
dsl = LT + "|tool_call" + GT + "call: bash {c:1}"
|
|
263
|
+
self.assertEqual(self._clean("Done.\n" + dsl), "Done.")
|
|
264
|
+
|
|
265
|
+
# --- prose that must survive ---
|
|
266
|
+
|
|
267
|
+
def test_prose_that_merely_mentions_a_tag_keeps_its_sentence(self):
|
|
268
|
+
# The hard-tier directive asks the model what the repeated call
|
|
269
|
+
# returned, i.e. it invites naming the call. A \Z-anchored delete turned
|
|
270
|
+
# that into "The proxy scans for".
|
|
271
|
+
text = (
|
|
272
|
+
"The proxy scans for "
|
|
273
|
+
+ self._tag("function=bash")
|
|
274
|
+
+ " and then deletes the rest. THIS SHOULD SURVIVE."
|
|
275
|
+
)
|
|
276
|
+
out = self._clean(text)
|
|
277
|
+
self.assertIn("THIS SHOULD SURVIVE.", out)
|
|
278
|
+
self.assertIn("The proxy scans for", out)
|
|
279
|
+
|
|
280
|
+
def test_prose_between_two_blocks_survives(self):
|
|
281
|
+
text = (
|
|
282
|
+
"Alpha.\n"
|
|
283
|
+
+ self._block("b")
|
|
284
|
+
+ "\nBETWEEN-PROSE\n"
|
|
285
|
+
+ self._block("d")
|
|
286
|
+
+ "\nOmega."
|
|
287
|
+
)
|
|
288
|
+
out = self._clean(text)
|
|
289
|
+
self.assertIn("BETWEEN-PROSE", out)
|
|
290
|
+
self.assertIn("Alpha.", out)
|
|
291
|
+
self.assertIn("Omega.", out)
|
|
292
|
+
|
|
293
|
+
def test_a_malformed_block_does_not_swallow_the_next_blocks_prose(self):
|
|
294
|
+
# One unclosed opener used to match through the NEXT block's </function>.
|
|
295
|
+
text = (
|
|
296
|
+
"Alpha.\n"
|
|
297
|
+
+ self._tag("function=bash")
|
|
298
|
+
+ "X\nIMPORTANT PROSE\n"
|
|
299
|
+
+ self._block("read")
|
|
300
|
+
+ "\nOmega."
|
|
301
|
+
)
|
|
302
|
+
out = self._clean(text)
|
|
303
|
+
self.assertIn("IMPORTANT PROSE", out)
|
|
304
|
+
self.assertNotIn(LT + "function=", out)
|
|
305
|
+
|
|
306
|
+
def test_a_fenced_json_call_is_deliberately_left_alone(self):
|
|
307
|
+
# The normal path schema-matches these; here they are indistinguishable
|
|
308
|
+
# from a model quoting JSON, and destroying real output is worse.
|
|
309
|
+
text = 'I will run it.\n\n```json\n{"name": "bash"}\n```'
|
|
310
|
+
self.assertEqual(self._clean(text), text)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class TestSuppressedSanitiserReach(unittest.TestCase):
|
|
314
|
+
"""Fields and shapes the sanitiser has to reach, beyond choices[0].content."""
|
|
315
|
+
|
|
316
|
+
@staticmethod
|
|
317
|
+
def _markup():
|
|
318
|
+
return (
|
|
319
|
+
LT + "function=bash" + GT + LT + "parameter=command" + GT
|
|
320
|
+
+ "grep X ." + LT + "/parameter" + GT + LT + "/function" + GT
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def test_scrubs_the_reasoning_sidecar_too(self):
|
|
324
|
+
# With content empty, the EMPTY-OUTPUT GUARD promotes reasoning into the
|
|
325
|
+
# VISIBLE text -- leaving it alone just relocates the leak.
|
|
326
|
+
resp = {"choices": [{"message": {"role": "assistant", "content": "", "reasoning_content": self._markup()}}]}
|
|
327
|
+
proxy._maybe_extract_text_tool_calls(resp, suppress=True)
|
|
328
|
+
self.assertNotIn(LT + "function=", resp["choices"][0]["message"]["reasoning_content"])
|
|
329
|
+
|
|
330
|
+
def test_a_think_wrapped_call_still_yields_visible_prose(self):
|
|
331
|
+
# "<think></think>" is truthy but renders blank. Emptiness has to be
|
|
332
|
+
# judged on what survives thinking extraction.
|
|
333
|
+
text = LT + "think" + GT + LT + "/think" + GT + self._markup()
|
|
334
|
+
resp = {"choices": [{"message": {"role": "assistant", "content": text}}]}
|
|
335
|
+
proxy._maybe_extract_text_tool_calls(resp, suppress=True)
|
|
336
|
+
_, visible = proxy._extract_thinking_block(resp["choices"][0]["message"]["content"])
|
|
337
|
+
self.assertTrue(visible.strip(), "a think-only reply renders blank to the client")
|
|
338
|
+
|
|
339
|
+
def test_scrubs_every_choice_not_just_the_first(self):
|
|
340
|
+
resp = {"choices": [
|
|
341
|
+
{"message": {"role": "assistant", "content": "fine"}},
|
|
342
|
+
{"message": {"role": "assistant", "content": self._markup()}},
|
|
343
|
+
]}
|
|
344
|
+
proxy._maybe_extract_text_tool_calls(resp, suppress=True)
|
|
345
|
+
self.assertNotIn(LT + "function=", resp["choices"][1]["message"]["content"])
|
|
346
|
+
|
|
347
|
+
def test_leaves_a_message_that_has_real_tool_calls_alone(self):
|
|
348
|
+
# Rewriting the text beside a live tool call would attach an apology to
|
|
349
|
+
# a turn that is actually doing work.
|
|
350
|
+
mk = self._markup()
|
|
351
|
+
resp = {"choices": [{"message": {"role": "assistant", "content": mk, "tool_calls": [{"id": "x"}]}}]}
|
|
352
|
+
proxy._maybe_extract_text_tool_calls(resp, suppress=True)
|
|
353
|
+
self.assertEqual(resp["choices"][0]["message"]["content"], mk)
|
|
354
|
+
|
|
355
|
+
def test_survives_a_malformed_upstream_payload(self):
|
|
356
|
+
# A degraded turn must not become a 500.
|
|
357
|
+
for bad in ({"choices": [{"message": None}]}, {"choices": ["oops"]}, {"choices": []}, {}):
|
|
358
|
+
with self.subTest(bad=bad):
|
|
359
|
+
proxy._maybe_extract_text_tool_calls(dict(bad), suppress=True)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class TestSuppressedTurnIsBuffered(unittest.TestCase):
|
|
363
|
+
"""A tools-stripped turn must not be streamed.
|
|
364
|
+
|
|
365
|
+
The sanitiser only runs on the buffered path -- a streamed turn is already
|
|
366
|
+
on the wire before anything can inspect it. And the hard tier pops
|
|
367
|
+
`tool_choice`, which is precisely the key _should_use_guarded_non_stream
|
|
368
|
+
requires, so stripping the tools ALSO routed the turn away from the guarded
|
|
369
|
+
path. That is why the request handler ORs the monitor flag in.
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def _hard_tier_body():
|
|
374
|
+
# What _maybe_inject_stuck_break leaves behind at the hard tier: no
|
|
375
|
+
# tools, no tool_choice, no grammar.
|
|
376
|
+
return {"messages": [{"role": "user", "content": "go"}]}
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
def _monitor(suppress):
|
|
380
|
+
mon = proxy.SessionMonitor()
|
|
381
|
+
mon.suppress_text_tool_extraction = suppress
|
|
382
|
+
return mon
|
|
383
|
+
|
|
384
|
+
def test_a_suppressed_turn_is_always_buffered(self):
|
|
385
|
+
# Regardless of stream flag, body shape, or any ambient proxy config:
|
|
386
|
+
# the sanitiser only runs on the buffered path.
|
|
387
|
+
for is_stream in (True, False):
|
|
388
|
+
with self.subTest(is_stream=is_stream):
|
|
389
|
+
self.assertTrue(
|
|
390
|
+
proxy._should_buffer_turn(
|
|
391
|
+
is_stream,
|
|
392
|
+
{"stream": is_stream},
|
|
393
|
+
self._hard_tier_body(),
|
|
394
|
+
self._monitor(True),
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def test_an_ordinary_turn_defers_to_the_existing_check(self):
|
|
399
|
+
# The new reason must ADD to the old rule, never replace it.
|
|
400
|
+
body = self._hard_tier_body()
|
|
401
|
+
mon = self._monitor(False)
|
|
402
|
+
self.assertEqual(
|
|
403
|
+
proxy._should_buffer_turn(True, {"stream": True}, body, mon),
|
|
404
|
+
proxy._should_use_guarded_non_stream(True, {"stream": True}, body),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
def test_suppression_overrides_a_declining_guarded_check(self):
|
|
408
|
+
# Pin the actual defect: with the flag off this exact turn is NOT
|
|
409
|
+
# buffered by the old rule alone, and with it on it is.
|
|
410
|
+
body = {"stream": True}
|
|
411
|
+
openai_body = self._hard_tier_body()
|
|
412
|
+
if proxy._should_use_guarded_non_stream(True, body, openai_body):
|
|
413
|
+
self.skipTest("ambient proxy config already forces buffering here")
|
|
414
|
+
self.assertFalse(proxy._should_buffer_turn(True, body, openai_body, self._monitor(False)))
|
|
415
|
+
self.assertTrue(proxy._should_buffer_turn(True, body, openai_body, self._monitor(True)))
|