@miller-tech/uap 1.14.1 → 1.15.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.14.1",
3
+ "version": "1.15.0",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -203,6 +203,20 @@ PROXY_SESSION_CONTAMINATION_KEEP_LAST = int(
203
203
  PROXY_AGENTIC_SUPPLEMENT_MODE = (
204
204
  os.environ.get("PROXY_AGENTIC_SUPPLEMENT_MODE", "clean").strip().lower()
205
205
  )
206
+ PROXY_ANALYSIS_ONLY_ROUTE = os.environ.get(
207
+ "PROXY_ANALYSIS_ONLY_ROUTE", "off"
208
+ ).lower() not in {
209
+ "0",
210
+ "false",
211
+ "off",
212
+ "no",
213
+ }
214
+ PROXY_ANALYSIS_ONLY_MIN_TOOLS = int(
215
+ os.environ.get("PROXY_ANALYSIS_ONLY_MIN_TOOLS", "12")
216
+ )
217
+ PROXY_ANALYSIS_ONLY_MAX_MESSAGES = int(
218
+ os.environ.get("PROXY_ANALYSIS_ONLY_MAX_MESSAGES", "2")
219
+ )
206
220
 
207
221
  # ---------------------------------------------------------------------------
208
222
  # Logging
@@ -549,8 +563,9 @@ def estimate_total_tokens(anthropic_body: dict) -> int:
549
563
  if isinstance(block, dict) and block.get("type") == "text":
550
564
  tokens += estimate_tokens(block.get("text", ""))
551
565
 
552
- # Agentic supplement tokens (always injected)
553
- tokens += estimate_tokens(_AGENTIC_SYSTEM_SUPPLEMENT)
566
+ # Agentic supplement tokens (only when tool mode is active)
567
+ if _has_tool_definitions(anthropic_body):
568
+ tokens += estimate_tokens(_AGENTIC_SYSTEM_SUPPLEMENT)
554
569
 
555
570
  # Messages
556
571
  for msg in anthropic_body.get("messages", []):
@@ -600,7 +615,8 @@ def prune_conversation(
600
615
  for block in system:
601
616
  if isinstance(block, dict) and block.get("type") == "text":
602
617
  overhead_tokens += estimate_tokens(block.get("text", ""))
603
- overhead_tokens += estimate_tokens(_AGENTIC_SYSTEM_SUPPLEMENT)
618
+ if _has_tool_definitions(anthropic_body):
619
+ overhead_tokens += estimate_tokens(_AGENTIC_SYSTEM_SUPPLEMENT)
604
620
  tools = anthropic_body.get("tools", [])
605
621
  if tools:
606
622
  overhead_tokens += estimate_tokens(json.dumps(tools))
@@ -768,7 +784,7 @@ async def lifespan(app: FastAPI):
768
784
  _resolve_prune_target_fraction() * 100,
769
785
  )
770
786
  logger.info(
771
- "Guardrails: malformed=%s stream_strict=%s force_non_stream=%s tool_narrowing=%s thinking_off_on_tools=%s contamination_breaker=%s(%d)",
787
+ "Guardrails: malformed=%s stream_strict=%s force_non_stream=%s tool_narrowing=%s thinking_off_on_tools=%s contamination_breaker=%s(%d) analysis_only_route=%s(min_tools=%d,max_msgs=%d)",
772
788
  PROXY_MALFORMED_TOOL_GUARDRAIL,
773
789
  PROXY_MALFORMED_TOOL_STREAM_STRICT,
774
790
  PROXY_FORCE_NON_STREAM,
@@ -776,6 +792,9 @@ async def lifespan(app: FastAPI):
776
792
  PROXY_DISABLE_THINKING_ON_TOOL_TURNS,
777
793
  PROXY_SESSION_CONTAMINATION_BREAKER,
778
794
  PROXY_SESSION_CONTAMINATION_THRESHOLD,
795
+ PROXY_ANALYSIS_ONLY_ROUTE,
796
+ PROXY_ANALYSIS_ONLY_MIN_TOOLS,
797
+ PROXY_ANALYSIS_ONLY_MAX_MESSAGES,
779
798
  )
780
799
 
781
800
  yield
@@ -879,6 +898,112 @@ def _extract_text(content) -> str:
879
898
  return str(content)
880
899
 
881
900
 
901
+ def _has_tool_definitions(anthropic_body: dict) -> bool:
902
+ tools = anthropic_body.get("tools")
903
+ return isinstance(tools, list) and len(tools) > 0
904
+
905
+
906
+ def _message_has_tool_result(content) -> bool:
907
+ return isinstance(content, list) and any(
908
+ isinstance(block, dict) and block.get("type") == "tool_result"
909
+ for block in content
910
+ )
911
+
912
+
913
+ def _last_user_text(anthropic_body: dict) -> str:
914
+ for msg in reversed(anthropic_body.get("messages", [])):
915
+ if msg.get("role") == "user":
916
+ return _extract_text(msg.get("content", "")).strip().lower()
917
+ return ""
918
+
919
+
920
+ def _is_analysis_only_prompt(text: str) -> bool:
921
+ if not text:
922
+ return False
923
+
924
+ analysis_markers = (
925
+ "analy",
926
+ "review",
927
+ "audit",
928
+ "summar",
929
+ "explain",
930
+ "plan",
931
+ "recommend",
932
+ "assess",
933
+ "compare",
934
+ "investigate",
935
+ "diagnose",
936
+ )
937
+ action_markers = (
938
+ "fix",
939
+ "edit",
940
+ "write",
941
+ "create",
942
+ "implement",
943
+ "patch",
944
+ "change",
945
+ "update",
946
+ "run ",
947
+ "execute",
948
+ "command",
949
+ "use tool",
950
+ "call tool",
951
+ "apply",
952
+ "commit",
953
+ "push",
954
+ "merge",
955
+ "publish",
956
+ "deploy",
957
+ "test",
958
+ "build",
959
+ "refactor",
960
+ "rename",
961
+ "delete",
962
+ "install",
963
+ )
964
+
965
+ has_analysis = any(marker in text for marker in analysis_markers)
966
+ has_action = any(marker in text for marker in action_markers)
967
+ return has_analysis and not has_action
968
+
969
+
970
+ def _should_route_analysis_without_tools(anthropic_body: dict) -> bool:
971
+ if not PROXY_ANALYSIS_ONLY_ROUTE:
972
+ return False
973
+
974
+ tools = anthropic_body.get("tools")
975
+ if not isinstance(tools, list) or len(tools) < max(
976
+ 1, PROXY_ANALYSIS_ONLY_MIN_TOOLS
977
+ ):
978
+ return False
979
+
980
+ messages = anthropic_body.get("messages", [])
981
+ if not isinstance(messages, list) or not messages:
982
+ return False
983
+
984
+ if len(messages) > max(1, PROXY_ANALYSIS_ONLY_MAX_MESSAGES):
985
+ return False
986
+
987
+ if any(msg.get("role") == "assistant" for msg in messages):
988
+ return False
989
+
990
+ if any(_message_has_tool_result(msg.get("content")) for msg in messages):
991
+ return False
992
+
993
+ return _is_analysis_only_prompt(_last_user_text(anthropic_body))
994
+
995
+
996
+ def _maybe_route_analysis_without_tools(anthropic_body: dict) -> tuple[dict, int]:
997
+ if not _should_route_analysis_without_tools(anthropic_body):
998
+ return anthropic_body, 0
999
+
1000
+ tools = anthropic_body.get("tools")
1001
+ removed = len(tools) if isinstance(tools, list) else 0
1002
+ updated = dict(anthropic_body)
1003
+ updated.pop("tools", None)
1004
+ return updated, removed
1005
+
1006
+
882
1007
  _AGENTIC_SYSTEM_SUPPLEMENT_LEGACY = (
883
1008
  "\n\n<agentic-protocol>\n"
884
1009
  "You are operating in an agentic coding loop with tool access. Follow these rules:\n"
@@ -1076,19 +1201,24 @@ def build_openai_request(anthropic_body: dict, monitor: SessionMonitor) -> dict:
1076
1201
  "stream": anthropic_body.get("stream", False),
1077
1202
  }
1078
1203
 
1079
- # Inject agentic protocol instructions into the system message so
1080
- # the model knows it must use tools to complete work, not just explain.
1081
- if openai_body["messages"] and openai_body["messages"][0].get("role") == "system":
1082
- openai_body["messages"][0]["content"] += _AGENTIC_SYSTEM_SUPPLEMENT
1083
- else:
1084
- # No system message from the client; inject one.
1085
- openai_body["messages"].insert(
1086
- 0,
1087
- {
1088
- "role": "system",
1089
- "content": _AGENTIC_SYSTEM_SUPPLEMENT.strip(),
1090
- },
1091
- )
1204
+ has_tools = _has_tool_definitions(anthropic_body)
1205
+
1206
+ # Inject agentic protocol instructions only for tool-enabled turns.
1207
+ if has_tools:
1208
+ if (
1209
+ openai_body["messages"]
1210
+ and openai_body["messages"][0].get("role") == "system"
1211
+ ):
1212
+ openai_body["messages"][0]["content"] += _AGENTIC_SYSTEM_SUPPLEMENT
1213
+ else:
1214
+ # No system message from the client; inject one.
1215
+ openai_body["messages"].insert(
1216
+ 0,
1217
+ {
1218
+ "role": "system",
1219
+ "content": _AGENTIC_SYSTEM_SUPPLEMENT.strip(),
1220
+ },
1221
+ )
1092
1222
 
1093
1223
  if "max_tokens" in anthropic_body:
1094
1224
  # Enforce configurable minimum floor for thinking mode: model needs
@@ -1137,7 +1267,7 @@ def build_openai_request(anthropic_body: dict, monitor: SessionMonitor) -> dict:
1137
1267
  openai_body["stop"] = anthropic_body["stop_sequences"]
1138
1268
 
1139
1269
  # Convert Anthropic tools to OpenAI function-calling tools
1140
- if "tools" in anthropic_body:
1270
+ if has_tools:
1141
1271
  openai_body["tools"] = _convert_anthropic_tools_to_openai(
1142
1272
  anthropic_body.get("tools", [])
1143
1273
  )
@@ -2200,6 +2330,14 @@ async def messages(request: Request):
2200
2330
  last_session_id = session_id
2201
2331
 
2202
2332
  body = _maybe_apply_session_contamination_breaker(body, monitor, session_id)
2333
+ body, analysis_tools_removed = _maybe_route_analysis_without_tools(body)
2334
+ if analysis_tools_removed > 0:
2335
+ monitor.consecutive_forced_count = 0
2336
+ monitor.no_progress_streak = 0
2337
+ logger.info(
2338
+ "ANALYSIS ROUTE: disabled %d tools for analysis-only prompt",
2339
+ analysis_tools_removed,
2340
+ )
2203
2341
 
2204
2342
  # Debug: log request summary
2205
2343
  n_messages = len(body.get("messages", []))
@@ -483,6 +483,82 @@ class TestToolTurnControls(unittest.TestCase):
483
483
  finally:
484
484
  setattr(proxy, "PROXY_DISABLE_THINKING_ON_TOOL_TURNS", old_disable)
485
485
 
486
+ def test_no_tools_does_not_inject_agentic_system_message(self):
487
+ body = {
488
+ "model": "test",
489
+ "messages": [{"role": "user", "content": "analyze architecture"}],
490
+ }
491
+ openai = proxy.build_openai_request(
492
+ body, proxy.SessionMonitor(context_window=262144)
493
+ )
494
+
495
+ self.assertEqual(openai["messages"][0]["role"], "user")
496
+ self.assertNotIn("tools", openai)
497
+
498
+ def test_analysis_only_route_removes_tools(self):
499
+ old_route = getattr(proxy, "PROXY_ANALYSIS_ONLY_ROUTE")
500
+ old_min_tools = getattr(proxy, "PROXY_ANALYSIS_ONLY_MIN_TOOLS")
501
+ old_max_messages = getattr(proxy, "PROXY_ANALYSIS_ONLY_MAX_MESSAGES")
502
+ try:
503
+ setattr(proxy, "PROXY_ANALYSIS_ONLY_ROUTE", True)
504
+ setattr(proxy, "PROXY_ANALYSIS_ONLY_MIN_TOOLS", 4)
505
+ setattr(proxy, "PROXY_ANALYSIS_ONLY_MAX_MESSAGES", 2)
506
+
507
+ body = {
508
+ "messages": [
509
+ {
510
+ "role": "user",
511
+ "content": "analyze lifecycle and plan options to improve performance and compliance",
512
+ }
513
+ ],
514
+ "tools": [
515
+ {"name": "Read", "input_schema": {"type": "object"}},
516
+ {"name": "Edit", "input_schema": {"type": "object"}},
517
+ {"name": "Write", "input_schema": {"type": "object"}},
518
+ {"name": "Bash", "input_schema": {"type": "object"}},
519
+ ],
520
+ }
521
+
522
+ updated, removed = proxy._maybe_route_analysis_without_tools(body)
523
+ self.assertEqual(removed, 4)
524
+ self.assertNotIn("tools", updated)
525
+ finally:
526
+ setattr(proxy, "PROXY_ANALYSIS_ONLY_ROUTE", old_route)
527
+ setattr(proxy, "PROXY_ANALYSIS_ONLY_MIN_TOOLS", old_min_tools)
528
+ setattr(proxy, "PROXY_ANALYSIS_ONLY_MAX_MESSAGES", old_max_messages)
529
+
530
+ def test_analysis_only_route_keeps_tools_for_action_prompt(self):
531
+ old_route = getattr(proxy, "PROXY_ANALYSIS_ONLY_ROUTE")
532
+ old_min_tools = getattr(proxy, "PROXY_ANALYSIS_ONLY_MIN_TOOLS")
533
+ old_max_messages = getattr(proxy, "PROXY_ANALYSIS_ONLY_MAX_MESSAGES")
534
+ try:
535
+ setattr(proxy, "PROXY_ANALYSIS_ONLY_ROUTE", True)
536
+ setattr(proxy, "PROXY_ANALYSIS_ONLY_MIN_TOOLS", 4)
537
+ setattr(proxy, "PROXY_ANALYSIS_ONLY_MAX_MESSAGES", 2)
538
+
539
+ body = {
540
+ "messages": [
541
+ {
542
+ "role": "user",
543
+ "content": "analyze failing run and fix the bug",
544
+ }
545
+ ],
546
+ "tools": [
547
+ {"name": "Read", "input_schema": {"type": "object"}},
548
+ {"name": "Edit", "input_schema": {"type": "object"}},
549
+ {"name": "Write", "input_schema": {"type": "object"}},
550
+ {"name": "Bash", "input_schema": {"type": "object"}},
551
+ ],
552
+ }
553
+
554
+ updated, removed = proxy._maybe_route_analysis_without_tools(body)
555
+ self.assertEqual(removed, 0)
556
+ self.assertIn("tools", updated)
557
+ finally:
558
+ setattr(proxy, "PROXY_ANALYSIS_ONLY_ROUTE", old_route)
559
+ setattr(proxy, "PROXY_ANALYSIS_ONLY_MIN_TOOLS", old_min_tools)
560
+ setattr(proxy, "PROXY_ANALYSIS_ONLY_MAX_MESSAGES", old_max_messages)
561
+
486
562
 
487
563
  class TestSessionContaminationBreaker(unittest.TestCase):
488
564
  def test_contamination_breaker_trims_and_resets_streak(self):