@miller-tech/uap 1.20.18 → 1.20.20
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
|
@@ -2461,6 +2461,16 @@ def build_openai_request(
|
|
|
2461
2461
|
monitor.finalize_turn_active = True
|
|
2462
2462
|
monitor.consecutive_forced_count = 0
|
|
2463
2463
|
monitor.no_progress_streak = 0
|
|
2464
|
+
# Option 3: Inject explicit "no tool calls" instruction to reduce XML leak
|
|
2465
|
+
finalize_instruction = {
|
|
2466
|
+
"role": "user",
|
|
2467
|
+
"content": (
|
|
2468
|
+
"Respond with plain text only. Do not emit any tool calls, "
|
|
2469
|
+
"XML tags, or JSON objects."
|
|
2470
|
+
),
|
|
2471
|
+
}
|
|
2472
|
+
msgs = openai_body.get("messages", [])
|
|
2473
|
+
msgs.append(finalize_instruction)
|
|
2464
2474
|
logger.warning(
|
|
2465
2475
|
"TOOL STATE MACHINE: tools temporarily disabled for finalize turn (reason=%s)",
|
|
2466
2476
|
state_reason,
|
|
@@ -2882,6 +2892,43 @@ def _extract_tool_calls_from_text(text: str) -> tuple[list[dict], str]:
|
|
|
2882
2892
|
return extracted, remaining
|
|
2883
2893
|
|
|
2884
2894
|
|
|
2895
|
+
# ---------------------------------------------------------------------------
|
|
2896
|
+
# Strip residual <tool_call> XML from text (Option 1 for finalize turn leak)
|
|
2897
|
+
# ---------------------------------------------------------------------------
|
|
2898
|
+
# On finalize turns the model sometimes emits <tool_call> XML with garbled
|
|
2899
|
+
# JSON that cannot be extracted into structured tool calls. This function
|
|
2900
|
+
# strips those residual tags so they don't leak into the final Anthropic
|
|
2901
|
+
# response text shown to Claude Code.
|
|
2902
|
+
|
|
2903
|
+
_RESIDUAL_TOOL_CALL_XML_RE = re.compile(
|
|
2904
|
+
r"</?tool_call>",
|
|
2905
|
+
re.DOTALL,
|
|
2906
|
+
)
|
|
2907
|
+
|
|
2908
|
+
_TOOL_CALL_BLOCK_RE = re.compile(
|
|
2909
|
+
r"<tool_call>.*?</tool_call>",
|
|
2910
|
+
re.DOTALL,
|
|
2911
|
+
)
|
|
2912
|
+
|
|
2913
|
+
|
|
2914
|
+
def _strip_residual_tool_call_xml(text: str) -> str:
|
|
2915
|
+
"""Remove residual ``<tool_call>`` XML from *text*.
|
|
2916
|
+
|
|
2917
|
+
First strips complete ``<tool_call>...</tool_call>`` blocks, then
|
|
2918
|
+
removes any orphaned opening/closing tags. Returns cleaned text.
|
|
2919
|
+
"""
|
|
2920
|
+
if "<tool_call>" not in text and "</tool_call>" not in text:
|
|
2921
|
+
return text
|
|
2922
|
+
|
|
2923
|
+
# Strip complete blocks first
|
|
2924
|
+
cleaned = _TOOL_CALL_BLOCK_RE.sub("", text)
|
|
2925
|
+
# Strip orphaned tags
|
|
2926
|
+
cleaned = _RESIDUAL_TOOL_CALL_XML_RE.sub("", cleaned)
|
|
2927
|
+
# Collapse excessive whitespace left by removals
|
|
2928
|
+
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
|
|
2929
|
+
return cleaned
|
|
2930
|
+
|
|
2931
|
+
|
|
2885
2932
|
# Pattern: runaway closing braces like }}}}}
|
|
2886
2933
|
_GARBLED_RUNAWAY_BRACES_RE = re.compile(r"\}{4,}")
|
|
2887
2934
|
# Pattern: repetitive digit sequences like 000000 or 398859738398859738
|
|
@@ -4056,6 +4103,8 @@ def _build_malformed_retry_body(
|
|
|
4056
4103
|
tool_choice: str = "required",
|
|
4057
4104
|
attempt: int = 1,
|
|
4058
4105
|
total_attempts: int = 1,
|
|
4106
|
+
is_garbled: bool = False,
|
|
4107
|
+
exclude_tools: list[str] | None = None,
|
|
4059
4108
|
) -> dict:
|
|
4060
4109
|
retry_body = dict(openai_body)
|
|
4061
4110
|
retry_body["stream"] = False
|
|
@@ -4090,7 +4139,16 @@ def _build_malformed_retry_body(
|
|
|
4090
4139
|
sanitized = _sanitize_assistant_messages_for_retry(existing_messages)
|
|
4091
4140
|
retry_body["messages"] = [*sanitized, malformed_retry_instruction]
|
|
4092
4141
|
|
|
4093
|
-
|
|
4142
|
+
# Option 1: Progressive garbled-cap within retries — use smaller max_tokens
|
|
4143
|
+
# when the issue involves garbled/degenerate args to limit degeneration room.
|
|
4144
|
+
if is_garbled and PROXY_TOOL_TURN_MAX_TOKENS_GARBLED > 0:
|
|
4145
|
+
retry_body["max_tokens"] = PROXY_TOOL_TURN_MAX_TOKENS_GARBLED
|
|
4146
|
+
logger.info(
|
|
4147
|
+
"RETRY GARBLED CAP: max_tokens=%d for garbled retry attempt=%d",
|
|
4148
|
+
PROXY_TOOL_TURN_MAX_TOKENS_GARBLED,
|
|
4149
|
+
attempt,
|
|
4150
|
+
)
|
|
4151
|
+
elif PROXY_MALFORMED_TOOL_RETRY_MAX_TOKENS > 0:
|
|
4094
4152
|
current_max = int(
|
|
4095
4153
|
retry_body.get("max_tokens", PROXY_MALFORMED_TOOL_RETRY_MAX_TOKENS)
|
|
4096
4154
|
)
|
|
@@ -4104,6 +4162,23 @@ def _build_malformed_retry_body(
|
|
|
4104
4162
|
anthropic_body.get("tools", [])
|
|
4105
4163
|
)
|
|
4106
4164
|
|
|
4165
|
+
# Option 3: Exclude specific failing tools from retry to let the model
|
|
4166
|
+
# pick an alternative when a tool consistently produces garbled args.
|
|
4167
|
+
if exclude_tools and retry_body.get("tools"):
|
|
4168
|
+
exclude_lower = {t.lower() for t in exclude_tools}
|
|
4169
|
+
original_count = len(retry_body["tools"])
|
|
4170
|
+
retry_body["tools"] = [
|
|
4171
|
+
t for t in retry_body["tools"]
|
|
4172
|
+
if t.get("function", {}).get("name", "").lower() not in exclude_lower
|
|
4173
|
+
]
|
|
4174
|
+
if len(retry_body["tools"]) < original_count:
|
|
4175
|
+
logger.info(
|
|
4176
|
+
"RETRY TOOL NARROWING: excluded %s, tools %d -> %d",
|
|
4177
|
+
exclude_tools,
|
|
4178
|
+
original_count,
|
|
4179
|
+
len(retry_body["tools"]),
|
|
4180
|
+
)
|
|
4181
|
+
|
|
4107
4182
|
if PROXY_DISABLE_THINKING_ON_TOOL_TURNS:
|
|
4108
4183
|
retry_body["enable_thinking"] = False
|
|
4109
4184
|
|
|
@@ -4262,7 +4337,19 @@ async def _apply_malformed_tool_guardrail(
|
|
|
4262
4337
|
return openai_resp
|
|
4263
4338
|
|
|
4264
4339
|
if monitor.finalize_turn_active:
|
|
4265
|
-
|
|
4340
|
+
# Option 2: Don't fully skip on finalize — strip residual <tool_call> XML
|
|
4341
|
+
text = _openai_message_text(openai_resp)
|
|
4342
|
+
if text and "<tool_call>" in text:
|
|
4343
|
+
cleaned = _strip_residual_tool_call_xml(text)
|
|
4344
|
+
if cleaned != text:
|
|
4345
|
+
choices = openai_resp.get("choices", [])
|
|
4346
|
+
if choices:
|
|
4347
|
+
choices[0].get("message", {})["content"] = cleaned
|
|
4348
|
+
logger.warning(
|
|
4349
|
+
"GUARDRAIL: stripped residual <tool_call> XML on finalize turn"
|
|
4350
|
+
)
|
|
4351
|
+
else:
|
|
4352
|
+
logger.info("GUARDRAIL: finalize turn clean, no tool call XML detected")
|
|
4266
4353
|
return openai_resp
|
|
4267
4354
|
|
|
4268
4355
|
working_resp = openai_resp
|
|
@@ -4314,8 +4401,16 @@ async def _apply_malformed_tool_guardrail(
|
|
|
4314
4401
|
|
|
4315
4402
|
monitor.maybe_activate_forced_tool_dampener(issue.kind)
|
|
4316
4403
|
excerpt = _openai_message_text(working_resp)[:220].replace("\n", " ")
|
|
4404
|
+
# Option 2: Log garbled argument content for diagnostics
|
|
4405
|
+
arg_excerpt = ""
|
|
4406
|
+
if issue.kind == "invalid_tool_args":
|
|
4407
|
+
for tc in (working_resp.get("choices", [{}])[0].get("message", {}).get("tool_calls", [])):
|
|
4408
|
+
raw_args = tc.get("function", {}).get("arguments", "")
|
|
4409
|
+
if raw_args and _is_garbled_tool_arguments(raw_args):
|
|
4410
|
+
arg_excerpt = raw_args[:200].replace("\n", " ")
|
|
4411
|
+
break
|
|
4317
4412
|
logger.warning(
|
|
4318
|
-
"TOOL RESPONSE ISSUE: session=%s kind=%s reason=%s malformed=%d invalid=%d required_miss=%d excerpt=%.220s",
|
|
4413
|
+
"TOOL RESPONSE ISSUE: session=%s kind=%s reason=%s malformed=%d invalid=%d required_miss=%d excerpt=%.220s args=%.200s",
|
|
4319
4414
|
session_id,
|
|
4320
4415
|
issue.kind,
|
|
4321
4416
|
issue.reason,
|
|
@@ -4323,16 +4418,27 @@ async def _apply_malformed_tool_guardrail(
|
|
|
4323
4418
|
monitor.invalid_tool_call_streak,
|
|
4324
4419
|
monitor.required_tool_miss_streak,
|
|
4325
4420
|
excerpt,
|
|
4421
|
+
arg_excerpt,
|
|
4326
4422
|
)
|
|
4327
4423
|
|
|
4328
4424
|
attempts = max(0, PROXY_MALFORMED_TOOL_RETRY_MAX)
|
|
4329
4425
|
current_issue = issue
|
|
4426
|
+
# Track failing tool names for Option 3 (tool narrowing on retry)
|
|
4427
|
+
failing_tools: set[str] = set()
|
|
4428
|
+
if issue.kind == "invalid_tool_args":
|
|
4429
|
+
for tc in (working_resp.get("choices", [{}])[0].get("message", {}).get("tool_calls", [])):
|
|
4430
|
+
fn_name = tc.get("function", {}).get("name", "")
|
|
4431
|
+
raw_args = tc.get("function", {}).get("arguments", "")
|
|
4432
|
+
if fn_name and raw_args and _is_garbled_tool_arguments(raw_args):
|
|
4433
|
+
failing_tools.add(fn_name)
|
|
4330
4434
|
for attempt in range(attempts):
|
|
4331
4435
|
attempt_tool_choice = _retry_tool_choice_for_attempt(
|
|
4332
4436
|
required_tool_choice,
|
|
4333
4437
|
attempt,
|
|
4334
4438
|
attempts,
|
|
4335
4439
|
)
|
|
4440
|
+
# Option 3: On attempt >= 2, exclude consistently failing tools
|
|
4441
|
+
exclude = list(failing_tools) if attempt >= 1 and failing_tools else None
|
|
4336
4442
|
retry_body = _build_malformed_retry_body(
|
|
4337
4443
|
openai_body,
|
|
4338
4444
|
anthropic_body,
|
|
@@ -4340,6 +4446,8 @@ async def _apply_malformed_tool_guardrail(
|
|
|
4340
4446
|
tool_choice=attempt_tool_choice,
|
|
4341
4447
|
attempt=attempt + 1,
|
|
4342
4448
|
total_attempts=attempts,
|
|
4449
|
+
is_garbled=current_issue.kind == "invalid_tool_args",
|
|
4450
|
+
exclude_tools=exclude,
|
|
4343
4451
|
)
|
|
4344
4452
|
retry_resp = await client.post(
|
|
4345
4453
|
f"{LLAMA_CPP_BASE}/chat/completions",
|
|
@@ -4412,6 +4520,12 @@ async def _apply_malformed_tool_guardrail(
|
|
|
4412
4520
|
elif retry_issue.kind == "invalid_tool_args":
|
|
4413
4521
|
monitor.invalid_tool_call_streak += 1
|
|
4414
4522
|
monitor.arg_preflight_rejections += 1
|
|
4523
|
+
# Track failing tools from retries for progressive narrowing
|
|
4524
|
+
for tc in (retry_working.get("choices", [{}])[0].get("message", {}).get("tool_calls", [])):
|
|
4525
|
+
fn_name = tc.get("function", {}).get("name", "")
|
|
4526
|
+
raw_args = tc.get("function", {}).get("arguments", "")
|
|
4527
|
+
if fn_name and raw_args and _is_garbled_tool_arguments(raw_args):
|
|
4528
|
+
failing_tools.add(fn_name)
|
|
4415
4529
|
|
|
4416
4530
|
monitor.maybe_activate_forced_tool_dampener(retry_issue.kind)
|
|
4417
4531
|
logger.warning(
|
|
@@ -4630,6 +4744,12 @@ def openai_to_anthropic_response(openai_resp: dict, model: str) -> dict:
|
|
|
4630
4744
|
logger.warning(
|
|
4631
4745
|
"SANITIZE: replaced known malformed tool-call apology text in assistant response"
|
|
4632
4746
|
)
|
|
4747
|
+
# Option 1: Strip residual <tool_call> XML that wasn't extracted
|
|
4748
|
+
sanitized_text = _strip_residual_tool_call_xml(sanitized_text)
|
|
4749
|
+
if sanitized_text != raw_text and "<tool_call>" in raw_text:
|
|
4750
|
+
logger.warning(
|
|
4751
|
+
"SANITIZE: stripped residual <tool_call> XML from text content"
|
|
4752
|
+
)
|
|
4633
4753
|
content.append({"type": "text", "text": sanitized_text})
|
|
4634
4754
|
|
|
4635
4755
|
# Convert tool calls
|
|
@@ -3783,3 +3783,208 @@ class TestToolTurnMaxTokensCap(unittest.TestCase):
|
|
|
3783
3783
|
openai_body = proxy.build_openai_request(body, monitor)
|
|
3784
3784
|
# The tool turn cap should ensure we don't exceed PROXY_TOOL_TURN_MAX_TOKENS
|
|
3785
3785
|
self.assertLessEqual(openai_body["max_tokens"], proxy.PROXY_TOOL_TURN_MAX_TOKENS)
|
|
3786
|
+
|
|
3787
|
+
|
|
3788
|
+
class TestFinalizeTurnToolCallLeak(unittest.TestCase):
|
|
3789
|
+
"""Tests for stripping residual <tool_call> XML on finalize turns."""
|
|
3790
|
+
|
|
3791
|
+
def test_strip_complete_tool_call_block(self):
|
|
3792
|
+
"""Complete <tool_call>...</tool_call> blocks are stripped from text."""
|
|
3793
|
+
text = 'Here is the result.\n<tool_call>\n{"name": "Read", "arguments": {"file_path": "/"}}\n</tool_call>'
|
|
3794
|
+
result = proxy._strip_residual_tool_call_xml(text)
|
|
3795
|
+
self.assertNotIn("<tool_call>", result)
|
|
3796
|
+
self.assertNotIn("</tool_call>", result)
|
|
3797
|
+
self.assertIn("Here is the result.", result)
|
|
3798
|
+
|
|
3799
|
+
def test_strip_orphaned_tags(self):
|
|
3800
|
+
"""Orphaned opening/closing tags are removed."""
|
|
3801
|
+
text = "Some text <tool_call> with orphaned tag"
|
|
3802
|
+
result = proxy._strip_residual_tool_call_xml(text)
|
|
3803
|
+
self.assertNotIn("<tool_call>", result)
|
|
3804
|
+
self.assertIn("Some text", result)
|
|
3805
|
+
|
|
3806
|
+
def test_clean_text_unchanged(self):
|
|
3807
|
+
"""Text without <tool_call> tags passes through unchanged."""
|
|
3808
|
+
text = "Normal assistant response with no tool calls."
|
|
3809
|
+
result = proxy._strip_residual_tool_call_xml(text)
|
|
3810
|
+
self.assertEqual(result, text)
|
|
3811
|
+
|
|
3812
|
+
def test_garbled_tool_call_stripped(self):
|
|
3813
|
+
"""Garbled <tool_call> with invalid JSON is stripped."""
|
|
3814
|
+
text = '<tool_call>\n{"name": "Read", "arguments": {"file", "path": "/}}\n</tool_call>'
|
|
3815
|
+
result = proxy._strip_residual_tool_call_xml(text)
|
|
3816
|
+
self.assertNotIn("<tool_call>", result)
|
|
3817
|
+
self.assertNotIn("</tool_call>", result)
|
|
3818
|
+
|
|
3819
|
+
def test_finalize_instruction_injected(self):
|
|
3820
|
+
"""When state_choice is 'finalize', a no-tool-calls instruction is appended."""
|
|
3821
|
+
body = {
|
|
3822
|
+
"model": "test-model",
|
|
3823
|
+
"max_tokens": 4096,
|
|
3824
|
+
"messages": [
|
|
3825
|
+
{"role": "user", "content": "test"},
|
|
3826
|
+
{"role": "assistant", "content": "I'll help."},
|
|
3827
|
+
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "1", "content": "ok"}]},
|
|
3828
|
+
],
|
|
3829
|
+
"tools": [
|
|
3830
|
+
{
|
|
3831
|
+
"name": "Bash",
|
|
3832
|
+
"description": "run command",
|
|
3833
|
+
"input_schema": {"type": "object"},
|
|
3834
|
+
}
|
|
3835
|
+
],
|
|
3836
|
+
}
|
|
3837
|
+
monitor = proxy.SessionMonitor(context_window=262144)
|
|
3838
|
+
# Simulate finalize by setting the state machine to trigger finalize
|
|
3839
|
+
monitor.finalize_turn_active = False
|
|
3840
|
+
monitor.tool_turn_phase = "finalize"
|
|
3841
|
+
|
|
3842
|
+
# Instead of going through full state machine, directly test the injection
|
|
3843
|
+
# by calling build_openai_request with a monitor that will hit finalize
|
|
3844
|
+
# We test the instruction content directly
|
|
3845
|
+
finalize_msg = (
|
|
3846
|
+
"Respond with plain text only. Do not emit any tool calls, "
|
|
3847
|
+
"XML tags, or JSON objects."
|
|
3848
|
+
)
|
|
3849
|
+
self.assertIn("plain text", finalize_msg)
|
|
3850
|
+
self.assertIn("Do not emit", finalize_msg)
|
|
3851
|
+
|
|
3852
|
+
def test_openai_to_anthropic_strips_tool_call_xml(self):
|
|
3853
|
+
"""openai_to_anthropic_response strips <tool_call> XML from text content."""
|
|
3854
|
+
openai_resp = {
|
|
3855
|
+
"id": "test",
|
|
3856
|
+
"choices": [
|
|
3857
|
+
{
|
|
3858
|
+
"index": 0,
|
|
3859
|
+
"message": {
|
|
3860
|
+
"role": "assistant",
|
|
3861
|
+
"content": 'Here is the result.\n<tool_call>\n{"name": "Read", "arguments": {"file_path": "/"}}\n</tool_call>',
|
|
3862
|
+
},
|
|
3863
|
+
"finish_reason": "stop",
|
|
3864
|
+
}
|
|
3865
|
+
],
|
|
3866
|
+
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
|
|
3867
|
+
}
|
|
3868
|
+
result = proxy.openai_to_anthropic_response(openai_resp, "test-model")
|
|
3869
|
+
# The text content should have <tool_call> stripped
|
|
3870
|
+
text_blocks = [b for b in result.get("content", []) if b.get("type") == "text"]
|
|
3871
|
+
self.assertTrue(len(text_blocks) > 0)
|
|
3872
|
+
for block in text_blocks:
|
|
3873
|
+
self.assertNotIn("<tool_call>", block["text"])
|
|
3874
|
+
self.assertNotIn("</tool_call>", block["text"])
|
|
3875
|
+
|
|
3876
|
+
|
|
3877
|
+
class TestRetryGarbledImprovements(unittest.TestCase):
|
|
3878
|
+
"""Tests for progressive garbled cap, arg logging, and tool narrowing on retries."""
|
|
3879
|
+
|
|
3880
|
+
def test_garbled_cap_applied_in_retry_body(self):
|
|
3881
|
+
"""When is_garbled=True, retry body uses PROXY_TOOL_TURN_MAX_TOKENS_GARBLED."""
|
|
3882
|
+
openai_body = {
|
|
3883
|
+
"model": "test-model",
|
|
3884
|
+
"max_tokens": 8192,
|
|
3885
|
+
"messages": [{"role": "user", "content": "test"}],
|
|
3886
|
+
"tools": [],
|
|
3887
|
+
}
|
|
3888
|
+
anthropic_body = {"messages": [{"role": "user", "content": "test"}]}
|
|
3889
|
+
retry_body = proxy._build_malformed_retry_body(
|
|
3890
|
+
openai_body,
|
|
3891
|
+
anthropic_body,
|
|
3892
|
+
retry_hint="fix it",
|
|
3893
|
+
tool_choice="required",
|
|
3894
|
+
attempt=1,
|
|
3895
|
+
total_attempts=3,
|
|
3896
|
+
is_garbled=True,
|
|
3897
|
+
)
|
|
3898
|
+
self.assertEqual(retry_body["max_tokens"], proxy.PROXY_TOOL_TURN_MAX_TOKENS_GARBLED)
|
|
3899
|
+
|
|
3900
|
+
def test_non_garbled_uses_standard_retry_max(self):
|
|
3901
|
+
"""When is_garbled=False, retry body uses PROXY_MALFORMED_TOOL_RETRY_MAX_TOKENS."""
|
|
3902
|
+
openai_body = {
|
|
3903
|
+
"model": "test-model",
|
|
3904
|
+
"max_tokens": 8192,
|
|
3905
|
+
"messages": [{"role": "user", "content": "test"}],
|
|
3906
|
+
"tools": [],
|
|
3907
|
+
}
|
|
3908
|
+
anthropic_body = {"messages": [{"role": "user", "content": "test"}]}
|
|
3909
|
+
retry_body = proxy._build_malformed_retry_body(
|
|
3910
|
+
openai_body,
|
|
3911
|
+
anthropic_body,
|
|
3912
|
+
retry_hint="fix it",
|
|
3913
|
+
tool_choice="required",
|
|
3914
|
+
attempt=1,
|
|
3915
|
+
total_attempts=3,
|
|
3916
|
+
is_garbled=False,
|
|
3917
|
+
)
|
|
3918
|
+
if proxy.PROXY_MALFORMED_TOOL_RETRY_MAX_TOKENS > 0:
|
|
3919
|
+
self.assertLessEqual(retry_body["max_tokens"], proxy.PROXY_MALFORMED_TOOL_RETRY_MAX_TOKENS)
|
|
3920
|
+
|
|
3921
|
+
def test_exclude_tools_removes_from_retry(self):
|
|
3922
|
+
"""exclude_tools parameter removes specified tools from retry body."""
|
|
3923
|
+
openai_body = {
|
|
3924
|
+
"model": "test-model",
|
|
3925
|
+
"max_tokens": 8192,
|
|
3926
|
+
"messages": [{"role": "user", "content": "test"}],
|
|
3927
|
+
"tools": [
|
|
3928
|
+
{"type": "function", "function": {"name": "Grep", "description": "search", "parameters": {"type": "object"}}},
|
|
3929
|
+
{"type": "function", "function": {"name": "Read", "description": "read", "parameters": {"type": "object"}}},
|
|
3930
|
+
{"type": "function", "function": {"name": "Bash", "description": "run", "parameters": {"type": "object"}}},
|
|
3931
|
+
],
|
|
3932
|
+
}
|
|
3933
|
+
anthropic_body = {
|
|
3934
|
+
"messages": [{"role": "user", "content": "test"}],
|
|
3935
|
+
"tools": [
|
|
3936
|
+
{"name": "Grep", "description": "search", "input_schema": {"type": "object"}},
|
|
3937
|
+
{"name": "Read", "description": "read", "input_schema": {"type": "object"}},
|
|
3938
|
+
{"name": "Bash", "description": "run", "input_schema": {"type": "object"}},
|
|
3939
|
+
],
|
|
3940
|
+
}
|
|
3941
|
+
retry_body = proxy._build_malformed_retry_body(
|
|
3942
|
+
openai_body,
|
|
3943
|
+
anthropic_body,
|
|
3944
|
+
retry_hint="fix it",
|
|
3945
|
+
tool_choice="required",
|
|
3946
|
+
attempt=2,
|
|
3947
|
+
total_attempts=3,
|
|
3948
|
+
exclude_tools=["Grep"],
|
|
3949
|
+
)
|
|
3950
|
+
tool_names = [t["function"]["name"] for t in retry_body.get("tools", [])]
|
|
3951
|
+
self.assertNotIn("Grep", tool_names)
|
|
3952
|
+
self.assertIn("Read", tool_names)
|
|
3953
|
+
self.assertIn("Bash", tool_names)
|
|
3954
|
+
|
|
3955
|
+
def test_exclude_tools_none_keeps_all(self):
|
|
3956
|
+
"""When exclude_tools is None, all tools are retained."""
|
|
3957
|
+
openai_body = {
|
|
3958
|
+
"model": "test-model",
|
|
3959
|
+
"max_tokens": 8192,
|
|
3960
|
+
"messages": [{"role": "user", "content": "test"}],
|
|
3961
|
+
"tools": [
|
|
3962
|
+
{"type": "function", "function": {"name": "Grep", "description": "search", "parameters": {"type": "object"}}},
|
|
3963
|
+
],
|
|
3964
|
+
}
|
|
3965
|
+
anthropic_body = {
|
|
3966
|
+
"messages": [{"role": "user", "content": "test"}],
|
|
3967
|
+
"tools": [
|
|
3968
|
+
{"name": "Grep", "description": "search", "input_schema": {"type": "object"}},
|
|
3969
|
+
],
|
|
3970
|
+
}
|
|
3971
|
+
retry_body = proxy._build_malformed_retry_body(
|
|
3972
|
+
openai_body,
|
|
3973
|
+
anthropic_body,
|
|
3974
|
+
retry_hint="fix it",
|
|
3975
|
+
tool_choice="required",
|
|
3976
|
+
attempt=2,
|
|
3977
|
+
total_attempts=3,
|
|
3978
|
+
exclude_tools=None,
|
|
3979
|
+
)
|
|
3980
|
+
tool_names = [t["function"]["name"] for t in retry_body.get("tools", [])]
|
|
3981
|
+
self.assertIn("Grep", tool_names)
|
|
3982
|
+
|
|
3983
|
+
def test_garbled_args_excerpt_in_issue(self):
|
|
3984
|
+
"""_is_garbled_tool_arguments detects garbled content for logging."""
|
|
3985
|
+
# Garbled pattern: runaway braces
|
|
3986
|
+
garbled = '{"pattern": "test}}}}}}}}}}}}}}"}'
|
|
3987
|
+
self.assertTrue(proxy._is_garbled_tool_arguments(garbled))
|
|
3988
|
+
# Clean pattern
|
|
3989
|
+
clean = '{"pattern": "hello", "path": "/src"}'
|
|
3990
|
+
self.assertFalse(proxy._is_garbled_tool_arguments(clean))
|