@miller-tech/uap 1.61.1 → 1.61.2
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
CHANGED
|
Binary file
|
|
@@ -945,6 +945,13 @@ class SessionMonitor:
|
|
|
945
945
|
tool_cycle_counts: dict = field(default_factory=dict) # {tool_name: cycle_count} across resets
|
|
946
946
|
last_response_garbled: bool = False # previous turn had garbled/malformed output
|
|
947
947
|
finalize_turn_active: bool = False
|
|
948
|
+
# Set True for the single turn on which a hard finalize breaker (TURN-COUNT
|
|
949
|
+
# or CONTAMINATION LOOP) strips tools to force a terminal text-only end_turn.
|
|
950
|
+
# Suppresses response-side prose->tool_call resurrection so a contaminated
|
|
951
|
+
# model emitting `<function=...>`/`<tool_call>` prose does not get it promoted
|
|
952
|
+
# back into a structured tool_use, which would continue the very loop the
|
|
953
|
+
# breaker is ending. Reset to False at the start of every request.
|
|
954
|
+
suppress_text_tool_extraction: bool = False
|
|
948
955
|
finalize_continuation_count: int = 0
|
|
949
956
|
finalize_hard_stop_count: int = 0 # monotonic, not reset by fresh user text
|
|
950
957
|
finalize_synthetic_tool_id: str = ""
|
|
@@ -4052,6 +4059,10 @@ def build_openai_request(
|
|
|
4052
4059
|
})
|
|
4053
4060
|
openai_body["messages"] = msgs
|
|
4054
4061
|
monitor.reset_tool_turn_state(reason="turn_count_finalize_breaker")
|
|
4062
|
+
# Tools were stripped to force a terminal text summary; do not let
|
|
4063
|
+
# the response-side extractor resurrect prose tool-calls (which
|
|
4064
|
+
# would defeat the breaker and continue the loop).
|
|
4065
|
+
monitor.suppress_text_tool_extraction = True
|
|
4055
4066
|
logger.warning(
|
|
4056
4067
|
"TURN-COUNT FINALIZE BREAKER: %d agent tool turns >= ceiling %d "
|
|
4057
4068
|
"-- stripped tools to force terminal summary (end_turn).",
|
|
@@ -7084,6 +7095,11 @@ def _maybe_apply_session_contamination_breaker(
|
|
|
7084
7095
|
"Summarize what you have accomplished and what remains to be done."
|
|
7085
7096
|
),
|
|
7086
7097
|
})
|
|
7098
|
+
# Suppress prose->tool_call resurrection on this turn: the model is
|
|
7099
|
+
# contaminated and will emit `<function=...>`/`<tool_call>` prose even
|
|
7100
|
+
# with tools removed; promoting it back to a structured tool_use would
|
|
7101
|
+
# continue the exact loop this finalize is meant to break.
|
|
7102
|
+
monitor.suppress_text_tool_extraction = True
|
|
7087
7103
|
return updated
|
|
7088
7104
|
|
|
7089
7105
|
messages = anthropic_body.get("messages", [])
|
|
@@ -7210,7 +7226,9 @@ def _maybe_apply_session_contamination_breaker(
|
|
|
7210
7226
|
|
|
7211
7227
|
|
|
7212
7228
|
def _maybe_extract_text_tool_calls(
|
|
7213
|
-
openai_resp: dict,
|
|
7229
|
+
openai_resp: dict,
|
|
7230
|
+
anthropic_tools: list[dict] | None = None,
|
|
7231
|
+
suppress: bool = False,
|
|
7214
7232
|
) -> dict:
|
|
7215
7233
|
"""Mutate *openai_resp* in-place: if the message has no structured
|
|
7216
7234
|
``tool_calls`` but contains tool-call markup in text, extract them
|
|
@@ -7222,6 +7240,11 @@ def _maybe_extract_text_tool_calls(
|
|
|
7222
7240
|
blocks pass through as text.
|
|
7223
7241
|
|
|
7224
7242
|
Returns the (possibly-mutated) response for chaining."""
|
|
7243
|
+
# A hard finalize breaker stripped tools this turn to force a terminal
|
|
7244
|
+
# text-only end_turn; do not resurrect prose tool-calls (that would defeat
|
|
7245
|
+
# the breaker and continue the loop). Carried per-turn on the SessionMonitor.
|
|
7246
|
+
if suppress:
|
|
7247
|
+
return openai_resp
|
|
7225
7248
|
choice = (openai_resp.get("choices") or [{}])[0]
|
|
7226
7249
|
message = choice.get("message", {})
|
|
7227
7250
|
|
|
@@ -7427,7 +7450,10 @@ def _extract_thinking_block(text: str) -> tuple[str | None, str]:
|
|
|
7427
7450
|
|
|
7428
7451
|
|
|
7429
7452
|
def openai_to_anthropic_response(
|
|
7430
|
-
openai_resp: dict,
|
|
7453
|
+
openai_resp: dict,
|
|
7454
|
+
model: str,
|
|
7455
|
+
expose_thinking: bool = True,
|
|
7456
|
+
suppress_text_tool_extraction: bool = False,
|
|
7431
7457
|
) -> dict:
|
|
7432
7458
|
"""Convert an OpenAI Chat Completions response to Anthropic Messages format.
|
|
7433
7459
|
|
|
@@ -7441,7 +7467,7 @@ def openai_to_anthropic_response(
|
|
|
7441
7467
|
they're surfaced as Anthropic blocks or silently consumed.
|
|
7442
7468
|
"""
|
|
7443
7469
|
# First: try to recover tool calls trapped in text XML tags
|
|
7444
|
-
_maybe_extract_text_tool_calls(openai_resp)
|
|
7470
|
+
_maybe_extract_text_tool_calls(openai_resp, suppress=suppress_text_tool_extraction)
|
|
7445
7471
|
# Second: strip garbled/degenerate tool call arguments
|
|
7446
7472
|
_sanitize_garbled_tool_calls(openai_resp)
|
|
7447
7473
|
|
|
@@ -7837,7 +7863,11 @@ async def stream_anthropic_response(
|
|
|
7837
7863
|
# -------------------------------------------------------------------
|
|
7838
7864
|
# Post-stream: recover <tool_call> XML from accumulated text
|
|
7839
7865
|
# -------------------------------------------------------------------
|
|
7840
|
-
if
|
|
7866
|
+
if (
|
|
7867
|
+
not tool_calls_by_index
|
|
7868
|
+
and "<tool_call>" in accumulated_text
|
|
7869
|
+
and not monitor.suppress_text_tool_extraction
|
|
7870
|
+
):
|
|
7841
7871
|
xml_extracted, remaining_text = _extract_tool_calls_from_text(accumulated_text)
|
|
7842
7872
|
if xml_extracted:
|
|
7843
7873
|
# We already streamed the text as-is. We cannot un-stream it,
|
|
@@ -8045,6 +8075,10 @@ async def messages(request: Request):
|
|
|
8045
8075
|
return await _passthrough_anthropic_request(request, body, is_stream)
|
|
8046
8076
|
session_id = resolve_session_id(request, body)
|
|
8047
8077
|
monitor = get_session_monitor(session_id)
|
|
8078
|
+
# Per-turn flag: only the turn whose breaker strips tools suppresses the
|
|
8079
|
+
# response-side prose->tool_call resurrection. Clear it at request entry so a
|
|
8080
|
+
# prior finalize turn never bleeds into the next turn's normal extraction.
|
|
8081
|
+
monitor.suppress_text_tool_extraction = False
|
|
8048
8082
|
last_session_id = session_id
|
|
8049
8083
|
# Make the session id visible to _ensure_slot_for_session inside
|
|
8050
8084
|
# _post_with_retry. The /v1/chat/completions handler also reaches this
|
|
@@ -8358,7 +8392,11 @@ async def messages(request: Request):
|
|
|
8358
8392
|
|
|
8359
8393
|
openai_resp = strict_resp.json()
|
|
8360
8394
|
# Recover tool calls from <tool_call> XML before guardrails run
|
|
8361
|
-
_maybe_extract_text_tool_calls(
|
|
8395
|
+
_maybe_extract_text_tool_calls(
|
|
8396
|
+
openai_resp,
|
|
8397
|
+
anthropic_tools=body.get("tools"),
|
|
8398
|
+
suppress=monitor.suppress_text_tool_extraction,
|
|
8399
|
+
)
|
|
8362
8400
|
openai_resp = await _apply_unexpected_end_turn_guardrail(
|
|
8363
8401
|
client,
|
|
8364
8402
|
openai_resp,
|
|
@@ -8417,6 +8455,7 @@ async def messages(request: Request):
|
|
|
8417
8455
|
openai_resp, model,
|
|
8418
8456
|
expose_thinking=isinstance(body.get("thinking"), dict)
|
|
8419
8457
|
and (body["thinking"].get("type") or "").lower() == "enabled",
|
|
8458
|
+
suppress_text_tool_extraction=monitor.suppress_text_tool_extraction,
|
|
8420
8459
|
)
|
|
8421
8460
|
_maybe_normalize_toolcall_paths(anthropic_resp, body)
|
|
8422
8461
|
# FINALIZE CONTINUATION: inject synthetic tool_use to keep client loop alive
|
|
@@ -8758,7 +8797,11 @@ async def messages(request: Request):
|
|
|
8758
8797
|
|
|
8759
8798
|
openai_resp = resp.json()
|
|
8760
8799
|
# Recover tool calls from <tool_call> XML before guardrails run
|
|
8761
|
-
_maybe_extract_text_tool_calls(
|
|
8800
|
+
_maybe_extract_text_tool_calls(
|
|
8801
|
+
openai_resp,
|
|
8802
|
+
anthropic_tools=body.get("tools"),
|
|
8803
|
+
suppress=monitor.suppress_text_tool_extraction,
|
|
8804
|
+
)
|
|
8762
8805
|
openai_resp = await _apply_unexpected_end_turn_guardrail(
|
|
8763
8806
|
client,
|
|
8764
8807
|
openai_resp,
|
|
@@ -8831,6 +8874,7 @@ async def messages(request: Request):
|
|
|
8831
8874
|
openai_resp, model,
|
|
8832
8875
|
expose_thinking=isinstance(body.get("thinking"), dict)
|
|
8833
8876
|
and (body["thinking"].get("type") or "").lower() == "enabled",
|
|
8877
|
+
suppress_text_tool_extraction=monitor.suppress_text_tool_extraction,
|
|
8834
8878
|
)
|
|
8835
8879
|
_maybe_normalize_toolcall_paths(anthropic_resp, body)
|
|
8836
8880
|
# FINALIZE CONTINUATION: inject synthetic tool_use (non-guarded stream path)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for the gated finalize-suppression fix.
|
|
3
|
+
|
|
4
|
+
When a hard finalize breaker (TURN-COUNT FINALIZE BREAKER or SESSION
|
|
5
|
+
CONTAMINATION LOOP) deliberately strips tools to force a terminal text-only
|
|
6
|
+
``end_turn``, the response-side prose->tool_call resurrection must be suppressed.
|
|
7
|
+
Otherwise a contaminated model that emits ``<function=...>`` / ``<tool_call>``
|
|
8
|
+
prose has it promoted back into a structured ``tool_use``, the client executes
|
|
9
|
+
it, and the very loop the breaker meant to end continues.
|
|
10
|
+
|
|
11
|
+
The suppression is carried per-turn on the SessionMonitor
|
|
12
|
+
(``suppress_text_tool_extraction``): the breakers set it True; the extractor and
|
|
13
|
+
the post-stream recovery honor it; the request handler clears it at the start of
|
|
14
|
+
every turn so a finalize turn never bleeds into the next turn's normal flow.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import importlib.util
|
|
19
|
+
import json
|
|
20
|
+
import unittest
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_proxy_module():
|
|
25
|
+
proxy_path = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
26
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy", proxy_path)
|
|
27
|
+
assert spec is not None and spec.loader is not None
|
|
28
|
+
module = importlib.util.module_from_spec(spec)
|
|
29
|
+
spec.loader.exec_module(module)
|
|
30
|
+
return module
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
proxy = _load_proxy_module()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _prose_tool_resp():
|
|
37
|
+
"""OpenAI response: message has NO structured tool_calls but DOES contain a
|
|
38
|
+
Hermes ``<function=...>`` prose tool call (the contamination output)."""
|
|
39
|
+
return {
|
|
40
|
+
"choices": [
|
|
41
|
+
{
|
|
42
|
+
"message": {
|
|
43
|
+
"role": "assistant",
|
|
44
|
+
"content": "<function=Bash>\n<parameter=command>ls</parameter>\n</function>",
|
|
45
|
+
},
|
|
46
|
+
"finish_reason": "stop",
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _FakeOpenAIStream:
|
|
53
|
+
"""Minimal stand-in for an httpx streaming response: yields the queued
|
|
54
|
+
``data: {...}`` SSE lines that stream_anthropic_response consumes."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, lines):
|
|
57
|
+
self._lines = list(lines)
|
|
58
|
+
|
|
59
|
+
async def aiter_lines(self):
|
|
60
|
+
for line in self._lines:
|
|
61
|
+
yield line
|
|
62
|
+
|
|
63
|
+
async def aclose(self):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _tool_call_text_stream():
|
|
68
|
+
"""An upstream stream whose text delta embeds a ``<tool_call>`` payload and
|
|
69
|
+
that carries NO structured tool_calls — the prose the post-stream recovery
|
|
70
|
+
would otherwise resurrect."""
|
|
71
|
+
text_delta = {
|
|
72
|
+
"choices": [
|
|
73
|
+
{
|
|
74
|
+
"delta": {
|
|
75
|
+
"content": '<tool_call>{"name":"Bash","arguments":{"command":"ls"}}</tool_call>'
|
|
76
|
+
},
|
|
77
|
+
"finish_reason": None,
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
final = {"choices": [{"delta": {}, "finish_reason": "stop"}]}
|
|
82
|
+
return _FakeOpenAIStream(
|
|
83
|
+
[f"data: {json.dumps(text_delta)}", f"data: {json.dumps(final)}", "data: [DONE]"]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def _drain(agen):
|
|
88
|
+
return [chunk async for chunk in agen]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestFinalizeSuppressionExtractor(unittest.TestCase):
|
|
92
|
+
def test_baseline_promotes_prose_tool_call(self):
|
|
93
|
+
"""suppress=False: the load-bearing parser still resurrects the prose
|
|
94
|
+
tool call (guards against the fix over-suppressing normal turns)."""
|
|
95
|
+
resp = _prose_tool_resp()
|
|
96
|
+
proxy._maybe_extract_text_tool_calls(resp)
|
|
97
|
+
msg = resp["choices"][0]["message"]
|
|
98
|
+
self.assertTrue(msg.get("tool_calls"), "expected prose call to be promoted")
|
|
99
|
+
self.assertEqual(msg["tool_calls"][0]["function"]["name"], "Bash")
|
|
100
|
+
self.assertEqual(resp["choices"][0]["finish_reason"], "tool_calls")
|
|
101
|
+
|
|
102
|
+
def test_suppressed_leaves_prose_as_text(self):
|
|
103
|
+
"""suppress=True: prose stays text -> the client sees a clean end_turn
|
|
104
|
+
with no action, so the agentic loop terminates."""
|
|
105
|
+
resp = _prose_tool_resp()
|
|
106
|
+
proxy._maybe_extract_text_tool_calls(resp, suppress=True)
|
|
107
|
+
msg = resp["choices"][0]["message"]
|
|
108
|
+
self.assertFalse(msg.get("tool_calls"), "must NOT resurrect tool call on finalize")
|
|
109
|
+
self.assertIn("<function=Bash>", msg["content"])
|
|
110
|
+
self.assertEqual(resp["choices"][0]["finish_reason"], "stop")
|
|
111
|
+
|
|
112
|
+
def test_conversion_respects_suppression(self):
|
|
113
|
+
"""openai_to_anthropic_response forwards suppression to its internal
|
|
114
|
+
extraction call (the 7444 site that lacks a monitor argument)."""
|
|
115
|
+
anthro = proxy.openai_to_anthropic_response(
|
|
116
|
+
_prose_tool_resp(), "m", suppress_text_tool_extraction=True
|
|
117
|
+
)
|
|
118
|
+
self.assertFalse(
|
|
119
|
+
any(b.get("type") == "tool_use" for b in anthro.get("content", [])),
|
|
120
|
+
"conversion must not resurrect a tool_use when suppressed",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class TestStreamingRecoverySuppression(unittest.TestCase):
|
|
125
|
+
def _run(self, suppress):
|
|
126
|
+
monitor = proxy.SessionMonitor(context_window=100000)
|
|
127
|
+
monitor.suppress_text_tool_extraction = suppress
|
|
128
|
+
agen = proxy.stream_anthropic_response(_tool_call_text_stream(), "test-model", monitor, {})
|
|
129
|
+
return "".join(asyncio.run(_drain(agen)))
|
|
130
|
+
|
|
131
|
+
def test_baseline_streaming_recovers_tool_use(self):
|
|
132
|
+
self.assertIn(
|
|
133
|
+
'"type": "tool_use"', self._run(False),
|
|
134
|
+
"post-stream recovery should fire when not suppressed",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def test_suppressed_streaming_does_not_recover_tool_use(self):
|
|
138
|
+
self.assertNotIn(
|
|
139
|
+
'"type": "tool_use"', self._run(True),
|
|
140
|
+
"must NOT recover tool_use on finalize",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TestContaminationBreakerSetsSuppression(unittest.TestCase):
|
|
145
|
+
def _monitor(self):
|
|
146
|
+
return proxy.SessionMonitor(context_window=100000)
|
|
147
|
+
|
|
148
|
+
def test_forcing_finalize_sets_suppression_and_strips_tools(self):
|
|
149
|
+
"""The terminal forcing-finalize branch (resets >= max) strips tools AND
|
|
150
|
+
sets the per-turn suppression flag on the monitor."""
|
|
151
|
+
m = self._monitor()
|
|
152
|
+
m.malformed_tool_streak = proxy.PROXY_SESSION_CONTAMINATION_THRESHOLD
|
|
153
|
+
m.contamination_resets = 3 # >= max (3) -> forcing-finalize branch
|
|
154
|
+
body = {
|
|
155
|
+
"messages": [{"role": "user", "content": "go"}],
|
|
156
|
+
"tools": [{"name": "Bash"}],
|
|
157
|
+
"tool_choice": "auto",
|
|
158
|
+
}
|
|
159
|
+
out = proxy._maybe_apply_session_contamination_breaker(body, m, "sess-finalize")
|
|
160
|
+
self.assertNotIn("tools", out)
|
|
161
|
+
self.assertNotIn("tool_choice", out)
|
|
162
|
+
self.assertTrue(m.suppress_text_tool_extraction, "forcing-finalize must set suppression")
|
|
163
|
+
|
|
164
|
+
def test_standard_reset_does_not_suppress(self):
|
|
165
|
+
"""A normal (non-terminal) contamination reset keeps tools and must NOT
|
|
166
|
+
suppress extraction — the model should still be able to recover."""
|
|
167
|
+
m = self._monitor()
|
|
168
|
+
m.malformed_tool_streak = proxy.PROXY_SESSION_CONTAMINATION_THRESHOLD
|
|
169
|
+
m.contamination_resets = 0 # below max -> standard reset path
|
|
170
|
+
msgs = [{"role": "user", "content": "go"}]
|
|
171
|
+
msgs += [{"role": "assistant", "content": f"turn {i}"} for i in range(12)]
|
|
172
|
+
body = {"messages": msgs, "tools": [{"name": "Bash"}]}
|
|
173
|
+
proxy._maybe_apply_session_contamination_breaker(body, m, "sess-standard")
|
|
174
|
+
self.assertFalse(m.suppress_text_tool_extraction, "standard reset must NOT suppress")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
unittest.main()
|