@miller-tech/uap 1.148.5 → 1.148.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/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +2 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +6 -1
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/verify.d.ts +11 -0
- package/dist/cli/verify.d.ts.map +1 -1
- package/dist/cli/verify.js +64 -0
- package/dist/cli/verify.js.map +1 -1
- package/dist/delivery/user-validation.d.ts +8 -0
- package/dist/delivery/user-validation.d.ts.map +1 -1
- package/dist/delivery/user-validation.js +22 -1
- package/dist/delivery/user-validation.js.map +1 -1
- package/dist/delivery/vision-judge.d.ts.map +1 -1
- package/dist/delivery/vision-judge.js +40 -15
- package/dist/delivery/vision-judge.js.map +1 -1
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/templates/hooks/stop.sh +9 -1
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +68 -0
- package/tools/agents/tests/test_empty_tool_loop_break.py +69 -0
|
@@ -729,6 +729,20 @@ PROXY_RECON_HARD_MULTIPLIER = max(1.0, float(
|
|
|
729
729
|
PROXY_RECON_SESSION_HARD_CAP = int(
|
|
730
730
|
os.environ.get("PROXY_RECON_SESSION_HARD_CAP", "3")
|
|
731
731
|
)
|
|
732
|
+
# Empty-tool-loop breaker: under a forced tool_choice='required' (e.g. the recon
|
|
733
|
+
# guardrail, or the first-turn/active-loop force), a model that has nothing valid
|
|
734
|
+
# to call can return a FULLY-EMPTY response (0 output tokens, no text, no tool
|
|
735
|
+
# call — logged as `finish=tool_use output_tokens=0`). It cannot say "I'm stuck /
|
|
736
|
+
# that file is missing" because the force forbids prose, so it spins: empty ->
|
|
737
|
+
# guardrail re-forces -> empty. That reads as a frozen session (observed live:
|
|
738
|
+
# 16 empties in 10 min on a project whose deliverable dir had been deleted). After
|
|
739
|
+
# this many CONSECUTIVE fully-empty responses, release tool_choice to 'auto' for
|
|
740
|
+
# the next turn and inject a directive telling the model to answer in PLAIN TEXT
|
|
741
|
+
# (state the blocker / ask the user), converting a silent spin into a recoverable
|
|
742
|
+
# ask. 0 disables.
|
|
743
|
+
PROXY_EMPTY_TOOL_LOOP_BREAK = int(
|
|
744
|
+
os.environ.get("PROXY_EMPTY_TOOL_LOOP_BREAK", "3")
|
|
745
|
+
)
|
|
732
746
|
# Fix F: context death-spiral breaker. When the *raw* (pre-prune) incoming
|
|
733
747
|
# context stays catastrophically over the window for several consecutive turns,
|
|
734
748
|
# releasing tool_choice to 'auto' (Fix B / LOOP BREAKER) is NOT enough — the
|
|
@@ -1410,6 +1424,7 @@ class SessionMonitor:
|
|
|
1410
1424
|
loop_warnings_emitted: int = 0 # How many loop warnings sent to the model
|
|
1411
1425
|
no_progress_streak: int = 0 # Forced tool turns without new tool_result
|
|
1412
1426
|
consecutive_no_write_turns: int = 0 # turns exploring with no write tool (B1)
|
|
1427
|
+
consecutive_empty_tool_turns: int = 0 # fully-empty responses (empty-tool loop breaker)
|
|
1413
1428
|
recon_hard_fires: int = 0 # Fix E: monotonic count of recon hard-tier firings
|
|
1414
1429
|
catastrophic_ctx_streak: int = 0 # Fix F: consecutive turns raw ctx >= finalize ratio
|
|
1415
1430
|
unexpected_end_turn_count: int = 0 # end_turn without tool_use in active loop
|
|
@@ -5192,6 +5207,44 @@ def _maybe_inject_mandate_deliver(openai_body: dict, monitor: "SessionMonitor")
|
|
|
5192
5207
|
)
|
|
5193
5208
|
|
|
5194
5209
|
|
|
5210
|
+
def _maybe_break_empty_tool_loop(openai_body: dict, monitor: "SessionMonitor") -> None:
|
|
5211
|
+
"""Release a forced tool_choice after repeated FULLY-EMPTY responses.
|
|
5212
|
+
|
|
5213
|
+
When tool_choice is forced ('required') and the model has nothing valid to
|
|
5214
|
+
call, it can return empty (0 tokens, no text, no tool call) — and it cannot
|
|
5215
|
+
say why, because the force forbids prose. That empties-loop is a silent spin
|
|
5216
|
+
(looks like a frozen session). After PROXY_EMPTY_TOOL_LOOP_BREAK consecutive
|
|
5217
|
+
empties, release tool_choice to 'auto' and inject a plain-text directive so
|
|
5218
|
+
the model can state the blocker or ask the user, turning the spin into a
|
|
5219
|
+
recoverable ask. Resets the streak so it fires once per window, not every turn.
|
|
5220
|
+
"""
|
|
5221
|
+
if PROXY_EMPTY_TOOL_LOOP_BREAK <= 0:
|
|
5222
|
+
return
|
|
5223
|
+
if monitor.consecutive_empty_tool_turns < PROXY_EMPTY_TOOL_LOOP_BREAK:
|
|
5224
|
+
return
|
|
5225
|
+
n = monitor.consecutive_empty_tool_turns
|
|
5226
|
+
# Release the force so prose is possible this turn.
|
|
5227
|
+
if openai_body.get("tool_choice") == "required":
|
|
5228
|
+
openai_body["tool_choice"] = "auto"
|
|
5229
|
+
openai_body.pop("grammar", None)
|
|
5230
|
+
directive = (
|
|
5231
|
+
f"You have returned {n} empty responses in a row — you are stuck and a tool "
|
|
5232
|
+
"call is being forced, but you cannot produce a valid one. STOP trying to "
|
|
5233
|
+
"call a tool this turn. Reply in PLAIN TEXT: state exactly what is blocking "
|
|
5234
|
+
"you (for example, a file or directory you expected does not exist), and ask "
|
|
5235
|
+
"the user how they want to proceed. Do NOT call any tool this turn."
|
|
5236
|
+
)
|
|
5237
|
+
msgs = openai_body.get("messages", [])
|
|
5238
|
+
msgs.append({"role": "user", "content": directive})
|
|
5239
|
+
openai_body["messages"] = msgs
|
|
5240
|
+
monitor.consecutive_empty_tool_turns = 0 # one-shot per window; don't force-release forever
|
|
5241
|
+
logger.warning(
|
|
5242
|
+
"EMPTY-TOOL LOOP BREAK: %d consecutive empty responses -> released "
|
|
5243
|
+
"tool_choice->auto + plain-text directive so the model can report the blocker",
|
|
5244
|
+
n,
|
|
5245
|
+
)
|
|
5246
|
+
|
|
5247
|
+
|
|
5195
5248
|
def _maybe_inject_recon_convergence(
|
|
5196
5249
|
openai_body: dict,
|
|
5197
5250
|
monitor: "SessionMonitor",
|
|
@@ -6022,6 +6075,13 @@ def build_openai_request(
|
|
|
6022
6075
|
# into a concrete action. Runs last so it can yield to stuck-break.
|
|
6023
6076
|
_maybe_inject_deferral_break(openai_body, monitor)
|
|
6024
6077
|
|
|
6078
|
+
# EMPTY-TOOL LOOP BREAK: after repeated FULLY-EMPTY responses under a forced
|
|
6079
|
+
# tool_choice, release the force + inject a plain-text directive so the model
|
|
6080
|
+
# can report the blocker (e.g. a deleted deliverable) instead of spinning.
|
|
6081
|
+
# Runs last among the message/tool_choice guards so its release is final —
|
|
6082
|
+
# the other guards here only ever FORCE, and this is the escape hatch.
|
|
6083
|
+
_maybe_break_empty_tool_loop(openai_body, monitor)
|
|
6084
|
+
|
|
6025
6085
|
_apply_thinking_grammar(openai_body)
|
|
6026
6086
|
|
|
6027
6087
|
_apply_json_response_grammar(openai_body, anthropic_body)
|
|
@@ -10082,6 +10142,14 @@ async def stream_anthropic_response(
|
|
|
10082
10142
|
# DEFERRAL-BREAK signal (Fix A): a no-tool prose turn that defers the
|
|
10083
10143
|
# work. `tc_names` empty means this turn emitted no tool call.
|
|
10084
10144
|
monitor.note_deferral_signal(accumulated_text, bool(tc_names))
|
|
10145
|
+
# EMPTY-TOOL-LOOP signal: a fully-empty response — no text, no tool call,
|
|
10146
|
+
# zero output tokens (typically `finish=tool_use` under a forced
|
|
10147
|
+
# tool_choice the model can't satisfy). Streak it so the next request can
|
|
10148
|
+
# release the force and let the model speak. Any non-empty turn resets it.
|
|
10149
|
+
_empty_resp = (output_tokens == 0) and (len(accumulated_text) == 0) and (not tc_names)
|
|
10150
|
+
monitor.consecutive_empty_tool_turns = (
|
|
10151
|
+
monitor.consecutive_empty_tool_turns + 1 if _empty_resp else 0
|
|
10152
|
+
)
|
|
10085
10153
|
except Exception:
|
|
10086
10154
|
pass
|
|
10087
10155
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Empty-tool-loop breaker: after N consecutive fully-empty responses under a
|
|
3
|
+
forced tool_choice, release the force + inject a plain-text directive so a stuck
|
|
4
|
+
model can report the blocker (e.g. a deleted deliverable) instead of spinning."""
|
|
5
|
+
import importlib.util
|
|
6
|
+
import os
|
|
7
|
+
import unittest
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load_proxy():
|
|
12
|
+
os.environ.setdefault("PROXY_EMPTY_TOOL_LOOP_BREAK", "3")
|
|
13
|
+
p = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
14
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy", p)
|
|
15
|
+
m = importlib.util.module_from_spec(spec)
|
|
16
|
+
spec.loader.exec_module(m)
|
|
17
|
+
return m
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
proxy = _load_proxy()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def forced_body():
|
|
24
|
+
return {"tool_choice": "required", "messages": [{"role": "user", "content": "go"}]}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EmptyToolLoopBreakTests(unittest.TestCase):
|
|
28
|
+
def setUp(self):
|
|
29
|
+
self.m = proxy.SessionMonitor()
|
|
30
|
+
|
|
31
|
+
def test_no_op_below_threshold(self):
|
|
32
|
+
body = forced_body()
|
|
33
|
+
self.m.consecutive_empty_tool_turns = proxy.PROXY_EMPTY_TOOL_LOOP_BREAK - 1
|
|
34
|
+
proxy._maybe_break_empty_tool_loop(body, self.m)
|
|
35
|
+
self.assertEqual(body["tool_choice"], "required") # force intact
|
|
36
|
+
self.assertEqual(len(body["messages"]), 1) # no directive injected
|
|
37
|
+
|
|
38
|
+
def test_releases_and_directs_at_threshold(self):
|
|
39
|
+
body = forced_body()
|
|
40
|
+
self.m.consecutive_empty_tool_turns = proxy.PROXY_EMPTY_TOOL_LOOP_BREAK
|
|
41
|
+
proxy._maybe_break_empty_tool_loop(body, self.m)
|
|
42
|
+
self.assertEqual(body["tool_choice"], "auto") # force released → prose possible
|
|
43
|
+
self.assertEqual(len(body["messages"]), 2) # plain-text directive appended
|
|
44
|
+
self.assertIn("PLAIN TEXT", body["messages"][-1]["content"])
|
|
45
|
+
self.assertEqual(self.m.consecutive_empty_tool_turns, 0) # one-shot per window
|
|
46
|
+
|
|
47
|
+
def test_disabled_when_threshold_zero(self):
|
|
48
|
+
body = forced_body()
|
|
49
|
+
old = proxy.PROXY_EMPTY_TOOL_LOOP_BREAK
|
|
50
|
+
try:
|
|
51
|
+
proxy.PROXY_EMPTY_TOOL_LOOP_BREAK = 0
|
|
52
|
+
self.m.consecutive_empty_tool_turns = 99
|
|
53
|
+
proxy._maybe_break_empty_tool_loop(body, self.m)
|
|
54
|
+
self.assertEqual(body["tool_choice"], "required") # disabled → no-op
|
|
55
|
+
finally:
|
|
56
|
+
proxy.PROXY_EMPTY_TOOL_LOOP_BREAK = old
|
|
57
|
+
|
|
58
|
+
def test_leaves_unforced_tool_choice_alone_but_still_directs(self):
|
|
59
|
+
# If tool_choice isn't 'required' (already auto), don't clobber it, but
|
|
60
|
+
# still surface the directive so the model breaks the empty streak.
|
|
61
|
+
body = {"tool_choice": "auto", "messages": [{"role": "user", "content": "go"}]}
|
|
62
|
+
self.m.consecutive_empty_tool_turns = proxy.PROXY_EMPTY_TOOL_LOOP_BREAK
|
|
63
|
+
proxy._maybe_break_empty_tool_loop(body, self.m)
|
|
64
|
+
self.assertEqual(body["tool_choice"], "auto")
|
|
65
|
+
self.assertEqual(len(body["messages"]), 2)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
unittest.main()
|