@miller-tech/uap 1.15.7 → 1.15.9

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.15.7",
3
+ "version": "1.15.9",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -76,6 +76,7 @@ Dependencies
76
76
  """
77
77
 
78
78
  import asyncio
79
+ import copy
79
80
  import hashlib
80
81
  import json
81
82
  import logging
@@ -305,6 +306,44 @@ def _load_tool_call_grammar(path: str) -> str:
305
306
 
306
307
 
307
308
  TOOL_CALL_GBNF = _load_tool_call_grammar(PROXY_TOOL_CALL_GRAMMAR_PATH)
309
+ TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE = True
310
+
311
+
312
+ def _is_grammar_tools_incompatibility(status_code: int, error_text: str) -> bool:
313
+ if status_code != 400:
314
+ return False
315
+ lowered = (error_text or "").lower()
316
+ return "custom grammar constraints" in lowered and "with tools" in lowered
317
+
318
+
319
+ def _maybe_disable_grammar_for_tools_error(
320
+ request_body: dict,
321
+ status_code: int,
322
+ error_text: str,
323
+ source: str,
324
+ ) -> bool:
325
+ global TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE
326
+
327
+ if "grammar" not in request_body or not request_body.get("tools"):
328
+ return False
329
+ if not _is_grammar_tools_incompatibility(status_code, error_text):
330
+ return False
331
+
332
+ request_body.pop("grammar", None)
333
+ if TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE:
334
+ TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE = False
335
+ logger.warning(
336
+ "Tool-call grammar rejected by upstream for tool turns; "
337
+ "disabling grammar-on-tools for this proxy process (%s)",
338
+ source,
339
+ )
340
+ else:
341
+ logger.warning(
342
+ "Tool-call grammar already disabled for tool turns; retrying %s without grammar",
343
+ source,
344
+ )
345
+
346
+ return True
308
347
 
309
348
 
310
349
  def _apply_tool_call_grammar(
@@ -318,6 +357,9 @@ def _apply_tool_call_grammar(
318
357
  if not request_body.get("tools"):
319
358
  return
320
359
 
360
+ if not TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE:
361
+ return
362
+
321
363
  effective_tool_choice = (
322
364
  tool_choice if tool_choice is not None else request_body.get("tool_choice")
323
365
  )
@@ -937,7 +979,7 @@ async def lifespan(app: FastAPI):
937
979
  _resolve_prune_target_fraction() * 100,
938
980
  )
939
981
  logger.info(
940
- "Guardrails: malformed=%s stream_strict=%s force_non_stream=%s args_preflight=%s tool_narrowing=%s thinking_off_on_tools=%s dampener=%s(%d/%d/%d/%d->%d) contamination_breaker=%s(%d forced=%d required_miss=%d) analysis_only_route=%s(min_tools=%d,max_msgs=%d) grammar=%s(required_only=%s loaded=%s path=%s)",
982
+ "Guardrails: malformed=%s stream_strict=%s force_non_stream=%s args_preflight=%s tool_narrowing=%s thinking_off_on_tools=%s dampener=%s(%d/%d/%d/%d->%d) contamination_breaker=%s(%d forced=%d required_miss=%d) analysis_only_route=%s(min_tools=%d,max_msgs=%d) grammar=%s(required_only=%s loaded=%s tools_compatible=%s path=%s)",
941
983
  PROXY_MALFORMED_TOOL_GUARDRAIL,
942
984
  PROXY_MALFORMED_TOOL_STREAM_STRICT,
943
985
  PROXY_FORCE_NON_STREAM,
@@ -960,6 +1002,7 @@ async def lifespan(app: FastAPI):
960
1002
  PROXY_TOOL_CALL_GRAMMAR,
961
1003
  PROXY_TOOL_CALL_GRAMMAR_REQUIRED_ONLY,
962
1004
  bool(TOOL_CALL_GBNF),
1005
+ TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE,
963
1006
  PROXY_TOOL_CALL_GRAMMAR_PATH,
964
1007
  )
965
1008
 
@@ -1915,6 +1958,20 @@ def _sanitize_markup_value(value):
1915
1958
  return value, False
1916
1959
 
1917
1960
 
1961
+ _REQUIRED_PLACEHOLDER = "__uap_required__"
1962
+ _MISSING_REQUIRED_VALUE = object()
1963
+
1964
+
1965
+ def _contains_required_placeholder(value) -> bool:
1966
+ if isinstance(value, str):
1967
+ return value.strip() == _REQUIRED_PLACEHOLDER
1968
+ if isinstance(value, list):
1969
+ return any(_contains_required_placeholder(item) for item in value)
1970
+ if isinstance(value, dict):
1971
+ return any(_contains_required_placeholder(item) for item in value.values())
1972
+ return False
1973
+
1974
+
1918
1975
  def _repair_tool_call_markup(openai_resp: dict) -> tuple[dict, int]:
1919
1976
  if not _openai_has_tool_calls(openai_resp):
1920
1977
  return openai_resp, 0
@@ -1986,33 +2043,30 @@ def _repair_tool_call_markup(openai_resp: dict) -> tuple[dict, int]:
1986
2043
 
1987
2044
 
1988
2045
  def _default_required_value(field_name: str, field_schema: dict):
1989
- expected_type = field_schema.get("type") if isinstance(field_schema, dict) else None
1990
- if isinstance(expected_type, list):
1991
- expected_type = expected_type[0] if expected_type else "string"
2046
+ _ = field_name
2047
+ if not isinstance(field_schema, dict):
2048
+ return _MISSING_REQUIRED_VALUE
2049
+
2050
+ if "default" in field_schema:
2051
+ default_value = copy.deepcopy(field_schema.get("default"))
2052
+ if not _contains_required_placeholder(default_value):
2053
+ return default_value
2054
+
2055
+ enum_values = field_schema.get("enum")
2056
+ if isinstance(enum_values, list):
2057
+ for candidate in enum_values:
2058
+ if _required_value_is_empty(candidate):
2059
+ continue
2060
+ if _contains_required_placeholder(candidate):
2061
+ continue
2062
+ return copy.deepcopy(candidate)
1992
2063
 
1993
- if expected_type == "integer":
1994
- return 0
1995
- if expected_type == "number":
1996
- return 0
1997
- if expected_type == "boolean":
1998
- return False
1999
- if expected_type == "object":
2000
- return {"value": "__uap_required__"}
2001
- if expected_type == "array":
2002
- return ["__uap_required__"]
2003
-
2004
- key = (field_name or "").lower()
2005
- if key in {"command", "cmd"}:
2006
- return "pwd"
2007
- if key == "cron":
2008
- return "* * * * *"
2009
- if key in {"pattern", "glob"}:
2010
- return "*"
2011
- if key == "subject":
2012
- return "task"
2013
- if key in {"path", "file", "filepath", "file_path"} or key.endswith("_path"):
2014
- return "."
2015
- return "__uap_required__"
2064
+ if "const" in field_schema:
2065
+ const_value = copy.deepcopy(field_schema.get("const"))
2066
+ if not _contains_required_placeholder(const_value):
2067
+ return const_value
2068
+
2069
+ return _MISSING_REQUIRED_VALUE
2016
2070
 
2017
2071
 
2018
2072
  def _repair_required_tool_args(
@@ -2075,7 +2129,10 @@ def _repair_required_tool_args(
2075
2129
  if isinstance(properties.get(field), dict)
2076
2130
  else {}
2077
2131
  )
2078
- parsed_args[field] = _default_required_value(field, field_schema)
2132
+ fallback_value = _default_required_value(field, field_schema)
2133
+ if fallback_value is _MISSING_REQUIRED_VALUE:
2134
+ continue
2135
+ parsed_args[field] = fallback_value
2079
2136
  changed = True
2080
2137
 
2081
2138
  if not changed:
@@ -2298,6 +2355,18 @@ def _validate_tool_call_arguments(
2298
2355
  ),
2299
2356
  )
2300
2357
 
2358
+ if _contains_required_placeholder(parsed):
2359
+ return ToolResponseIssue(
2360
+ kind="invalid_tool_args",
2361
+ reason=(
2362
+ f"arguments for '{tool_name}' contain unresolved placeholder values"
2363
+ ),
2364
+ retry_hint=(
2365
+ f"Emit exactly one `{tool_name}` tool call with real schema-valid arguments. "
2366
+ f"Never emit `{_REQUIRED_PLACEHOLDER}` placeholders."
2367
+ ),
2368
+ )
2369
+
2301
2370
  if not isinstance(tool_schema, dict):
2302
2371
  tool_schema = {}
2303
2372
 
@@ -2312,6 +2381,7 @@ def _validate_tool_call_arguments(
2312
2381
  missing: list[str] = []
2313
2382
  empty: list[str] = []
2314
2383
  wrong_type: list[str] = []
2384
+ enum_mismatch: list[str] = []
2315
2385
 
2316
2386
  for field in required:
2317
2387
  if not isinstance(field, str):
@@ -2334,6 +2404,15 @@ def _validate_tool_call_arguments(
2334
2404
  wrong_type.append(field)
2335
2405
  continue
2336
2406
 
2407
+ enum_values = schema.get("enum")
2408
+ if isinstance(enum_values, list) and enum_values and value not in enum_values:
2409
+ enum_mismatch.append(field)
2410
+ continue
2411
+
2412
+ if "const" in schema and value != schema.get("const"):
2413
+ enum_mismatch.append(field)
2414
+ continue
2415
+
2337
2416
  min_length = schema.get("minLength")
2338
2417
  if (
2339
2418
  isinstance(min_length, int)
@@ -2351,7 +2430,7 @@ def _validate_tool_call_arguments(
2351
2430
  ):
2352
2431
  empty.append(field)
2353
2432
 
2354
- if missing or empty or wrong_type:
2433
+ if missing or empty or wrong_type or enum_mismatch:
2355
2434
  details = []
2356
2435
  if missing:
2357
2436
  details.append(f"missing: {', '.join(missing)}")
@@ -2359,6 +2438,8 @@ def _validate_tool_call_arguments(
2359
2438
  details.append(f"empty: {', '.join(empty)}")
2360
2439
  if wrong_type:
2361
2440
  details.append(f"type mismatch: {', '.join(wrong_type)}")
2441
+ if enum_mismatch:
2442
+ details.append(f"enum mismatch: {', '.join(enum_mismatch)}")
2362
2443
  required_fields = ", ".join(str(f) for f in required if isinstance(f, str))
2363
2444
  required_hint = (
2364
2445
  f"Required fields must be non-empty: {required_fields}. "
@@ -3442,6 +3523,20 @@ async def messages(request: Request):
3442
3523
  headers={"Content-Type": "application/json"},
3443
3524
  )
3444
3525
 
3526
+ if strict_resp.status_code != 200:
3527
+ error_text = strict_resp.text[:1000]
3528
+ if _maybe_disable_grammar_for_tools_error(
3529
+ strict_body,
3530
+ strict_resp.status_code,
3531
+ error_text,
3532
+ "strict-stream",
3533
+ ):
3534
+ strict_resp = await client.post(
3535
+ f"{LLAMA_CPP_BASE}/chat/completions",
3536
+ json=strict_body,
3537
+ headers={"Content-Type": "application/json"},
3538
+ )
3539
+
3445
3540
  if strict_resp.status_code != 200:
3446
3541
  error_text = strict_resp.text[:1000]
3447
3542
  logger.error(
@@ -3582,6 +3677,35 @@ async def messages(request: Request):
3582
3677
  error_body = await resp.aread()
3583
3678
  await resp.aclose()
3584
3679
  error_text = error_body.decode("utf-8", errors="replace")[:1000]
3680
+ if _maybe_disable_grammar_for_tools_error(
3681
+ openai_body,
3682
+ resp.status_code,
3683
+ error_text,
3684
+ "stream",
3685
+ ):
3686
+ resp = await client.send(
3687
+ client.build_request(
3688
+ "POST",
3689
+ f"{LLAMA_CPP_BASE}/chat/completions",
3690
+ json=openai_body,
3691
+ headers={"Content-Type": "application/json"},
3692
+ ),
3693
+ stream=True,
3694
+ )
3695
+ if resp.status_code == 200:
3696
+ return StreamingResponse(
3697
+ stream_anthropic_response(resp, model, monitor, body),
3698
+ media_type="text/event-stream",
3699
+ headers={
3700
+ "Cache-Control": "no-cache",
3701
+ "Connection": "keep-alive",
3702
+ },
3703
+ )
3704
+
3705
+ error_body = await resp.aread()
3706
+ await resp.aclose()
3707
+ error_text = error_body.decode("utf-8", errors="replace")[:1000]
3708
+
3585
3709
  logger.error("Upstream HTTP %d: %s", resp.status_code, error_text)
3586
3710
 
3587
3711
  # Parse the error for a user-friendly message
@@ -3669,6 +3793,20 @@ async def messages(request: Request):
3669
3793
  headers={"Content-Type": "application/json"},
3670
3794
  )
3671
3795
 
3796
+ if resp.status_code != 200:
3797
+ error_text = resp.text[:1000]
3798
+ if _maybe_disable_grammar_for_tools_error(
3799
+ openai_body,
3800
+ resp.status_code,
3801
+ error_text,
3802
+ "non-stream",
3803
+ ):
3804
+ resp = await client.post(
3805
+ f"{LLAMA_CPP_BASE}/chat/completions",
3806
+ json=openai_body,
3807
+ headers={"Content-Type": "application/json"},
3808
+ )
3809
+
3672
3810
  # Option B: Handle non-streaming errors too
3673
3811
  if resp.status_code != 200:
3674
3812
  error_text = resp.text[:1000]
@@ -3812,6 +3950,7 @@ async def context_status(request: Request):
3812
3950
  "required_only": PROXY_TOOL_CALL_GRAMMAR_REQUIRED_ONLY,
3813
3951
  "path": PROXY_TOOL_CALL_GRAMMAR_PATH,
3814
3952
  "loaded": bool(TOOL_CALL_GBNF),
3953
+ "tools_compatible": TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE,
3815
3954
  },
3816
3955
  # Loop protection stats
3817
3956
  "loop_protection": {
@@ -518,10 +518,12 @@ class TestMalformedToolGuardrail(unittest.TestCase):
518
518
  old_enabled = getattr(proxy, "PROXY_TOOL_CALL_GRAMMAR")
519
519
  old_required_only = getattr(proxy, "PROXY_TOOL_CALL_GRAMMAR_REQUIRED_ONLY")
520
520
  old_grammar = getattr(proxy, "TOOL_CALL_GBNF")
521
+ old_tools_compatible = getattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE")
521
522
  try:
522
523
  setattr(proxy, "PROXY_TOOL_CALL_GRAMMAR", True)
523
524
  setattr(proxy, "PROXY_TOOL_CALL_GRAMMAR_REQUIRED_ONLY", True)
524
525
  setattr(proxy, "TOOL_CALL_GBNF", 'root ::= "<tool_call>"')
526
+ setattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE", True)
525
527
 
526
528
  openai_body = {
527
529
  "model": "test",
@@ -548,6 +550,56 @@ class TestMalformedToolGuardrail(unittest.TestCase):
548
550
  setattr(proxy, "PROXY_TOOL_CALL_GRAMMAR", old_enabled)
549
551
  setattr(proxy, "PROXY_TOOL_CALL_GRAMMAR_REQUIRED_ONLY", old_required_only)
550
552
  setattr(proxy, "TOOL_CALL_GBNF", old_grammar)
553
+ setattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE", old_tools_compatible)
554
+
555
+ def test_apply_tool_call_grammar_skips_when_upstream_tools_are_incompatible(self):
556
+ old_enabled = getattr(proxy, "PROXY_TOOL_CALL_GRAMMAR")
557
+ old_required_only = getattr(proxy, "PROXY_TOOL_CALL_GRAMMAR_REQUIRED_ONLY")
558
+ old_grammar = getattr(proxy, "TOOL_CALL_GBNF")
559
+ old_tools_compatible = getattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE")
560
+ try:
561
+ setattr(proxy, "PROXY_TOOL_CALL_GRAMMAR", True)
562
+ setattr(proxy, "PROXY_TOOL_CALL_GRAMMAR_REQUIRED_ONLY", True)
563
+ setattr(proxy, "TOOL_CALL_GBNF", 'root ::= "<tool_call>"')
564
+ setattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE", False)
565
+
566
+ request = {
567
+ "tools": [{"type": "function", "function": {"name": "Read"}}],
568
+ "tool_choice": "required",
569
+ }
570
+ proxy._apply_tool_call_grammar(request)
571
+
572
+ self.assertNotIn("grammar", request)
573
+ finally:
574
+ setattr(proxy, "PROXY_TOOL_CALL_GRAMMAR", old_enabled)
575
+ setattr(proxy, "PROXY_TOOL_CALL_GRAMMAR_REQUIRED_ONLY", old_required_only)
576
+ setattr(proxy, "TOOL_CALL_GBNF", old_grammar)
577
+ setattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE", old_tools_compatible)
578
+
579
+ def test_maybe_disable_grammar_for_tools_error_strips_grammar_and_disables_flag(
580
+ self,
581
+ ):
582
+ old_tools_compatible = getattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE")
583
+ try:
584
+ setattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE", True)
585
+
586
+ request = {
587
+ "tools": [{"type": "function", "function": {"name": "Read"}}],
588
+ "grammar": 'root ::= "<tool_call>"',
589
+ }
590
+
591
+ retried = proxy._maybe_disable_grammar_for_tools_error(
592
+ request,
593
+ 400,
594
+ '{"error":{"message":"Cannot use custom grammar constraints with tools."}}',
595
+ "unit-test",
596
+ )
597
+
598
+ self.assertTrue(retried)
599
+ self.assertNotIn("grammar", request)
600
+ self.assertFalse(getattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE"))
601
+ finally:
602
+ setattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE", old_tools_compatible)
551
603
 
552
604
  def test_clean_guardrail_response_does_not_promise_future_tool_call(self):
553
605
  guardrail = proxy._build_clean_guardrail_openai_response(
@@ -643,7 +695,11 @@ class TestMalformedToolGuardrail(unittest.TestCase):
643
695
  "type": "object",
644
696
  "required": ["cron", "command"],
645
697
  "properties": {
646
- "cron": {"type": "string", "minLength": 1},
698
+ "cron": {
699
+ "type": "string",
700
+ "minLength": 1,
701
+ "default": "* * * * *",
702
+ },
647
703
  "command": {"type": "string", "minLength": 1},
648
704
  },
649
705
  },
@@ -956,9 +1012,21 @@ class TestMalformedToolGuardrail(unittest.TestCase):
956
1012
  "type": "object",
957
1013
  "required": ["cron", "pattern", "subject"],
958
1014
  "properties": {
959
- "cron": {"type": "string", "minLength": 1},
960
- "pattern": {"type": "string", "minLength": 1},
961
- "subject": {"type": "string", "minLength": 1},
1015
+ "cron": {
1016
+ "type": "string",
1017
+ "minLength": 1,
1018
+ "default": "* * * * *",
1019
+ },
1020
+ "pattern": {
1021
+ "type": "string",
1022
+ "minLength": 1,
1023
+ "default": "*",
1024
+ },
1025
+ "subject": {
1026
+ "type": "string",
1027
+ "minLength": 1,
1028
+ "default": "task",
1029
+ },
962
1030
  },
963
1031
  },
964
1032
  }
@@ -1008,9 +1076,21 @@ class TestMalformedToolGuardrail(unittest.TestCase):
1008
1076
  "type": "object",
1009
1077
  "required": ["cron", "pattern", "subject"],
1010
1078
  "properties": {
1011
- "cron": {"type": "string", "minLength": 1},
1012
- "pattern": {"type": "string", "minLength": 1},
1013
- "subject": {"type": "string", "minLength": 1},
1079
+ "cron": {
1080
+ "type": "string",
1081
+ "minLength": 1,
1082
+ "default": "* * * * *",
1083
+ },
1084
+ "pattern": {
1085
+ "type": "string",
1086
+ "minLength": 1,
1087
+ "default": "*",
1088
+ },
1089
+ "subject": {
1090
+ "type": "string",
1091
+ "minLength": 1,
1092
+ "default": "task",
1093
+ },
1014
1094
  },
1015
1095
  },
1016
1096
  }
@@ -1134,10 +1214,7 @@ class TestMalformedToolGuardrail(unittest.TestCase):
1134
1214
  )
1135
1215
  self.assertTrue(args["cron"].strip())
1136
1216
  self.assertTrue(args["command"].strip())
1137
- self.assertTrue(
1138
- monitor.arg_preflight_repairs >= 1
1139
- or monitor.arg_preflight_rejections >= 1
1140
- )
1217
+ self.assertGreaterEqual(len(fake_client.requests), 1)
1141
1218
  if fake_client.requests:
1142
1219
  retry_payload = fake_client.requests[0]["kwargs"]["json"]
1143
1220
  repair_message = retry_payload["messages"][-1]["content"]
@@ -1488,6 +1565,139 @@ class TestToolTurnControls(unittest.TestCase):
1488
1565
  setattr(proxy, "PROXY_ANALYSIS_ONLY_MAX_MESSAGES", old_max_messages)
1489
1566
 
1490
1567
 
1568
+ class TestRequiredArgRepair(unittest.TestCase):
1569
+ def test_repair_required_args_uses_schema_enum_value(self):
1570
+ openai_resp = {
1571
+ "choices": [
1572
+ {
1573
+ "message": {
1574
+ "tool_calls": [
1575
+ {
1576
+ "id": "call_1",
1577
+ "function": {
1578
+ "name": "omp_task",
1579
+ "arguments": '{"prompt":"analyze"}',
1580
+ },
1581
+ }
1582
+ ]
1583
+ }
1584
+ }
1585
+ ]
1586
+ }
1587
+ anthropic_body = {
1588
+ "tools": [
1589
+ {
1590
+ "name": "omp_task",
1591
+ "input_schema": {
1592
+ "type": "object",
1593
+ "required": ["agent", "prompt"],
1594
+ "properties": {
1595
+ "agent": {
1596
+ "type": "string",
1597
+ "enum": ["task", "explore", "plan"],
1598
+ },
1599
+ "prompt": {"type": "string"},
1600
+ },
1601
+ },
1602
+ }
1603
+ ]
1604
+ }
1605
+
1606
+ repaired, repaired_count = proxy._repair_required_tool_args(
1607
+ openai_resp, anthropic_body
1608
+ )
1609
+
1610
+ self.assertEqual(repaired_count, 1)
1611
+ args = json.loads(
1612
+ repaired["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"]
1613
+ )
1614
+ self.assertEqual(args["agent"], "task")
1615
+
1616
+ def test_repair_required_args_does_not_inject_placeholder_without_schema_defaults(
1617
+ self,
1618
+ ):
1619
+ openai_resp = {
1620
+ "choices": [
1621
+ {
1622
+ "message": {
1623
+ "tool_calls": [
1624
+ {
1625
+ "id": "call_1",
1626
+ "function": {
1627
+ "name": "omp_task",
1628
+ "arguments": '{"prompt":"analyze"}',
1629
+ },
1630
+ }
1631
+ ]
1632
+ }
1633
+ }
1634
+ ]
1635
+ }
1636
+ anthropic_body = {
1637
+ "tools": [
1638
+ {
1639
+ "name": "omp_task",
1640
+ "input_schema": {
1641
+ "type": "object",
1642
+ "required": ["agent", "prompt"],
1643
+ "properties": {
1644
+ "agent": {"type": "string"},
1645
+ "prompt": {"type": "string"},
1646
+ },
1647
+ },
1648
+ }
1649
+ ]
1650
+ }
1651
+
1652
+ repaired, repaired_count = proxy._repair_required_tool_args(
1653
+ openai_resp, anthropic_body
1654
+ )
1655
+
1656
+ self.assertEqual(repaired_count, 0)
1657
+ args = json.loads(
1658
+ repaired["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"]
1659
+ )
1660
+ self.assertNotIn("agent", args)
1661
+
1662
+ def test_validate_tool_args_rejects_placeholder_values(self):
1663
+ issue = proxy._validate_tool_call_arguments(
1664
+ "omp_task",
1665
+ '{"agent":"__uap_required__","prompt":"analyze"}',
1666
+ {
1667
+ "type": "object",
1668
+ "required": ["agent", "prompt"],
1669
+ "properties": {
1670
+ "agent": {"type": "string", "enum": ["task", "explore"]},
1671
+ "prompt": {"type": "string"},
1672
+ },
1673
+ },
1674
+ {"omp_task"},
1675
+ )
1676
+
1677
+ self.assertTrue(issue.has_issue())
1678
+ self.assertEqual(issue.kind, "invalid_tool_args")
1679
+ self.assertIn("placeholder", issue.reason)
1680
+
1681
+ def test_validate_tool_args_rejects_enum_mismatch(self):
1682
+ issue = proxy._validate_tool_call_arguments(
1683
+ "omp_task",
1684
+ '{"agent":"planner","prompt":"analyze"}',
1685
+ {
1686
+ "type": "object",
1687
+ "required": ["agent", "prompt"],
1688
+ "properties": {
1689
+ "agent": {"type": "string", "enum": ["task", "explore"]},
1690
+ "prompt": {"type": "string"},
1691
+ },
1692
+ },
1693
+ {"omp_task"},
1694
+ )
1695
+
1696
+ self.assertTrue(issue.has_issue())
1697
+ self.assertEqual(issue.kind, "invalid_tool_args")
1698
+ self.assertIn("enum mismatch", issue.reason)
1699
+
1700
+
1491
1701
  class TestSessionContaminationBreaker(unittest.TestCase):
1492
1702
  def test_contamination_breaker_trims_and_resets_streak(self):
1493
1703
  old_enabled = getattr(proxy, "PROXY_SESSION_CONTAMINATION_BREAKER")