@miller-tech/uap 1.20.49 → 1.20.50
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
|
@@ -207,6 +207,19 @@ _READ_ONLY_TOOL_CLASS = frozenset({
|
|
|
207
207
|
"search", "Search", "list_files", "ListFiles",
|
|
208
208
|
})
|
|
209
209
|
|
|
210
|
+
# Tools that produce or mutate a deliverable. Using any of these in a turn
|
|
211
|
+
# means the agent is converging from exploration toward output, and resets
|
|
212
|
+
# the recon-convergence streak (B1). This is deliberately a SHORT allowlist
|
|
213
|
+
# of write tools, NOT a read-only denylist: exploration happens through an
|
|
214
|
+
# open-ended set of tools (Bash, WebFetch, Agent, ...) that cannot be
|
|
215
|
+
# enumerated, but "the agent produced a write" is a small, stable signal.
|
|
216
|
+
# Names are matched case-insensitively (callers lower() before lookup).
|
|
217
|
+
_WRITE_TOOL_CLASS = frozenset({
|
|
218
|
+
"write", "edit", "multiedit", "notebookedit",
|
|
219
|
+
"str_replace", "str_replace_editor", "str_replace_based_edit_tool",
|
|
220
|
+
"create_file", "applypatch", "apply_patch",
|
|
221
|
+
})
|
|
222
|
+
|
|
210
223
|
PROXY_GUARDRAIL_RETRY = os.environ.get("PROXY_GUARDRAIL_RETRY", "on").lower() not in {
|
|
211
224
|
"0",
|
|
212
225
|
"false",
|
|
@@ -224,12 +237,15 @@ PROXY_FINALIZE_CONTINUATION_MAX = int(
|
|
|
224
237
|
PROXY_FINALIZE_SESSION_HARD_CAP = int(
|
|
225
238
|
os.environ.get("PROXY_FINALIZE_SESSION_HARD_CAP", "3")
|
|
226
239
|
)
|
|
227
|
-
# Recon-convergence guardrail: after this many consecutive turns
|
|
228
|
-
#
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
240
|
+
# Recon-convergence guardrail: after this many consecutive turns that use
|
|
241
|
+
# tools but produce NO write/deliverable tool call (see _WRITE_TOOL_CLASS),
|
|
242
|
+
# the proxy injects a directive telling the model to stop exploring and
|
|
243
|
+
# produce its deliverable. Targets the failure mode where an agentic recon
|
|
244
|
+
# task explores for hundreds of turns and never converges to the
|
|
232
245
|
# synthesis/write step (observed: 664-turn recon, no deliverable started).
|
|
246
|
+
# Defined as write-tool ABSENCE rather than read-tool presence: a real
|
|
247
|
+
# recon agent explores via Bash/WebFetch/Agent, not just Read/Grep, so a
|
|
248
|
+
# "all tools are recognized read-only" test never accumulates a streak.
|
|
233
249
|
# 0 disables.
|
|
234
250
|
PROXY_RECON_CONVERGENCE_THRESHOLD = int(
|
|
235
251
|
os.environ.get("PROXY_RECON_CONVERGENCE_THRESHOLD", "40")
|
|
@@ -727,7 +743,7 @@ class SessionMonitor:
|
|
|
727
743
|
)
|
|
728
744
|
loop_warnings_emitted: int = 0 # How many loop warnings sent to the model
|
|
729
745
|
no_progress_streak: int = 0 # Forced tool turns without new tool_result
|
|
730
|
-
|
|
746
|
+
consecutive_no_write_turns: int = 0 # turns exploring with no write tool (B1)
|
|
731
747
|
unexpected_end_turn_count: int = 0 # end_turn without tool_use in active loop
|
|
732
748
|
tool_starvation_streak: int = 0 # Consecutive forced turns with no tool_calls produced
|
|
733
749
|
malformed_tool_streak: int = 0 # consecutive malformed pseudo tool payloads
|
|
@@ -885,15 +901,19 @@ class SessionMonitor:
|
|
|
885
901
|
if len(self.tool_call_history) > 30:
|
|
886
902
|
self.tool_call_history = self.tool_call_history[-30:]
|
|
887
903
|
|
|
888
|
-
# Recon-convergence (B1): count consecutive turns
|
|
889
|
-
#
|
|
890
|
-
#
|
|
891
|
-
#
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
904
|
+
# Recon-convergence (B1): count consecutive turns that use tools but
|
|
905
|
+
# produce NO write/deliverable tool call. A turn that uses any write
|
|
906
|
+
# tool resets the streak — that's the model converging from
|
|
907
|
+
# exploration toward synthesis/output. A turn with no tool calls at
|
|
908
|
+
# all is a plain-text turn (neither exploration nor a write) and
|
|
909
|
+
# leaves the streak unchanged. This is the inverse of the old
|
|
910
|
+
# "all tools are recognized read-only" test, which reset on any
|
|
911
|
+
# Bash/WebFetch/Agent turn and so never accumulated for real agents.
|
|
912
|
+
if tool_names:
|
|
913
|
+
if any(n.lower() in _WRITE_TOOL_CLASS for n in tool_names):
|
|
914
|
+
self.consecutive_no_write_turns = 0
|
|
915
|
+
else:
|
|
916
|
+
self.consecutive_no_write_turns += 1
|
|
897
917
|
|
|
898
918
|
# Track read-only tool targets for dedup (Option 3)
|
|
899
919
|
if tool_targets:
|
|
@@ -3269,46 +3289,46 @@ def _resolve_state_machine_tool_choice(
|
|
|
3269
3289
|
|
|
3270
3290
|
|
|
3271
3291
|
def _maybe_inject_recon_convergence(openai_body: dict, monitor: "SessionMonitor") -> None:
|
|
3272
|
-
"""Nudge a session stuck in prolonged
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
"
|
|
3281
|
-
the streak is 2x over threshold.
|
|
3292
|
+
"""Nudge a session stuck in prolonged exploration toward its deliverable.
|
|
3293
|
+
|
|
3294
|
+
Fires when `consecutive_no_write_turns` crosses
|
|
3295
|
+
PROXY_RECON_CONVERGENCE_THRESHOLD — the model has used tools for many
|
|
3296
|
+
turns without producing any write/deliverable tool call. Targets the
|
|
3297
|
+
observed failure mode of an agentic recon task wandering for hundreds
|
|
3298
|
+
of turns and never converging to the synthesis/write step. Two
|
|
3299
|
+
escalation tiers: a firm "switch to synthesis" directive, then a hard
|
|
3300
|
+
"STOP, write it now" once the streak is 2x over threshold.
|
|
3282
3301
|
"""
|
|
3283
3302
|
if PROXY_RECON_CONVERGENCE_THRESHOLD <= 0:
|
|
3284
3303
|
return
|
|
3285
|
-
streak = monitor.
|
|
3304
|
+
streak = monitor.consecutive_no_write_turns
|
|
3286
3305
|
if streak < PROXY_RECON_CONVERGENCE_THRESHOLD:
|
|
3287
3306
|
return
|
|
3288
3307
|
util = monitor.get_utilization()
|
|
3289
3308
|
if streak >= 2 * PROXY_RECON_CONVERGENCE_THRESHOLD:
|
|
3290
3309
|
directive = (
|
|
3291
3310
|
f"STOP exploring. You have run {streak} consecutive turns of "
|
|
3292
|
-
f"
|
|
3293
|
-
"You will NOT finish if you keep
|
|
3294
|
-
"deliverable NOW from the information you already
|
|
3295
|
-
"it to a file with the appropriate tool. Do not
|
|
3311
|
+
f"exploration without producing a deliverable and context is at "
|
|
3312
|
+
f"{util * 100:.0f}%. You will NOT finish if you keep exploring. "
|
|
3313
|
+
"Produce your deliverable NOW from the information you already "
|
|
3314
|
+
"have — write it to a file with the appropriate tool. Do not "
|
|
3315
|
+
"read or run anything else."
|
|
3296
3316
|
)
|
|
3297
3317
|
tier = "hard"
|
|
3298
3318
|
else:
|
|
3299
3319
|
directive = (
|
|
3300
|
-
f"You have
|
|
3320
|
+
f"You have explored for {streak} consecutive turns without "
|
|
3301
3321
|
f"producing a deliverable (context {util * 100:.0f}%). You have "
|
|
3302
3322
|
"enough to begin. Switch from exploration to synthesis: write "
|
|
3303
|
-
"your deliverable now.
|
|
3304
|
-
"strictly required to write it."
|
|
3323
|
+
"your deliverable now. Explore at most one more time, and only "
|
|
3324
|
+
"if strictly required to write it."
|
|
3305
3325
|
)
|
|
3306
3326
|
tier = "firm"
|
|
3307
3327
|
msgs = openai_body.get("messages", [])
|
|
3308
3328
|
msgs.append({"role": "user", "content": directive})
|
|
3309
3329
|
openai_body["messages"] = msgs
|
|
3310
3330
|
logger.warning(
|
|
3311
|
-
"RECON CONVERGENCE: injected %s directive (
|
|
3331
|
+
"RECON CONVERGENCE: injected %s directive (no_write_streak=%d, ctx=%.0f%%)",
|
|
3312
3332
|
tier, streak, util * 100,
|
|
3313
3333
|
)
|
|
3314
3334
|
|
|
@@ -3821,8 +3841,8 @@ def build_openai_request(
|
|
|
3821
3841
|
_apply_tool_call_grammar(openai_body, grammar_override=profile_grammar)
|
|
3822
3842
|
|
|
3823
3843
|
# Recon-convergence guardrail (B1) — runs on every built request so a
|
|
3824
|
-
# session wandering in
|
|
3825
|
-
# deliverable regardless of tool-turn phase.
|
|
3844
|
+
# session wandering in exploration without producing a write is nudged
|
|
3845
|
+
# toward its deliverable regardless of tool-turn phase.
|
|
3826
3846
|
_maybe_inject_recon_convergence(openai_body, monitor)
|
|
3827
3847
|
|
|
3828
3848
|
return openai_body
|
|
@@ -5375,10 +5375,13 @@ class TestSlotSaveRestore(unittest.TestCase):
|
|
|
5375
5375
|
|
|
5376
5376
|
class TestReconConvergence(unittest.TestCase):
|
|
5377
5377
|
"""Tests for the B1 recon-convergence guardrail — nudges a session
|
|
5378
|
-
stuck
|
|
5378
|
+
stuck exploring without producing a write toward its deliverable.
|
|
5379
5379
|
|
|
5380
|
-
|
|
5381
|
-
|
|
5380
|
+
The streak is defined as write-tool ABSENCE, not read-tool presence: a
|
|
5381
|
+
real recon agent explores via Bash/WebFetch/Agent, so an "all tools are
|
|
5382
|
+
recognized read-only" test never accumulates. Targets the observed
|
|
5383
|
+
failure: a 664-turn agentic recon task that explored for hours and
|
|
5384
|
+
never converged to the synthesis/write step."""
|
|
5382
5385
|
|
|
5383
5386
|
def setUp(self):
|
|
5384
5387
|
self._threshold = proxy.PROXY_RECON_CONVERGENCE_THRESHOLD
|
|
@@ -5387,37 +5390,60 @@ class TestReconConvergence(unittest.TestCase):
|
|
|
5387
5390
|
proxy.PROXY_RECON_CONVERGENCE_THRESHOLD = self._threshold
|
|
5388
5391
|
|
|
5389
5392
|
def test_readonly_turns_increment_the_streak(self):
|
|
5390
|
-
"""Consecutive turns using only read
|
|
5393
|
+
"""Consecutive turns using only read tools grow the streak."""
|
|
5391
5394
|
m = proxy.SessionMonitor(context_window=131072)
|
|
5392
5395
|
for _ in range(5):
|
|
5393
5396
|
m.record_tool_calls(["Read"])
|
|
5394
|
-
self.assertEqual(m.
|
|
5397
|
+
self.assertEqual(m.consecutive_no_write_turns, 5)
|
|
5395
5398
|
m.record_tool_calls(["Grep", "Glob"])
|
|
5396
|
-
self.assertEqual(m.
|
|
5399
|
+
self.assertEqual(m.consecutive_no_write_turns, 6)
|
|
5397
5400
|
|
|
5398
|
-
def
|
|
5401
|
+
def test_bash_and_webfetch_turns_increment_the_streak(self):
|
|
5402
|
+
"""The core fix: exploration via Bash/WebFetch/Agent — tools the old
|
|
5403
|
+
read-only allowlist did not recognize — must grow the streak. The
|
|
5404
|
+
old logic reset on every such turn, so the streak never built."""
|
|
5405
|
+
m = proxy.SessionMonitor(context_window=131072)
|
|
5406
|
+
m.record_tool_calls(["Bash"])
|
|
5407
|
+
m.record_tool_calls(["WebFetch"])
|
|
5408
|
+
m.record_tool_calls(["Agent"])
|
|
5409
|
+
m.record_tool_calls(["Read", "Bash"]) # mixed exploration, no write
|
|
5410
|
+
self.assertEqual(m.consecutive_no_write_turns, 4)
|
|
5411
|
+
|
|
5412
|
+
def test_write_tool_resets_the_streak(self):
|
|
5399
5413
|
"""A turn using a write/edit tool means the model converged toward
|
|
5400
|
-
|
|
5414
|
+
output — the streak resets to 0."""
|
|
5401
5415
|
m = proxy.SessionMonitor(context_window=131072)
|
|
5402
5416
|
for _ in range(10):
|
|
5403
|
-
m.record_tool_calls(["
|
|
5404
|
-
self.assertEqual(m.
|
|
5417
|
+
m.record_tool_calls(["Bash"])
|
|
5418
|
+
self.assertEqual(m.consecutive_no_write_turns, 10)
|
|
5405
5419
|
m.record_tool_calls(["Write"])
|
|
5406
|
-
self.assertEqual(m.
|
|
5420
|
+
self.assertEqual(m.consecutive_no_write_turns, 0)
|
|
5407
5421
|
|
|
5408
5422
|
def test_mixed_turn_with_one_write_resets(self):
|
|
5409
|
-
"""A turn mixing
|
|
5410
|
-
converging — any
|
|
5423
|
+
"""A turn mixing exploration and a write tool still counts as
|
|
5424
|
+
converging — any write tool resets."""
|
|
5411
5425
|
m = proxy.SessionMonitor(context_window=131072)
|
|
5412
5426
|
for _ in range(10):
|
|
5413
5427
|
m.record_tool_calls(["Read"])
|
|
5414
5428
|
m.record_tool_calls(["Read", "Edit"])
|
|
5415
|
-
self.assertEqual(m.
|
|
5429
|
+
self.assertEqual(m.consecutive_no_write_turns, 0)
|
|
5430
|
+
|
|
5431
|
+
def test_no_tool_turn_leaves_streak_unchanged(self):
|
|
5432
|
+
"""A plain-text turn (no tool calls) is neither exploration nor a
|
|
5433
|
+
write — it must leave the streak untouched, not reset it."""
|
|
5434
|
+
m = proxy.SessionMonitor(context_window=131072)
|
|
5435
|
+
for _ in range(7):
|
|
5436
|
+
m.record_tool_calls(["Bash"])
|
|
5437
|
+
self.assertEqual(m.consecutive_no_write_turns, 7)
|
|
5438
|
+
m.record_tool_calls([]) # plain-text turn
|
|
5439
|
+
self.assertEqual(m.consecutive_no_write_turns, 7)
|
|
5440
|
+
m.record_tool_calls(["Read"])
|
|
5441
|
+
self.assertEqual(m.consecutive_no_write_turns, 8)
|
|
5416
5442
|
|
|
5417
5443
|
def test_no_injection_below_threshold(self):
|
|
5418
5444
|
proxy.PROXY_RECON_CONVERGENCE_THRESHOLD = 40
|
|
5419
5445
|
m = proxy.SessionMonitor(context_window=131072)
|
|
5420
|
-
m.
|
|
5446
|
+
m.consecutive_no_write_turns = 39
|
|
5421
5447
|
body = {"messages": [{"role": "user", "content": "go"}]}
|
|
5422
5448
|
proxy._maybe_inject_recon_convergence(body, m)
|
|
5423
5449
|
self.assertEqual(len(body["messages"]), 1)
|
|
@@ -5425,7 +5451,7 @@ class TestReconConvergence(unittest.TestCase):
|
|
|
5425
5451
|
def test_firm_directive_at_threshold(self):
|
|
5426
5452
|
proxy.PROXY_RECON_CONVERGENCE_THRESHOLD = 40
|
|
5427
5453
|
m = proxy.SessionMonitor(context_window=131072)
|
|
5428
|
-
m.
|
|
5454
|
+
m.consecutive_no_write_turns = 45
|
|
5429
5455
|
m.last_input_tokens = 120000
|
|
5430
5456
|
body = {"messages": [{"role": "user", "content": "go"}]}
|
|
5431
5457
|
proxy._maybe_inject_recon_convergence(body, m)
|
|
@@ -5438,7 +5464,7 @@ class TestReconConvergence(unittest.TestCase):
|
|
|
5438
5464
|
"""Once the streak is 2x over threshold, escalate to a hard STOP."""
|
|
5439
5465
|
proxy.PROXY_RECON_CONVERGENCE_THRESHOLD = 40
|
|
5440
5466
|
m = proxy.SessionMonitor(context_window=131072)
|
|
5441
|
-
m.
|
|
5467
|
+
m.consecutive_no_write_turns = 80
|
|
5442
5468
|
m.last_input_tokens = 250000 # over budget — the real-incident shape
|
|
5443
5469
|
body = {"messages": [{"role": "user", "content": "go"}]}
|
|
5444
5470
|
proxy._maybe_inject_recon_convergence(body, m)
|
|
@@ -5448,7 +5474,7 @@ class TestReconConvergence(unittest.TestCase):
|
|
|
5448
5474
|
def test_disabled_when_threshold_zero(self):
|
|
5449
5475
|
proxy.PROXY_RECON_CONVERGENCE_THRESHOLD = 0
|
|
5450
5476
|
m = proxy.SessionMonitor(context_window=131072)
|
|
5451
|
-
m.
|
|
5477
|
+
m.consecutive_no_write_turns = 500
|
|
5452
5478
|
body = {"messages": [{"role": "user", "content": "go"}]}
|
|
5453
5479
|
proxy._maybe_inject_recon_convergence(body, m)
|
|
5454
5480
|
self.assertEqual(len(body["messages"]), 1)
|