@miller-tech/uap 1.220.9 → 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/dist/.tsbuildinfo +1 -1
- package/dist/delivery/agentic-executor.d.ts +63 -0
- package/dist/delivery/agentic-executor.d.ts.map +1 -1
- package/dist/delivery/agentic-executor.js +166 -18
- package/dist/delivery/agentic-executor.js.map +1 -1
- 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
|
@@ -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)))
|