@miller-tech/uap 2.16.4 → 2.16.7
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/README.md +1 -1
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/quality_metrics_gate.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 +86 -5
- package/tools/agents/tests/test_anthropic_proxy_streaming.py +108 -0
- package/tools/agents/tests/test_repeat_call_guard.py +64 -0
- package/tools/agents/tests/test_stuck_break_hard.py +77 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
[](#testing)
|
|
10
10
|
[](LICENSE)
|
|
11
11
|
|
|
12
|
-
`v2.
|
|
12
|
+
`v2.16.6` · 367 TypeScript modules across 26 subsystems · 459 vitest suites (+ a ~1,200-test Python enforcer/proxy suite) · 9 agent harnesses
|
|
13
13
|
|
|
14
14
|
[Quickstart](#quickstart) · [Why UAP?](#why-uap) · [The delivery pipeline](docs/guides/DELIVERY_PIPELINE.md) · [`uap deliver`](#the-deliver-harness) · [Docs](docs/INDEX.md)
|
|
15
15
|
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -2494,6 +2494,15 @@ class SessionMonitor:
|
|
|
2494
2494
|
# back into a structured tool_use, which would continue the very loop the
|
|
2495
2495
|
# breaker is ending. Reset to False at the start of every request.
|
|
2496
2496
|
suppress_text_tool_extraction: bool = False
|
|
2497
|
+
# Bounded snippet of the most recent tool_result text seen in the request.
|
|
2498
|
+
# Feeds the STUCK-BREAK repeat-call directive so the model is SHOWN the
|
|
2499
|
+
# unchanged output instead of only being told it "already has" it — live
|
|
2500
|
+
# 2026-09-29, a model re-ran `uap memory query "plane workspace slug pay2u"`
|
|
2501
|
+
# 7 times ignoring the directive, because a "No results" output is not an
|
|
2502
|
+
# answer a weak model recognises as final; it believes the missing value
|
|
2503
|
+
# must exist and keeps re-asking. Quoting the output makes the futility
|
|
2504
|
+
# concrete. Refreshed on every request from the conversation.
|
|
2505
|
+
last_tool_result_snippet: str = ""
|
|
2497
2506
|
# Tool-turn count at which the TURN-COUNT FINALIZE BREAKER last fired. The
|
|
2498
2507
|
# count is derived from the (only-growing) conversation, so without this the
|
|
2499
2508
|
# breaker would re-fire on EVERY turn once the ceiling is first crossed —
|
|
@@ -7265,15 +7274,27 @@ def _maybe_inject_stuck_break(openai_body: dict, monitor: "SessionMonitor") -> N
|
|
|
7265
7274
|
if reason.startswith(_REPEAT_CALL_REASON):
|
|
7266
7275
|
# The call SUCCEEDS every time, so "stop retrying a failing action" and
|
|
7267
7276
|
# the switch-channel advice would both be nonsense here. Name the real
|
|
7268
|
-
# problem: the answer is already in hand and re-asking cannot change
|
|
7277
|
+
# problem: the answer is already in hand and re-asking cannot change
|
|
7278
|
+
# it. QUOTE the actual output — live 2026-09-29, "You already have that
|
|
7279
|
+
# output" was ignored 7 times on a `uap memory query` returning "No
|
|
7280
|
+
# results": the model does not recognise an empty result as an answer,
|
|
7281
|
+
# so the directive must show it that the output IS the output.
|
|
7282
|
+
snippet = (monitor.last_tool_result_snippet or "").strip()
|
|
7283
|
+
quoted = (
|
|
7284
|
+
f' It returns EXACTLY this every time: "{snippet[:300]}"' if snippet else ""
|
|
7285
|
+
)
|
|
7269
7286
|
directive = (
|
|
7270
7287
|
"\n\nSTOP — you have issued the same tool call with the same "
|
|
7271
7288
|
"arguments " + reason.rsplit("x", 1)[-1] + " times in a row. It "
|
|
7272
7289
|
"SUCCEEDED each time and the result will not change by asking again. "
|
|
7273
|
-
"You already have that output. Do NOT repeat it
|
|
7274
|
-
"
|
|
7275
|
-
"
|
|
7276
|
-
"
|
|
7290
|
+
"You already have that output." + quoted + " Do NOT repeat it — "
|
|
7291
|
+
"re-asking cannot produce new information, and an empty or "
|
|
7292
|
+
"'no results' output IS this source's final answer. Either take the "
|
|
7293
|
+
"NEXT concrete action using what it told you (proceeding WITHOUT "
|
|
7294
|
+
"the missing information and stating your assumption if necessary), "
|
|
7295
|
+
"or — if you genuinely cannot proceed — state the single blocking "
|
|
7296
|
+
"question in one sentence and stop. Answer in plain text now if "
|
|
7297
|
+
"the work is done."
|
|
7277
7298
|
)
|
|
7278
7299
|
else:
|
|
7279
7300
|
directive = (
|
|
@@ -9093,6 +9114,9 @@ def _record_last_assistant_tool_calls(
|
|
|
9093
9114
|
_latest_err = any(_flags)
|
|
9094
9115
|
break
|
|
9095
9116
|
monitor.note_tool_result_error(_latest_tr, _latest_err)
|
|
9117
|
+
# Bounded raw snippet of the same result, for the STUCK-BREAK repeat-call
|
|
9118
|
+
# directive (see SessionMonitor.last_tool_result_snippet).
|
|
9119
|
+
monitor.last_tool_result_snippet = (_latest_tr or "")[:400]
|
|
9096
9120
|
_seed_tool_history_from_request(monitor, messages)
|
|
9097
9121
|
tool_fingerprints = []
|
|
9098
9122
|
tool_targets: dict[str, str] = {}
|
|
@@ -11783,6 +11807,19 @@ async def _apply_unexpected_end_turn_guardrail(
|
|
|
11783
11807
|
logger.info("GUARDRAIL: skipped unexpected_end_turn retry on finalize turn")
|
|
11784
11808
|
return openai_resp
|
|
11785
11809
|
|
|
11810
|
+
# A tools-stripped breaker turn (STUCK-BREAK HARD / finalize-adjacent) ends
|
|
11811
|
+
# in prose BY DESIGN -- observed live 2026-09-29 08:43:40, the model finally
|
|
11812
|
+
# produced the prose exit and this guardrail treated it as an anomaly and
|
|
11813
|
+
# retried with tool_choice=required, re-coercing the very loop the breaker
|
|
11814
|
+
# exists to end (and burning a full prefill to do it).
|
|
11815
|
+
if monitor.suppress_text_tool_extraction:
|
|
11816
|
+
logger.info(
|
|
11817
|
+
"GUARDRAIL: skipped unexpected_end_turn retry on a tools-stripped "
|
|
11818
|
+
"breaker turn -- the prose exit is the intended outcome, not an "
|
|
11819
|
+
"anomaly to re-coerce"
|
|
11820
|
+
)
|
|
11821
|
+
return openai_resp
|
|
11822
|
+
|
|
11786
11823
|
if not _is_unexpected_end_turn(openai_resp, anthropic_body):
|
|
11787
11824
|
return openai_resp
|
|
11788
11825
|
|
|
@@ -11877,6 +11914,15 @@ async def _apply_malformed_tool_guardrail(
|
|
|
11877
11914
|
logger.info("GUARDRAIL: finalize turn clean, no tool call XML detected")
|
|
11878
11915
|
return openai_resp
|
|
11879
11916
|
|
|
11917
|
+
# A tools-stripped breaker turn drops whatever the backend resurrects at
|
|
11918
|
+
# conversion time; repairing the resurrected call's arguments first would
|
|
11919
|
+
# burn a retry prefill perfecting a call that is about to be discarded.
|
|
11920
|
+
if monitor.suppress_text_tool_extraction:
|
|
11921
|
+
logger.info(
|
|
11922
|
+
"GUARDRAIL: skipped malformed-tool retry on a tools-stripped breaker turn"
|
|
11923
|
+
)
|
|
11924
|
+
return openai_resp
|
|
11925
|
+
|
|
11880
11926
|
working_resp = openai_resp
|
|
11881
11927
|
repair_count = 0
|
|
11882
11928
|
if PROXY_TOOL_ARGS_PREFLIGHT and _openai_has_tool_calls(openai_resp):
|
|
@@ -12922,6 +12968,41 @@ def openai_to_anthropic_response(
|
|
|
12922
12968
|
message = choice.get("message", {})
|
|
12923
12969
|
finish = choice.get("finish_reason", "stop")
|
|
12924
12970
|
|
|
12971
|
+
# Structured resurrection on a tools-stripped turn. The text-markup
|
|
12972
|
+
# suppression above only scrubs tool calls written into the TEXT body --
|
|
12973
|
+
# but the BACKEND can still parse the model's repeated <tool_call> into a
|
|
12974
|
+
# STRUCTURED tool_calls field even with an empty tools list (observed live
|
|
12975
|
+
# 2026-09-29 08:38, tabbyAPI: finish=tool_calls, text_len=0, the identical
|
|
12976
|
+
# ssh call re-served 30+ times at ~9 req/min; the client re-executed it
|
|
12977
|
+
# each pass and the loop never ended). On a suppressed turn there are, by
|
|
12978
|
+
# construction, no tools, so ANY structured call is resurrection: drop it,
|
|
12979
|
+
# force the finish back to prose, and let the fallback below carry the
|
|
12980
|
+
# reply when the model wrote nothing else.
|
|
12981
|
+
if suppress_text_tool_extraction and message.get("tool_calls"):
|
|
12982
|
+
dropped = message.pop("tool_calls") or []
|
|
12983
|
+
if finish in ("tool_calls", "function_call"):
|
|
12984
|
+
choice["finish_reason"] = "stop"
|
|
12985
|
+
finish = "stop"
|
|
12986
|
+
_suppressed_content = message.get("content")
|
|
12987
|
+
if _suppressed_content is None or (
|
|
12988
|
+
isinstance(_suppressed_content, str) and not _suppressed_content.strip()
|
|
12989
|
+
):
|
|
12990
|
+
message["content"] = STUCK_BREAK_PROSE_FALLBACK
|
|
12991
|
+
logger.warning(
|
|
12992
|
+
"STUCK-BREAK HARD: suppressed STRUCTURED tool_calls from the backend on "
|
|
12993
|
+
"a tools-stripped turn (%d call(s), e.g. %s) -- the turn now ends in "
|
|
12994
|
+
"prose as intended",
|
|
12995
|
+
len(dropped),
|
|
12996
|
+
next(
|
|
12997
|
+
(
|
|
12998
|
+
(c.get("function") or {}).get("name", "?")
|
|
12999
|
+
for c in dropped
|
|
13000
|
+
if isinstance(c, dict)
|
|
13001
|
+
),
|
|
13002
|
+
"?",
|
|
13003
|
+
),
|
|
13004
|
+
)
|
|
13005
|
+
|
|
12925
13006
|
content = []
|
|
12926
13007
|
# Surface Qwen's <think>...</think> output as Anthropic-style thinking
|
|
12927
13008
|
# blocks (Anthropic extended-thinking API shape:
|
|
@@ -1737,6 +1737,114 @@ class TestMalformedToolGuardrail(unittest.TestCase):
|
|
|
1737
1737
|
self.assertEqual(malformed, openai_resp)
|
|
1738
1738
|
self.assertEqual(len(fake_client.requests), 0)
|
|
1739
1739
|
|
|
1740
|
+
def test_guardrails_skip_tools_stripped_breaker_turn(self):
|
|
1741
|
+
# A STUCK-BREAK HARD turn ending in prose is the INTENDED exit, not an
|
|
1742
|
+
# anomaly. Live 2026-09-29 08:43:40 (ses_f18850847): the model finally
|
|
1743
|
+
# produced the prose exit and the unexpected_end_turn guardrail retried
|
|
1744
|
+
# it with tool_choice=required, re-coercing the very loop the breaker
|
|
1745
|
+
# exists to end. Both guardrails must yield on a suppressed turn.
|
|
1746
|
+
monitor = proxy.SessionMonitor(context_window=262144)
|
|
1747
|
+
monitor.suppress_text_tool_extraction = True
|
|
1748
|
+
monitor.tool_turn_phase = "act"
|
|
1749
|
+
|
|
1750
|
+
# Prose end_turn + an active tool loop: exactly the shape the
|
|
1751
|
+
# unexpected_end_turn guardrail would otherwise retry.
|
|
1752
|
+
openai_resp = {
|
|
1753
|
+
"choices": [
|
|
1754
|
+
{
|
|
1755
|
+
"finish_reason": "stop",
|
|
1756
|
+
"message": {
|
|
1757
|
+
"content": "The query keeps failing; I need operator input.",
|
|
1758
|
+
"tool_calls": [],
|
|
1759
|
+
},
|
|
1760
|
+
}
|
|
1761
|
+
]
|
|
1762
|
+
}
|
|
1763
|
+
# A garbled structured call: exactly the shape the malformed-tool
|
|
1764
|
+
# guardrail would otherwise spend a retry prefill repairing -- on a
|
|
1765
|
+
# turn where conversion drops the call anyway.
|
|
1766
|
+
garbled_resp = {
|
|
1767
|
+
"choices": [
|
|
1768
|
+
{
|
|
1769
|
+
"finish_reason": "tool_calls",
|
|
1770
|
+
"message": {
|
|
1771
|
+
"content": "",
|
|
1772
|
+
"tool_calls": [
|
|
1773
|
+
{
|
|
1774
|
+
"id": "call_1",
|
|
1775
|
+
"type": "function",
|
|
1776
|
+
"function": {
|
|
1777
|
+
"name": "Bash",
|
|
1778
|
+
"arguments": '{"command": "ssh root@db psq',
|
|
1779
|
+
},
|
|
1780
|
+
}
|
|
1781
|
+
],
|
|
1782
|
+
},
|
|
1783
|
+
}
|
|
1784
|
+
]
|
|
1785
|
+
}
|
|
1786
|
+
openai_body = {
|
|
1787
|
+
"model": "test",
|
|
1788
|
+
"tool_choice": "auto",
|
|
1789
|
+
"messages": [{"role": "user", "content": "continue"}],
|
|
1790
|
+
}
|
|
1791
|
+
anthropic_body = {
|
|
1792
|
+
"tools": [{"name": "Bash", "input_schema": {"type": "object"}}],
|
|
1793
|
+
"messages": [
|
|
1794
|
+
{"role": "user", "content": "start"},
|
|
1795
|
+
{
|
|
1796
|
+
"role": "assistant",
|
|
1797
|
+
"content": [
|
|
1798
|
+
{
|
|
1799
|
+
"type": "tool_use",
|
|
1800
|
+
"id": "toolu_1",
|
|
1801
|
+
"name": "Bash",
|
|
1802
|
+
"input": {"command": "pwd"},
|
|
1803
|
+
}
|
|
1804
|
+
],
|
|
1805
|
+
},
|
|
1806
|
+
{
|
|
1807
|
+
"role": "user",
|
|
1808
|
+
"content": [
|
|
1809
|
+
{
|
|
1810
|
+
"type": "tool_result",
|
|
1811
|
+
"tool_use_id": "toolu_1",
|
|
1812
|
+
"content": "ok",
|
|
1813
|
+
}
|
|
1814
|
+
],
|
|
1815
|
+
},
|
|
1816
|
+
],
|
|
1817
|
+
}
|
|
1818
|
+
fake_client = _FakeClient([_FakeResponse({"choices": []})])
|
|
1819
|
+
unexpected = asyncio.run(
|
|
1820
|
+
proxy._apply_unexpected_end_turn_guardrail(
|
|
1821
|
+
fake_client,
|
|
1822
|
+
openai_resp,
|
|
1823
|
+
openai_body,
|
|
1824
|
+
anthropic_body,
|
|
1825
|
+
monitor,
|
|
1826
|
+
"session-stuck-break",
|
|
1827
|
+
)
|
|
1828
|
+
)
|
|
1829
|
+
malformed = asyncio.run(
|
|
1830
|
+
proxy._apply_malformed_tool_guardrail(
|
|
1831
|
+
fake_client,
|
|
1832
|
+
garbled_resp,
|
|
1833
|
+
openai_body,
|
|
1834
|
+
anthropic_body,
|
|
1835
|
+
monitor,
|
|
1836
|
+
"session-stuck-break",
|
|
1837
|
+
)
|
|
1838
|
+
)
|
|
1839
|
+
self.assertEqual(unexpected, openai_resp)
|
|
1840
|
+
self.assertEqual(malformed, garbled_resp)
|
|
1841
|
+
self.assertEqual(
|
|
1842
|
+
malformed["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
|
|
1843
|
+
'{"command": "ssh root@db psq',
|
|
1844
|
+
"a suppressed turn must not repair a call it is about to drop",
|
|
1845
|
+
)
|
|
1846
|
+
self.assertEqual(len(fake_client.requests), 0)
|
|
1847
|
+
|
|
1740
1848
|
def test_unexpected_end_turn_guardrail_retries_review_auto_turn_in_active_loop(self):
|
|
1741
1849
|
monitor = proxy.SessionMonitor(context_window=262144)
|
|
1742
1850
|
monitor.tool_turn_phase = "review"
|
|
@@ -226,3 +226,67 @@ class TestStreakSurvivesAFreshMonitor(unittest.TestCase):
|
|
|
226
226
|
[{"role": "assistant", "content": "plain text"}]):
|
|
227
227
|
self.proxy._seed_tool_history_from_request(mon, bad)
|
|
228
228
|
self.assertEqual(mon.tool_call_history, [])
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class TestDirectiveQuotesTheActualOutput(unittest.TestCase):
|
|
232
|
+
"""Live 2026-09-29 (ses_f158cb463, qwen3.8): the advisory directive told the
|
|
233
|
+
model 'you already have that output' on a `uap memory query` returning "No
|
|
234
|
+
results" — and it was ignored 7 times, because a weak model does not
|
|
235
|
+
recognise an EMPTY result as an answer; it believes the missing value must
|
|
236
|
+
exist and keeps re-asking. The directive must SHOW the model the output it
|
|
237
|
+
keeps receiving, and say plainly that 'no results' IS the source's answer.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
@classmethod
|
|
241
|
+
def setUpClass(cls):
|
|
242
|
+
cls.proxy = load_proxy()
|
|
243
|
+
|
|
244
|
+
NO_RESULTS = "No results in short-term memory\n\nNo results in long-term memory"
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def looping_monitor(proxy, snippet):
|
|
248
|
+
mon = proxy.SessionMonitor(context_window=100000)
|
|
249
|
+
for _ in range(4):
|
|
250
|
+
mon.record_tool_calls(tool_names=["Bash"], fingerprint="Bash|uap memory query x")
|
|
251
|
+
mon.last_tool_result_snippet = snippet
|
|
252
|
+
return mon
|
|
253
|
+
|
|
254
|
+
def inject(self, mon):
|
|
255
|
+
body = {"messages": [{"role": "system", "content": "base"}], "tool_choice": "required"}
|
|
256
|
+
self.proxy._maybe_inject_stuck_break(body, mon)
|
|
257
|
+
return body["messages"][0]["content"]
|
|
258
|
+
|
|
259
|
+
def test_the_directive_quotes_the_repeated_calls_actual_output(self):
|
|
260
|
+
text = self.inject(self.looping_monitor(self.proxy, self.NO_RESULTS))
|
|
261
|
+
self.assertIn("No results in short-term memory", text)
|
|
262
|
+
self.assertIn("returns EXACTLY this every time", text)
|
|
263
|
+
# The trap named in words, not just implied by the quote.
|
|
264
|
+
self.assertIn("'no results' output IS this source's final answer", text)
|
|
265
|
+
|
|
266
|
+
def test_a_long_result_is_bounded_in_the_directive(self):
|
|
267
|
+
text = self.inject(self.looping_monitor(self.proxy, "x" * 5000))
|
|
268
|
+
self.assertLess(len(text), 2000, "a huge tool result must not bloat the directive")
|
|
269
|
+
|
|
270
|
+
def test_a_missing_snippet_keeps_the_directive_valid(self):
|
|
271
|
+
# No tool_result seen (e.g. the repeat was in the request seeds only):
|
|
272
|
+
# the directive must still fire, unquoted, and not crash.
|
|
273
|
+
text = self.inject(self.looping_monitor(self.proxy, ""))
|
|
274
|
+
self.assertIn("SUCCEEDED each time", text)
|
|
275
|
+
self.assertIn("will not change", text)
|
|
276
|
+
self.assertNotIn("returns EXACTLY this", text)
|
|
277
|
+
|
|
278
|
+
def test_the_snippet_is_captured_from_the_latest_tool_result_of_the_request(self):
|
|
279
|
+
# The capture path: the most recent tool_result text in the
|
|
280
|
+
# conversation becomes the monitor snippet, so the NEXT turn's
|
|
281
|
+
# directive quotes what the repeated call actually returned.
|
|
282
|
+
mon = self.proxy.SessionMonitor(context_window=100000)
|
|
283
|
+
msgs = [{"role": "user", "content": "find the slug"}]
|
|
284
|
+
for i in range(2):
|
|
285
|
+
msgs.append({"role": "assistant", "content": [
|
|
286
|
+
{"type": "tool_use", "id": f"t{i}", "name": "Bash",
|
|
287
|
+
"input": {"command": "uap memory query x"}}]})
|
|
288
|
+
msgs.append({"role": "user", "content": [
|
|
289
|
+
{"type": "tool_result", "tool_use_id": f"t{i}",
|
|
290
|
+
"content": self.NO_RESULTS}]})
|
|
291
|
+
self.proxy._record_last_assistant_tool_calls({"messages": msgs}, mon)
|
|
292
|
+
self.assertIn("No results in short-term memory", mon.last_tool_result_snippet)
|
|
@@ -413,3 +413,80 @@ class TestSuppressedTurnIsBuffered(unittest.TestCase):
|
|
|
413
413
|
self.skipTest("ambient proxy config already forces buffering here")
|
|
414
414
|
self.assertFalse(proxy._should_buffer_turn(True, body, openai_body, self._monitor(False)))
|
|
415
415
|
self.assertTrue(proxy._should_buffer_turn(True, body, openai_body, self._monitor(True)))
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class TestSuppressedStructuredToolCalls(unittest.TestCase):
|
|
419
|
+
"""The STRUCTURED half of the hard break (live incident 2026-09-29 08:38).
|
|
420
|
+
|
|
421
|
+
The text-markup suppression scrubs tool calls written into the text body,
|
|
422
|
+
but the BACKEND can still parse the model's repeated markup into a
|
|
423
|
+
STRUCTURED tool_calls field on a tools-stripped turn -- observed on tabbyAPI
|
|
424
|
+
as finish=tool_calls with an EMPTY text body, re-served 30+ times at ~9
|
|
425
|
+
req/min. The conversion promoted each one straight back to tool_use, the
|
|
426
|
+
client re-executed the identical ssh call, and the loop ran for hours. On a
|
|
427
|
+
suppressed turn there are by construction no tools, so ANY structured call
|
|
428
|
+
is resurrection and must be dropped.
|
|
429
|
+
"""
|
|
430
|
+
|
|
431
|
+
@staticmethod
|
|
432
|
+
def _resp(text="", finish="tool_calls"):
|
|
433
|
+
return {
|
|
434
|
+
"choices": [
|
|
435
|
+
{
|
|
436
|
+
"finish_reason": finish,
|
|
437
|
+
"message": {
|
|
438
|
+
"role": "assistant",
|
|
439
|
+
"content": text,
|
|
440
|
+
"tool_calls": [
|
|
441
|
+
{
|
|
442
|
+
"id": "call_1",
|
|
443
|
+
"type": "function",
|
|
444
|
+
"function": {
|
|
445
|
+
"name": "bash",
|
|
446
|
+
"arguments": '{"command": "ssh root@db psql"}',
|
|
447
|
+
},
|
|
448
|
+
}
|
|
449
|
+
],
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
],
|
|
453
|
+
"usage": {"prompt_tokens": 100, "completion_tokens": 0},
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
@staticmethod
|
|
457
|
+
def _text(out):
|
|
458
|
+
return "".join(b.get("text") or "" for b in out.get("content") or [])
|
|
459
|
+
|
|
460
|
+
def test_resurrected_call_is_dropped_and_turn_is_a_prose_end_turn(self):
|
|
461
|
+
out = proxy.openai_to_anthropic_response(
|
|
462
|
+
self._resp(), "qwen", suppress_text_tool_extraction=True
|
|
463
|
+
)
|
|
464
|
+
self.assertEqual(out["stop_reason"], "end_turn")
|
|
465
|
+
types = [b.get("type") for b in out["content"]]
|
|
466
|
+
self.assertNotIn("tool_use", types, "a suppressed turn must not carry tool_use")
|
|
467
|
+
|
|
468
|
+
def test_empty_body_gets_fallback_prose_not_a_blank_reply(self):
|
|
469
|
+
# The live incident shape: text_len=0, everything in tool_calls.
|
|
470
|
+
out = proxy.openai_to_anthropic_response(
|
|
471
|
+
self._resp(text=""), "qwen", suppress_text_tool_extraction=True
|
|
472
|
+
)
|
|
473
|
+
joined = self._text(out)
|
|
474
|
+
self.assertIn("repeated the same tool call", joined)
|
|
475
|
+
self.assertNotIn("ssh root@db psql", joined, "dropped args must not leak")
|
|
476
|
+
|
|
477
|
+
def test_model_prose_is_kept_when_the_resurrected_call_is_dropped(self):
|
|
478
|
+
out = proxy.openai_to_anthropic_response(
|
|
479
|
+
self._resp(text="The query keeps failing; I need operator input."),
|
|
480
|
+
"qwen",
|
|
481
|
+
suppress_text_tool_extraction=True,
|
|
482
|
+
)
|
|
483
|
+
self.assertIn("The query keeps failing", self._text(out))
|
|
484
|
+
|
|
485
|
+
def test_normal_turns_still_promote_structured_calls(self):
|
|
486
|
+
# The suppression must not leak onto the normal path, where serving a
|
|
487
|
+
# structured call is the whole point of the conversion.
|
|
488
|
+
out = proxy.openai_to_anthropic_response(
|
|
489
|
+
self._resp(), "qwen", suppress_text_tool_extraction=False
|
|
490
|
+
)
|
|
491
|
+
self.assertEqual(out["stop_reason"], "tool_use")
|
|
492
|
+
self.assertTrue(any(b.get("type") == "tool_use" for b in out["content"]))
|