@miller-tech/uap 1.88.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.88.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",
@@ -42,10 +42,12 @@ class Settings:
42
42
  signal: str
43
43
  threshold: float
44
44
  fusion_n: int
45
+ remom_quorum: int
45
46
  auto_fusion_chars: int
46
47
  model: str
47
48
  endpoint: str
48
49
  api_key: str
50
+ allow_self_judge: bool = False
49
51
 
50
52
  @classmethod
51
53
  def from_env(cls) -> "Settings":
@@ -59,7 +61,7 @@ class Settings:
59
61
  return default
60
62
 
61
63
  recipe = os.environ.get("PROXY_RECIPE", "auto").lower()
62
- if recipe not in {"auto", "single", "confidence", "fusion"}:
64
+ if recipe not in {"auto", "single", "confidence", "fusion", "ratings", "remom", "workflow"}:
63
65
  recipe = "auto"
64
66
  signal = os.environ.get("PROXY_CONFIDENCE_SIGNAL", "heuristic").lower()
65
67
  if signal not in {"heuristic", "selfverify"}:
@@ -70,15 +72,37 @@ class Settings:
70
72
  signal=signal,
71
73
  threshold=num("PROXY_CONFIDENCE_THRESHOLD", 0.5, float),
72
74
  fusion_n=max(2, min(6, num("PROXY_FUSION_N", 3, int))),
75
+ remom_quorum=max(1, min(6, num("PROXY_REMOM_QUORUM", 2, int))),
73
76
  auto_fusion_chars=num("PROXY_AUTO_FUSION_CHARS", 600, int),
74
77
  model=os.environ.get("PROXY_ESCALATE_MODEL", ""),
75
78
  endpoint=os.environ.get("PROXY_ESCALATE_ENDPOINT", ""),
76
79
  api_key=os.environ.get("PROXY_ESCALATE_API_KEY", ""),
80
+ allow_self_judge=flag("PROXY_ALLOW_SELF_JUDGE"),
77
81
  )
78
82
 
79
83
  def backend_configured(self) -> bool:
80
84
  return bool(self.model and self.endpoint)
81
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
+
82
106
 
83
107
  # ---- helpers --------------------------------------------------------------
84
108
  def latest_user_text(anthropic_body: dict) -> str:
@@ -220,6 +244,7 @@ def select_recipe(anthropic_body: dict, settings: Settings, has_tools: bool) ->
220
244
  return "single"
221
245
  if settings.recipe != "auto":
222
246
  return settings.recipe
247
+ pm = (anthropic_body or {}).get("model", "")
223
248
  text = latest_user_text(anthropic_body)
224
249
  # Prefer the harness reactor's ACTUAL routeResult signal when fresh; else the
225
250
  # proxy's own signal extraction (faithful port of query-complexity).
@@ -227,7 +252,7 @@ def select_recipe(anthropic_body: dict, settings: Settings, has_tools: bool) ->
227
252
  if rsig is not None:
228
253
  rec = rsig.get("recipe")
229
254
  if rec in {"single", "confidence", "fusion"}:
230
- if rec == "fusion" and not settings.backend_configured():
255
+ if rec == "fusion" and not settings.judge_available(pm):
231
256
  return "confidence"
232
257
  return rec
233
258
  complexity = rsig.get("complexity") or query_complexity(text)
@@ -235,9 +260,9 @@ def select_recipe(anthropic_body: dict, settings: Settings, has_tools: bool) ->
235
260
  else:
236
261
  complexity = query_complexity(text)
237
262
  shape = task_shape(text)
238
- if settings.backend_configured() and (complexity == "complex" or shape == "reasoning"):
263
+ if settings.judge_available(pm) and (complexity == "complex" or shape == "reasoning"):
239
264
  return "fusion"
240
- if settings.backend_configured() and len(text) >= settings.auto_fusion_chars:
265
+ if settings.judge_available(pm) and len(text) >= settings.auto_fusion_chars:
241
266
  return "fusion"
242
267
  return "confidence"
243
268
 
@@ -321,6 +346,26 @@ def parse_judge_index(text: str, n: int):
321
346
  return i if 0 <= i < n else None
322
347
 
323
348
 
349
+ def build_synthesis_payload(anthropic_body: dict, candidate_texts: list[str], settings: Settings) -> dict:
350
+ q = latest_user_text(anthropic_body)
351
+ listing = "\n\n".join(f"[{i}]\n{t}" for i, t in enumerate(candidate_texts))
352
+ prompt = (
353
+ "Synthesize a SINGLE best answer to the REQUEST by merging the correct, "
354
+ "complementary parts of the candidate answers below. Resolve disagreements "
355
+ "and keep the required output format. Reply with ONLY the final answer."
356
+ "\n\nREQUEST:\n" + q + "\n\nCANDIDATES:\n" + listing
357
+ )
358
+ return {"model": settings.model, "max_tokens": anthropic_body.get("max_tokens", 4096),
359
+ "messages": [{"role": "user", "content": prompt}]}
360
+
361
+
362
+ async def _fanout_candidates(primary_resp, openai_body, settings, call_primary):
363
+ """[primary] + up to fusion_n-1 temperature-varied breadth candidates."""
364
+ variants = build_fusion_variants(openai_body or {}, settings.fusion_n)
365
+ results = await asyncio.gather(*[call_primary(v) for v in variants], return_exceptions=True)
366
+ return [primary_resp] + [r for r in results if isinstance(r, dict)]
367
+
368
+
324
369
  def should_escalate(text: str, settings: Settings, has_tools: bool) -> bool:
325
370
  """Back-compat (heuristic confidence path)."""
326
371
  if not settings.enabled or not settings.backend_configured() or has_tools:
@@ -329,8 +374,8 @@ def should_escalate(text: str, settings: Settings, has_tools: bool) -> bool:
329
374
 
330
375
 
331
376
  # ---- orchestration (injected callables) -----------------------------------
332
- async def _confidence_score(text, anthropic_body, settings, call_judge):
333
- 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:
334
379
  jr = await call_judge(build_verify_payload(anthropic_body, text, settings))
335
380
  score = parse_verify_score(extract_text(jr)) if isinstance(jr, dict) else None
336
381
  if score is not None:
@@ -348,10 +393,16 @@ async def apply_recipe(primary_resp, anthropic_body, openai_body, settings, has_
348
393
  if recipe == "single" or not isinstance(primary_resp, dict):
349
394
  return primary_resp
350
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
351
402
 
352
403
  if recipe == "confidence":
353
- conf = await _confidence_score(primary_text, anthropic_body, settings, call_judge)
354
- 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:
355
406
  esc = await call_judge(build_escalation_payload(anthropic_body, settings))
356
407
  if isinstance(esc, dict):
357
408
  return esc
@@ -370,6 +421,42 @@ async def apply_recipe(primary_resp, anthropic_body, openai_body, settings, has_
370
421
  if idx is not None:
371
422
  return candidates[idx]
372
423
  return primary_resp # fallback to best valid evidence
424
+
425
+ if recipe == "ratings":
426
+ # Bounded ensemble: rate each candidate independently, pick the best.
427
+ candidates = await _fanout_candidates(primary_resp, openai_body, settings, call_primary)
428
+ if len(candidates) <= 1 or not settings.backend_configured() or call_judge is None:
429
+ return primary_resp
430
+ rs = await asyncio.gather(
431
+ *[call_judge(build_verify_payload(anthropic_body, extract_text(c), settings))
432
+ for c in candidates],
433
+ return_exceptions=True,
434
+ )
435
+ scored = []
436
+ for c, jr2 in zip(candidates, rs):
437
+ sc = parse_verify_score(extract_text(jr2)) if isinstance(jr2, dict) else None
438
+ scored.append((sc if sc is not None else text_confidence(extract_text(c)), c))
439
+ return max(scored, key=lambda x: x[0])[1]
440
+
441
+ if recipe == "remom":
442
+ # Breadth -> quorum -> synthesis into the output contract; fall back
443
+ # to the best valid evidence if synthesis fails (no API error).
444
+ candidates = await _fanout_candidates(primary_resp, openai_body, settings, call_primary)
445
+ valid = [c for c in candidates if extract_text(c).strip()]
446
+ if len(valid) >= settings.remom_quorum and settings.backend_configured() and call_judge is not None:
447
+ synth = await call_judge(
448
+ build_synthesis_payload(anthropic_body, [extract_text(c) for c in valid], settings)
449
+ )
450
+ if isinstance(synth, dict) and extract_text(synth).strip():
451
+ return synth
452
+ return max(valid, key=lambda c: len(extract_text(c))) if valid else primary_resp
453
+
454
+ if recipe == "workflow":
455
+ # Workflows (planner/patcher/verifier under a contract) are the
456
+ # deliver convergence loop's job — it owns the real execution +
457
+ # acceptance gates and repo state a stateless serving turn lacks.
458
+ # Pass through; the harness routes workflow tasks through uap deliver.
459
+ return primary_resp
373
460
  except Exception:
374
461
  return primary_resp
375
462
  return primary_resp
@@ -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
 
@@ -15,10 +15,10 @@ spec.loader.exec_module(ce)
15
15
 
16
16
 
17
17
  def S(enabled=True, recipe="auto", signal="heuristic", threshold=0.5, fusion_n=3,
18
- auto_chars=600, model="opus", endpoint="http://x/", key="k"):
18
+ auto_chars=600, model="opus", endpoint="http://x/", key="k", remom_quorum=2):
19
19
  return ce.Settings(enabled=enabled, recipe=recipe, signal=signal, threshold=threshold,
20
- fusion_n=fusion_n, auto_fusion_chars=auto_chars, model=model,
21
- endpoint=endpoint, api_key=key)
20
+ fusion_n=fusion_n, remom_quorum=remom_quorum, auto_fusion_chars=auto_chars,
21
+ model=model, endpoint=endpoint, api_key=key)
22
22
 
23
23
 
24
24
  def resp(text):
@@ -180,5 +180,93 @@ class CrossProcessSignalTest(unittest.TestCase): # reactor -> proxy
180
180
  self.assertEqual(r, "fusion") # reactor signal overrides the simple self-classification
181
181
 
182
182
 
183
+
184
+ class ApplyRatingsTest(unittest.TestCase): # Ratings
185
+ def test_picks_highest_rated_candidate(self):
186
+ async def primary(v): return resp("cand-1")
187
+ scores = iter(["3", "9"]) # primary rated 3, fanout rated 9
188
+ async def judge(p): return resp(next(scores))
189
+ out = run(ce.apply_recipe(resp("cand-0"), body(), {"model": "q", "messages": []},
190
+ S(recipe="ratings", fusion_n=2), False, primary, judge))
191
+ self.assertEqual(ce.extract_text(out), "cand-1")
192
+
193
+
194
+ class ApplyReMoMTest(unittest.TestCase): # ReMoM
195
+ def test_synthesizes_when_quorum_met(self):
196
+ async def primary(v): return resp("evidence B")
197
+ async def judge(p): return resp("MERGED ANSWER")
198
+ out = run(ce.apply_recipe(resp("evidence A"), body(), {"model": "q", "messages": []},
199
+ S(recipe="remom", fusion_n=2), False, primary, judge))
200
+ self.assertEqual(ce.extract_text(out), "MERGED ANSWER")
201
+
202
+ def test_falls_back_to_best_evidence_when_synthesis_fails(self):
203
+ async def primary(v): return resp("a longer, more complete candidate answer")
204
+ async def judge(p): return None # synthesis failed
205
+ out = run(ce.apply_recipe(resp("short"), body(), {"model": "q", "messages": []},
206
+ S(recipe="remom", fusion_n=2), False, primary, judge))
207
+ self.assertEqual(ce.extract_text(out), "a longer, more complete candidate answer")
208
+
209
+
210
+ class WorkflowTest(unittest.TestCase):
211
+ def test_workflow_passes_through(self): # Workflows = deliver's job
212
+ async def primary(v): return resp("x")
213
+ async def judge(p): return resp("y")
214
+ out = run(ce.apply_recipe(resp("PRIMARY"), body(), {}, S(recipe="workflow"), False, primary, judge))
215
+ self.assertEqual(ce.extract_text(out), "PRIMARY")
216
+
217
+
218
+ class SettingsExtTest(unittest.TestCase):
219
+ def test_remom_quorum_default(self):
220
+ import os, tempfile
221
+ for k in ("PROXY_REMOM_QUORUM",): os.environ.pop(k, None)
222
+ self.assertEqual(ce.Settings.from_env().remom_quorum, 2)
223
+
224
+ def test_new_recipes_valid(self):
225
+ import os
226
+ for r in ("ratings", "remom", "workflow"):
227
+ os.environ["PROXY_RECIPE"] = r
228
+ try:
229
+ self.assertEqual(ce.Settings.from_env().recipe, r)
230
+ finally:
231
+ os.environ.pop("PROXY_RECIPE", None)
232
+
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
+
183
271
  if __name__ == "__main__":
184
272
  unittest.main()