@miller-tech/uap 1.89.0 → 1.90.0

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.89.0",
3
+ "version": "1.90.0",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -7979,6 +7979,28 @@ def openai_to_anthropic_response(
7979
7979
  }
7980
7980
  )
7981
7981
 
7982
+ # Empty-output guard (no-tool path). Qwen-style models emit their whole
7983
+ # response inside a <think> block even with enable_thinking=False; when the
7984
+ # client did NOT opt into thinking (expose_thinking=False) the block is
7985
+ # consumed and -- if the model produced nothing after </think> -- the
7986
+ # response collapses to an empty text block. That empty body is useless to
7987
+ # the client and silently breaks downstream consumers (notably the
7988
+ # Fusion/Confidence judge, whose escalate call is a no-tool turn -- an empty
7989
+ # judge reply makes apply_recipe fall back to the primary, so recipes
7990
+ # degrade to single). When there is no text and no tool_use content but we
7991
+ # DID capture thinking, surface the de-tagged thinking as the body rather
7992
+ # than returning nothing.
7993
+ _has_text = any(
7994
+ b.get("type") == "text" and (b.get("text") or "").strip() for b in content
7995
+ )
7996
+ _has_tool_use = any(b.get("type") == "tool_use" for b in content)
7997
+ if not _has_text and not _has_tool_use and thinking_chunks:
7998
+ content.append({"type": "text", "text": "\n\n".join(thinking_chunks)})
7999
+ logger.warning(
8000
+ "EMPTY-OUTPUT GUARD: no-tool turn produced only <think> content; "
8001
+ "promoted de-tagged thinking to the text body (would otherwise be empty)"
8002
+ )
8003
+
7982
8004
  stop_reason_map = {
7983
8005
  "stop": "end_turn",
7984
8006
  "length": "max_tokens",
@@ -47,6 +47,7 @@ class Settings:
47
47
  model: str
48
48
  endpoint: str
49
49
  api_key: str
50
+ allow_self_judge: bool = False
50
51
 
51
52
  @classmethod
52
53
  def from_env(cls) -> "Settings":
@@ -76,11 +77,32 @@ class Settings:
76
77
  model=os.environ.get("PROXY_ESCALATE_MODEL", ""),
77
78
  endpoint=os.environ.get("PROXY_ESCALATE_ENDPOINT", ""),
78
79
  api_key=os.environ.get("PROXY_ESCALATE_API_KEY", ""),
80
+ allow_self_judge=flag("PROXY_ALLOW_SELF_JUDGE"),
79
81
  )
80
82
 
81
83
  def backend_configured(self) -> bool:
82
84
  return bool(self.model and self.endpoint)
83
85
 
86
+ def judge_is_self(self, primary_model: str = "") -> bool:
87
+ """True when the configured judge model name matches the primary
88
+ (generator) model -- i.e. qwen judging qwen."""
89
+ pm = (primary_model or "").strip().lower()
90
+ jm = (self.model or "").strip().lower()
91
+ return bool(pm) and bool(jm) and pm == jm
92
+
93
+ def judge_available(self, primary_model: str = "") -> bool:
94
+ """A judge backend that is configured AND distinct from the primary
95
+ model. A same-model judge (qwen judging qwen) was MEASURED to add no
96
+ quality lift, so judge-dependent recipes (fusion / ratings / remom and
97
+ confidence-escalation) require a distinct judge by default and otherwise
98
+ downgrade to single. Set PROXY_ALLOW_SELF_JUDGE=1 to force self-judging.
99
+ When the primary model is unknown the judge is treated as distinct."""
100
+ if not self.backend_configured():
101
+ return False
102
+ if self.allow_self_judge:
103
+ return True
104
+ return not self.judge_is_self(primary_model)
105
+
84
106
 
85
107
  # ---- helpers --------------------------------------------------------------
86
108
  def latest_user_text(anthropic_body: dict) -> str:
@@ -222,6 +244,7 @@ def select_recipe(anthropic_body: dict, settings: Settings, has_tools: bool) ->
222
244
  return "single"
223
245
  if settings.recipe != "auto":
224
246
  return settings.recipe
247
+ pm = (anthropic_body or {}).get("model", "")
225
248
  text = latest_user_text(anthropic_body)
226
249
  # Prefer the harness reactor's ACTUAL routeResult signal when fresh; else the
227
250
  # proxy's own signal extraction (faithful port of query-complexity).
@@ -229,7 +252,7 @@ def select_recipe(anthropic_body: dict, settings: Settings, has_tools: bool) ->
229
252
  if rsig is not None:
230
253
  rec = rsig.get("recipe")
231
254
  if rec in {"single", "confidence", "fusion"}:
232
- if rec == "fusion" and not settings.backend_configured():
255
+ if rec == "fusion" and not settings.judge_available(pm):
233
256
  return "confidence"
234
257
  return rec
235
258
  complexity = rsig.get("complexity") or query_complexity(text)
@@ -237,9 +260,9 @@ def select_recipe(anthropic_body: dict, settings: Settings, has_tools: bool) ->
237
260
  else:
238
261
  complexity = query_complexity(text)
239
262
  shape = task_shape(text)
240
- if settings.backend_configured() and (complexity == "complex" or shape == "reasoning"):
263
+ if settings.judge_available(pm) and (complexity == "complex" or shape == "reasoning"):
241
264
  return "fusion"
242
- if settings.backend_configured() and len(text) >= settings.auto_fusion_chars:
265
+ if settings.judge_available(pm) and len(text) >= settings.auto_fusion_chars:
243
266
  return "fusion"
244
267
  return "confidence"
245
268
 
@@ -351,8 +374,8 @@ def should_escalate(text: str, settings: Settings, has_tools: bool) -> bool:
351
374
 
352
375
 
353
376
  # ---- orchestration (injected callables) -----------------------------------
354
- async def _confidence_score(text, anthropic_body, settings, call_judge):
355
- if settings.signal == "selfverify" and settings.backend_configured() and call_judge is not None:
377
+ async def _confidence_score(text, anthropic_body, settings, call_judge, primary_model=""):
378
+ if settings.signal == "selfverify" and settings.judge_available(primary_model) and call_judge is not None:
356
379
  jr = await call_judge(build_verify_payload(anthropic_body, text, settings))
357
380
  score = parse_verify_score(extract_text(jr)) if isinstance(jr, dict) else None
358
381
  if score is not None:
@@ -370,10 +393,16 @@ async def apply_recipe(primary_resp, anthropic_body, openai_body, settings, has_
370
393
  if recipe == "single" or not isinstance(primary_resp, dict):
371
394
  return primary_resp
372
395
  primary_text = extract_text(primary_resp)
396
+ primary_model = (openai_body or {}).get("model", "")
397
+ # Judge-dependent recipes need a configured, DISTINCT (non-self) judge.
398
+ # A same-model judge adds no measured lift, so downgrade to single BEFORE
399
+ # spending fan-out / judge calls.
400
+ if recipe in {"fusion", "ratings", "remom"} and not settings.judge_available(primary_model):
401
+ return primary_resp
373
402
 
374
403
  if recipe == "confidence":
375
- conf = await _confidence_score(primary_text, anthropic_body, settings, call_judge)
376
- if conf < settings.threshold and settings.backend_configured() and call_judge is not None:
404
+ conf = await _confidence_score(primary_text, anthropic_body, settings, call_judge, primary_model)
405
+ if conf < settings.threshold and settings.judge_available(primary_model) and call_judge is not None:
377
406
  esc = await call_judge(build_escalation_payload(anthropic_body, settings))
378
407
  if isinstance(esc, dict):
379
408
  return esc
@@ -3790,6 +3790,34 @@ class TestGenerationHangRecovery(unittest.TestCase):
3790
3790
  asyncio.run(_run())
3791
3791
 
3792
3792
 
3793
+ class EmptyOutputGuardTest(unittest.TestCase):
3794
+ """#1: a no-tool turn whose entire response was inside <think> must never
3795
+ collapse to an empty body (would silently break the Fusion/Confidence judge)."""
3796
+
3797
+ def test_all_think_no_tool_promotes_thinking_to_body(self):
3798
+ openai_resp = {"choices": [{"message": {"content": "<think>the answer is 42</think>"},
3799
+ "finish_reason": "stop"}]}
3800
+ out = proxy.openai_to_anthropic_response(openai_resp, "qwen", expose_thinking=False)
3801
+ texts = [b.get("text", "") for b in out["content"] if b.get("type") == "text"]
3802
+ self.assertTrue(any("42" in t for t in texts)) # body is non-empty
3803
+ self.assertTrue(all(b.get("type") != "thinking" for b in out["content"]))
3804
+
3805
+ def test_normal_post_think_body_unaffected(self):
3806
+ openai_resp = {"choices": [{"message": {"content": "<think>reason</think>FINAL"},
3807
+ "finish_reason": "stop"}]}
3808
+ out = proxy.openai_to_anthropic_response(openai_resp, "qwen", expose_thinking=False)
3809
+ texts = "".join(b.get("text", "") for b in out["content"] if b.get("type") == "text")
3810
+ self.assertEqual(texts, "FINAL") # guard does not fire
3811
+
3812
+ def test_tool_only_response_not_padded(self):
3813
+ openai_resp = {"choices": [{"message": {"content": "<think>x</think>",
3814
+ "tool_calls": [{"id": "a", "function": {"name": "bash",
3815
+ "arguments": "{}"}}]}, "finish_reason": "tool_calls"}]}
3816
+ out = proxy.openai_to_anthropic_response(openai_resp, "qwen", expose_thinking=False)
3817
+ self.assertTrue(any(b.get("type") == "tool_use" for b in out["content"]))
3818
+ self.assertFalse(any(b.get("type") == "text" for b in out["content"]))
3819
+
3820
+
3793
3821
  if __name__ == "__main__":
3794
3822
  unittest.main()
3795
3823
 
@@ -231,5 +231,42 @@ class SettingsExtTest(unittest.TestCase):
231
231
  os.environ.pop("PROXY_RECIPE", None)
232
232
 
233
233
 
234
+ class JudgeGatingTest(unittest.TestCase): # #2 stronger non-self judge
235
+ def test_judge_available_distinct_vs_self(self):
236
+ s = S(model="opus") # judge=opus, primary=qwen -> distinct
237
+ self.assertTrue(s.judge_available("qwen"))
238
+ self.assertFalse(s.judge_available("opus")) # self-judge blocked
239
+ self.assertFalse(S(model="qwen").judge_available("qwen"))
240
+ self.assertTrue(s.judge_available("")) # unknown primary -> allow
241
+ self.assertFalse(S(model="", endpoint="").judge_available("qwen")) # unconfigured
242
+
243
+ def test_allow_self_judge_override(self):
244
+ s = ce.Settings(enabled=True, recipe="fusion", signal="heuristic", threshold=0.5,
245
+ fusion_n=2, remom_quorum=2, auto_fusion_chars=600, model="qwen",
246
+ endpoint="http://x/", api_key="k", allow_self_judge=True)
247
+ self.assertTrue(s.judge_available("qwen"))
248
+
249
+ def test_self_judge_downgrades_fusion_without_calling_judge(self):
250
+ async def primary(v): raise AssertionError("fan-out must not run on self-judge")
251
+ async def judge(p): raise AssertionError("judge must not be called on self-judge")
252
+ # judge model == primary model ("qwen") -> downgrade to single
253
+ out = run(ce.apply_recipe(resp("c0"), body(), {"model": "qwen", "messages": []},
254
+ S(recipe="fusion", fusion_n=2, model="qwen"), False, primary, judge))
255
+ self.assertEqual(ce.extract_text(out), "c0")
256
+
257
+ def test_distinct_judge_still_runs_fusion(self):
258
+ async def primary(v): return resp("c1")
259
+ async def judge(p): return resp("1")
260
+ out = run(ce.apply_recipe(resp("c0"), body(), {"model": "qwen", "messages": []},
261
+ S(recipe="fusion", fusion_n=2, model="opus"), False, primary, judge))
262
+ self.assertEqual(ce.extract_text(out), "c1")
263
+
264
+ def test_select_recipe_skips_fusion_on_self_judge(self):
265
+ b = {"model": "qwen", "messages": [{"role": "user",
266
+ "content": "prove the theorem and analyze why x is even"}]}
267
+ self.assertEqual(ce.select_recipe(b, S(recipe="auto", model="qwen"), False), "confidence")
268
+ self.assertEqual(ce.select_recipe(b, S(recipe="auto", model="opus"), False), "fusion")
269
+
270
+
234
271
  if __name__ == "__main__":
235
272
  unittest.main()