@miller-tech/uap 2.1.4 → 2.1.5
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 +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +246 -7
- package/tools/agents/tests/test_anthropic_proxy_tool_arg_types.py +234 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miller-tech/uap",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.5",
|
|
4
4
|
"description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"start": "node dist/bin/cli.js",
|
|
22
22
|
"test": "vitest",
|
|
23
23
|
"test:ci": "vitest run",
|
|
24
|
-
"test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_conditional_destructive_verbs tools.agents.tests.test_live_deliver_kill tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_schema_diff_inline tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_gate_evidence tools.agents.tests.test_repeat_call_guard tools.agents.tests.test_repeat_guard tools.agents.tests.test_gate_integrity tools.agents.tests.test_gate_failclosed_indirection tools.agents.tests.test_gate_failclosed_schema_diff tools.agents.tests.test_expert_review_pr_scope tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_write_complexity tools.agents.tests.test_fastpath_config tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_stuck_break_hard tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_non_stream_resp_log tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings tools.agents.tests.test_tool_narrowing_core tools.agents.tests.test_models_context_window tools.agents.tests.test_thinking_template_kwargs tools.agents.tests.test_cycle_break_wait_poll tools.agents.tests.test_upstream_model_aliases tools.agents.tests.test_stream_thinking_deltas",
|
|
24
|
+
"test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_conditional_destructive_verbs tools.agents.tests.test_live_deliver_kill tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_schema_diff_inline tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_gate_evidence tools.agents.tests.test_repeat_call_guard tools.agents.tests.test_repeat_guard tools.agents.tests.test_gate_integrity tools.agents.tests.test_gate_failclosed_indirection tools.agents.tests.test_gate_failclosed_schema_diff tools.agents.tests.test_expert_review_pr_scope tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_anthropic_proxy_tool_arg_types tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_write_complexity tools.agents.tests.test_fastpath_config tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_stuck_break_hard tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_non_stream_resp_log tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings tools.agents.tests.test_tool_narrowing_core tools.agents.tests.test_models_context_window tools.agents.tests.test_thinking_template_kwargs tools.agents.tests.test_cycle_break_wait_poll tools.agents.tests.test_upstream_model_aliases tools.agents.tests.test_stream_thinking_deltas",
|
|
25
25
|
"test:coverage": "vitest --coverage",
|
|
26
26
|
"bench": "vitest --config vitest.bench.config.ts",
|
|
27
27
|
"lint": "eslint src --ext .ts",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -6603,13 +6603,10 @@ def _maybe_inject_error_loop_break(openai_body: dict, monitor: "SessionMonitor")
|
|
|
6603
6603
|
directive = (
|
|
6604
6604
|
"\n\nSTOP — the SAME failure has now recurred "
|
|
6605
6605
|
+ str(monitor.error_signature_streak)
|
|
6606
|
-
+ " times in a row
|
|
6606
|
+
+ " times in a row: \""
|
|
6607
6607
|
+ monitor.last_error_signature[:120]
|
|
6608
|
-
+ "\".
|
|
6609
|
-
|
|
6610
|
-
"output) top-to-bottom — the bug is very likely somewhere you have not "
|
|
6611
|
-
"looked (a duplicate declaration, a wrong import, a different file). "
|
|
6612
|
-
"Only after you can name the exact line causing THIS error, fix that."
|
|
6608
|
+
+ "\". "
|
|
6609
|
+
+ _error_loop_remedy(monitor.last_error_signature)
|
|
6613
6610
|
)
|
|
6614
6611
|
if (
|
|
6615
6612
|
PROXY_ERROR_LOOP_HARD_LIMIT > 0
|
|
@@ -6636,6 +6633,60 @@ def _maybe_inject_error_loop_break(openai_body: dict, monitor: "SessionMonitor")
|
|
|
6636
6633
|
)
|
|
6637
6634
|
|
|
6638
6635
|
|
|
6636
|
+
def _error_loop_remedy(signature: str) -> str:
|
|
6637
|
+
"""Corrective advice matched to the failing signature.
|
|
6638
|
+
|
|
6639
|
+
2026-09-13. The generic remedy is "re-read the ENTIRE failing file", which
|
|
6640
|
+
assumes the loop is a *code* bug. Many observed loops are TOOL MISUSE, where
|
|
6641
|
+
that advice is not merely unhelpful but wrong — there is no failing file to
|
|
6642
|
+
re-read, and the model duly repeats the identical bad call until the hard stop
|
|
6643
|
+
ends the run. Observed live on Signal-3.8-27B + droid: `Read` invoked on a
|
|
6644
|
+
directory x4, and a TodoWrite schema violation x4, each ending in a breaker.
|
|
6645
|
+
|
|
6646
|
+
Matching is on the normalised signature the monitor already computes (literals
|
|
6647
|
+
are replaced by `#`/`<path>`), so these stay substring tests. Falls back to the
|
|
6648
|
+
generic code-bug remedy when nothing matches.
|
|
6649
|
+
"""
|
|
6650
|
+
sig = (signature or "").lower()
|
|
6651
|
+
|
|
6652
|
+
if "is a directory" in sig:
|
|
6653
|
+
return (
|
|
6654
|
+
"This is TOOL MISUSE, not a code bug: you called a file-reading tool "
|
|
6655
|
+
"on a DIRECTORY. Do NOT retry it on the same path. To see what is "
|
|
6656
|
+
"inside a directory use the directory-listing tool (LS) or a glob "
|
|
6657
|
+
"search (Glob); use the file-reading tool only on a concrete FILE path."
|
|
6658
|
+
)
|
|
6659
|
+
if "does not exist" in sig or "no such file" in sig:
|
|
6660
|
+
return (
|
|
6661
|
+
"This is TOOL MISUSE, not a code bug: the path you passed does not "
|
|
6662
|
+
"exist, so re-reading it cannot help. Do NOT retype the same path. "
|
|
6663
|
+
"First LIST the parent directory (LS) or search for the file by name "
|
|
6664
|
+
"(Glob/Grep), then use the exact path those results return."
|
|
6665
|
+
)
|
|
6666
|
+
if "must be" in sig and ("status" in sig or "enum" in sig or "one of" in sig):
|
|
6667
|
+
return (
|
|
6668
|
+
"This is a SCHEMA violation, not a code bug: one of your tool "
|
|
6669
|
+
"arguments used a value or shape the tool does not accept. Re-read "
|
|
6670
|
+
"that tool's parameter schema in the tool definition and emit the "
|
|
6671
|
+
"arguments EXACTLY as declared — real JSON types (an array must be a "
|
|
6672
|
+
"JSON array, not a string containing one), and only the permitted "
|
|
6673
|
+
"enum values. If you cannot express it, skip the tool and continue."
|
|
6674
|
+
)
|
|
6675
|
+
if "required" in sig and ("property" in sig or "argument" in sig or "field" in sig):
|
|
6676
|
+
return (
|
|
6677
|
+
"This is a SCHEMA violation, not a code bug: a required argument is "
|
|
6678
|
+
"missing. Re-read the tool's parameter schema and supply every "
|
|
6679
|
+
"required field with its declared type."
|
|
6680
|
+
)
|
|
6681
|
+
return (
|
|
6682
|
+
"Your edits are NOT addressing the real cause. Do NOT make another "
|
|
6683
|
+
"edit yet. FIRST re-read the ENTIRE failing file (and the full error "
|
|
6684
|
+
"output) top-to-bottom — the bug is very likely somewhere you have not "
|
|
6685
|
+
"looked (a duplicate declaration, a wrong import, a different file). "
|
|
6686
|
+
"Only after you can name the exact line causing THIS error, fix that."
|
|
6687
|
+
)
|
|
6688
|
+
|
|
6689
|
+
|
|
6639
6690
|
def _maybe_inject_doubling_break(openai_body: dict, monitor: "SessionMonitor") -> None:
|
|
6640
6691
|
"""Break a doubling-down run: the SAME tool call has failed N times in a row.
|
|
6641
6692
|
|
|
@@ -9465,6 +9516,182 @@ def _default_required_value(field_name: str, field_schema: dict):
|
|
|
9465
9516
|
return _MISSING_REQUIRED_VALUE
|
|
9466
9517
|
|
|
9467
9518
|
|
|
9519
|
+
def _declared_json_types(field_schema: dict) -> set[str]:
|
|
9520
|
+
"""JSON Schema `type` for one property, normalised to a set.
|
|
9521
|
+
|
|
9522
|
+
Handles the scalar form ("array"), the list form (["array", "null"]) and the
|
|
9523
|
+
anyOf/oneOf union form, so a nullable or unioned field is not mistaken for an
|
|
9524
|
+
untyped one. Returns an empty set when the schema declares no type at all —
|
|
9525
|
+
callers must treat that as "unknown, do not coerce".
|
|
9526
|
+
"""
|
|
9527
|
+
if not isinstance(field_schema, dict):
|
|
9528
|
+
return set()
|
|
9529
|
+
out: set[str] = set()
|
|
9530
|
+
raw = field_schema.get("type")
|
|
9531
|
+
if isinstance(raw, str):
|
|
9532
|
+
out.add(raw)
|
|
9533
|
+
elif isinstance(raw, list):
|
|
9534
|
+
out.update(t for t in raw if isinstance(t, str))
|
|
9535
|
+
for union_key in ("anyOf", "oneOf"):
|
|
9536
|
+
for sub in field_schema.get(union_key) or []:
|
|
9537
|
+
out |= _declared_json_types(sub)
|
|
9538
|
+
return out
|
|
9539
|
+
|
|
9540
|
+
|
|
9541
|
+
def _coerce_to_declared_type(value, declared: set[str]):
|
|
9542
|
+
"""Return a type-corrected `value`, or `_MISSING_REQUIRED_VALUE` if no safe fix.
|
|
9543
|
+
|
|
9544
|
+
Deliberately conservative: a coercion is applied ONLY when the corrected
|
|
9545
|
+
value's own JSON type is one the schema actually declares. Anything
|
|
9546
|
+
ambiguous is left untouched for the existing preflight/retry path.
|
|
9547
|
+
"""
|
|
9548
|
+
if not declared:
|
|
9549
|
+
return _MISSING_REQUIRED_VALUE
|
|
9550
|
+
if not isinstance(value, str):
|
|
9551
|
+
return _MISSING_REQUIRED_VALUE
|
|
9552
|
+
text = value.strip()
|
|
9553
|
+
if not text:
|
|
9554
|
+
return _MISSING_REQUIRED_VALUE
|
|
9555
|
+
|
|
9556
|
+
# The dominant local-model defect: a nested array/object emitted as a
|
|
9557
|
+
# *stringified* JSON blob (double-encoded) instead of real JSON.
|
|
9558
|
+
if ("array" in declared or "object" in declared) and text[0] in "[{":
|
|
9559
|
+
try:
|
|
9560
|
+
parsed = json.loads(text)
|
|
9561
|
+
except (json.JSONDecodeError, ValueError):
|
|
9562
|
+
return _MISSING_REQUIRED_VALUE
|
|
9563
|
+
if isinstance(parsed, list) and "array" in declared:
|
|
9564
|
+
return parsed
|
|
9565
|
+
if isinstance(parsed, dict) and "object" in declared:
|
|
9566
|
+
return parsed
|
|
9567
|
+
return _MISSING_REQUIRED_VALUE
|
|
9568
|
+
|
|
9569
|
+
# Scalars arriving quoted. Guarded so "1.5" never silently becomes an int and
|
|
9570
|
+
# a numeric string stays a string when the schema really wants a string.
|
|
9571
|
+
if "string" in declared:
|
|
9572
|
+
return _MISSING_REQUIRED_VALUE
|
|
9573
|
+
if "boolean" in declared and text.lower() in {"true", "false"}:
|
|
9574
|
+
return text.lower() == "true"
|
|
9575
|
+
if "integer" in declared:
|
|
9576
|
+
try:
|
|
9577
|
+
return int(text)
|
|
9578
|
+
except ValueError:
|
|
9579
|
+
pass
|
|
9580
|
+
if "number" in declared:
|
|
9581
|
+
try:
|
|
9582
|
+
return float(text)
|
|
9583
|
+
except ValueError:
|
|
9584
|
+
pass
|
|
9585
|
+
return _MISSING_REQUIRED_VALUE
|
|
9586
|
+
|
|
9587
|
+
|
|
9588
|
+
def _repair_tool_arg_types(
|
|
9589
|
+
openai_resp: dict, anthropic_body: dict
|
|
9590
|
+
) -> tuple[dict, int]:
|
|
9591
|
+
"""Coerce tool arguments whose JSON type contradicts the declared schema.
|
|
9592
|
+
|
|
9593
|
+
2026-09-13. The preflight guard (`_validate_tool_call_arguments`) only checks
|
|
9594
|
+
that `arguments` parses as JSON and is an object — it never compares property
|
|
9595
|
+
types against the schema. So a double-encoded argument passes validation
|
|
9596
|
+
unflagged, reaches the client broken, and no retry hint ever fires; the model
|
|
9597
|
+
then repeats the identical malformed call until the ERROR-LOOP guard hard-stops
|
|
9598
|
+
the run.
|
|
9599
|
+
|
|
9600
|
+
Measured on Signal-3.8-27B + droid: **120/120** TodoWrite payloads over four
|
|
9601
|
+
days emitted `todos` as a *string* rather than an array, e.g.
|
|
9602
|
+
|
|
9603
|
+
{"todos": "[{\"content\": \"...\", \"status\": \"pending\"}]"}
|
|
9604
|
+
|
|
9605
|
+
which droid rejects ("todo item #: status must be ..."), giving a 0% success
|
|
9606
|
+
rate on that tool. This repairs the encoding at the proxy so the call lands
|
|
9607
|
+
valid. It is a TYPE fix only — it cannot invent a correct inner schema, so a
|
|
9608
|
+
payload whose nested objects use the wrong KEYS still fails downstream (that
|
|
9609
|
+
case is bounded by the coordination-tool ban instead).
|
|
9610
|
+
|
|
9611
|
+
Conservative by construction: a value is rewritten only when the declared type
|
|
9612
|
+
is known and the corrected value matches it, so a tool that legitimately takes
|
|
9613
|
+
a JSON string is never touched.
|
|
9614
|
+
"""
|
|
9615
|
+
if not _openai_has_tool_calls(openai_resp):
|
|
9616
|
+
return openai_resp, 0
|
|
9617
|
+
|
|
9618
|
+
tools_by_name = _anthropic_tools_by_name(anthropic_body)
|
|
9619
|
+
if not tools_by_name:
|
|
9620
|
+
return openai_resp, 0
|
|
9621
|
+
|
|
9622
|
+
choice, message = _extract_openai_choice(openai_resp)
|
|
9623
|
+
tool_calls = message.get("tool_calls") or []
|
|
9624
|
+
if not tool_calls:
|
|
9625
|
+
return openai_resp, 0
|
|
9626
|
+
|
|
9627
|
+
repaired_tool_calls = []
|
|
9628
|
+
repaired_count = 0
|
|
9629
|
+
|
|
9630
|
+
for tool_call in tool_calls:
|
|
9631
|
+
fn = tool_call.get("function") if isinstance(tool_call, dict) else {}
|
|
9632
|
+
if not isinstance(fn, dict):
|
|
9633
|
+
fn = {}
|
|
9634
|
+
tool_name = fn.get("name", "")
|
|
9635
|
+
schema = tools_by_name.get(tool_name) or {}
|
|
9636
|
+
properties = schema.get("properties") if isinstance(schema, dict) else {}
|
|
9637
|
+
if not isinstance(properties, dict) or not properties:
|
|
9638
|
+
repaired_tool_calls.append(tool_call)
|
|
9639
|
+
continue
|
|
9640
|
+
|
|
9641
|
+
raw_args = fn.get("arguments", "{}")
|
|
9642
|
+
if isinstance(raw_args, dict):
|
|
9643
|
+
parsed_args = dict(raw_args)
|
|
9644
|
+
else:
|
|
9645
|
+
try:
|
|
9646
|
+
parsed_args = json.loads(str(raw_args))
|
|
9647
|
+
except (json.JSONDecodeError, ValueError):
|
|
9648
|
+
repaired_tool_calls.append(tool_call)
|
|
9649
|
+
continue
|
|
9650
|
+
if not isinstance(parsed_args, dict):
|
|
9651
|
+
repaired_tool_calls.append(tool_call)
|
|
9652
|
+
continue
|
|
9653
|
+
|
|
9654
|
+
fixed_fields = []
|
|
9655
|
+
for field, value in list(parsed_args.items()):
|
|
9656
|
+
declared = _declared_json_types(properties.get(field, {}))
|
|
9657
|
+
coerced = _coerce_to_declared_type(value, declared)
|
|
9658
|
+
if coerced is _MISSING_REQUIRED_VALUE:
|
|
9659
|
+
continue
|
|
9660
|
+
parsed_args[field] = coerced
|
|
9661
|
+
fixed_fields.append(field)
|
|
9662
|
+
|
|
9663
|
+
if not fixed_fields:
|
|
9664
|
+
repaired_tool_calls.append(tool_call)
|
|
9665
|
+
continue
|
|
9666
|
+
|
|
9667
|
+
new_tool_call = dict(tool_call)
|
|
9668
|
+
new_fn = dict(fn)
|
|
9669
|
+
new_fn["arguments"] = json.dumps(parsed_args, separators=(",", ":"))
|
|
9670
|
+
new_tool_call["function"] = new_fn
|
|
9671
|
+
repaired_tool_calls.append(new_tool_call)
|
|
9672
|
+
repaired_count += 1
|
|
9673
|
+
logger.warning(
|
|
9674
|
+
"TOOL ARG TYPE REPAIR: '%s' fields=%s coerced to declared schema types",
|
|
9675
|
+
tool_name,
|
|
9676
|
+
fixed_fields,
|
|
9677
|
+
)
|
|
9678
|
+
|
|
9679
|
+
if repaired_count == 0:
|
|
9680
|
+
return openai_resp, 0
|
|
9681
|
+
|
|
9682
|
+
choices = list(openai_resp.get("choices") or [])
|
|
9683
|
+
if not choices:
|
|
9684
|
+
return openai_resp, 0
|
|
9685
|
+
repaired_response = dict(openai_resp)
|
|
9686
|
+
updated_choice = dict(choice)
|
|
9687
|
+
updated_message = dict(message)
|
|
9688
|
+
updated_message["tool_calls"] = repaired_tool_calls
|
|
9689
|
+
updated_choice["message"] = updated_message
|
|
9690
|
+
choices[0] = updated_choice
|
|
9691
|
+
repaired_response["choices"] = choices
|
|
9692
|
+
return repaired_response, repaired_count
|
|
9693
|
+
|
|
9694
|
+
|
|
9468
9695
|
def _repair_required_tool_args(
|
|
9469
9696
|
openai_resp: dict, anthropic_body: dict
|
|
9470
9697
|
) -> tuple[dict, int]:
|
|
@@ -10610,12 +10837,24 @@ async def _apply_malformed_tool_guardrail(
|
|
|
10610
10837
|
repair_count = 0
|
|
10611
10838
|
if PROXY_TOOL_ARGS_PREFLIGHT and _openai_has_tool_calls(openai_resp):
|
|
10612
10839
|
working_resp, markup_repairs = _repair_tool_call_markup(openai_resp)
|
|
10840
|
+
# Type coercion runs BEFORE required-arg filling: a double-encoded value
|
|
10841
|
+
# is *present* (just wrongly typed), so _repair_required_tool_args would
|
|
10842
|
+
# leave it alone, and the preflight below never type-checks it at all.
|
|
10843
|
+
working_resp, type_repairs = _repair_tool_arg_types(
|
|
10844
|
+
working_resp, anthropic_body
|
|
10845
|
+
)
|
|
10613
10846
|
working_resp, required_repairs = _repair_required_tool_args(
|
|
10614
10847
|
working_resp, anthropic_body
|
|
10615
10848
|
)
|
|
10616
10849
|
working_resp, bash_repairs = _repair_bash_command_artifacts(working_resp)
|
|
10617
10850
|
working_resp, leak_repairs = _repair_system_prompt_leak(working_resp)
|
|
10618
|
-
repair_count =
|
|
10851
|
+
repair_count = (
|
|
10852
|
+
markup_repairs
|
|
10853
|
+
+ type_repairs
|
|
10854
|
+
+ required_repairs
|
|
10855
|
+
+ bash_repairs
|
|
10856
|
+
+ leak_repairs
|
|
10857
|
+
)
|
|
10619
10858
|
|
|
10620
10859
|
required_tool_choice = openai_body.get("tool_choice") == "required"
|
|
10621
10860
|
has_tool_calls = _openai_has_tool_calls(working_resp)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tool-argument type coercion + signature-aware ERROR-LOOP remedies.
|
|
3
|
+
|
|
4
|
+
Regression cover for the 2026-09-13 droid loop: the preflight guard validated
|
|
5
|
+
only that `arguments` parsed as JSON and was an object, so a double-encoded
|
|
6
|
+
argument reached the client broken and the model repeated the identical bad
|
|
7
|
+
call until the ERROR-LOOP hard stop killed the run.
|
|
8
|
+
|
|
9
|
+
Payloads here are verbatim from the live proxy journal.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
import json
|
|
14
|
+
import unittest
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_proxy_module():
|
|
19
|
+
proxy_path = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
20
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy", proxy_path)
|
|
21
|
+
assert spec is not None and spec.loader is not None
|
|
22
|
+
module = importlib.util.module_from_spec(spec)
|
|
23
|
+
spec.loader.exec_module(module)
|
|
24
|
+
return module
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
proxy = _load_proxy_module()
|
|
28
|
+
|
|
29
|
+
TODO_SCHEMA = {
|
|
30
|
+
"name": "TodoWrite",
|
|
31
|
+
"input_schema": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"properties": {
|
|
34
|
+
"todos": {
|
|
35
|
+
"type": "array",
|
|
36
|
+
"items": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"properties": {
|
|
39
|
+
"content": {"type": "string"},
|
|
40
|
+
"status": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"enum": ["pending", "in_progress", "completed"],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"required": ["todos"],
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resp(tool_name, arguments):
|
|
54
|
+
return {
|
|
55
|
+
"choices": [
|
|
56
|
+
{
|
|
57
|
+
"message": {
|
|
58
|
+
"role": "assistant",
|
|
59
|
+
"tool_calls": [
|
|
60
|
+
{
|
|
61
|
+
"id": "call_1",
|
|
62
|
+
"type": "function",
|
|
63
|
+
"function": {"name": tool_name, "arguments": arguments},
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _args_of(resp):
|
|
73
|
+
fn = resp["choices"][0]["message"]["tool_calls"][0]["function"]
|
|
74
|
+
return json.loads(fn["arguments"])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TestToolArgTypeRepair(unittest.TestCase):
|
|
78
|
+
def test_stringified_array_is_parsed_into_a_real_array(self):
|
|
79
|
+
"""The live defect: 120/120 TodoWrite payloads sent `todos` as a string."""
|
|
80
|
+
inner = [{"content": "Set up worktree", "status": "in_progress"}]
|
|
81
|
+
resp = _resp("TodoWrite", json.dumps({"todos": json.dumps(inner)}))
|
|
82
|
+
out, n = proxy._repair_tool_arg_types(resp, {"tools": [TODO_SCHEMA]})
|
|
83
|
+
self.assertEqual(n, 1)
|
|
84
|
+
self.assertEqual(_args_of(out)["todos"], inner)
|
|
85
|
+
|
|
86
|
+
def test_already_valid_array_is_left_untouched(self):
|
|
87
|
+
inner = [{"content": "x", "status": "pending"}]
|
|
88
|
+
resp = _resp("TodoWrite", json.dumps({"todos": inner}))
|
|
89
|
+
out, n = proxy._repair_tool_arg_types(resp, {"tools": [TODO_SCHEMA]})
|
|
90
|
+
self.assertEqual(n, 0)
|
|
91
|
+
self.assertEqual(_args_of(out)["todos"], inner)
|
|
92
|
+
|
|
93
|
+
def test_a_string_typed_field_is_never_coerced(self):
|
|
94
|
+
"""A tool that legitimately takes JSON-in-a-string must not be rewritten."""
|
|
95
|
+
schema = {
|
|
96
|
+
"name": "Run",
|
|
97
|
+
"input_schema": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"properties": {"payload": {"type": "string"}},
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
resp = _resp("Run", json.dumps({"payload": '[{"a": 1}]'}))
|
|
103
|
+
out, n = proxy._repair_tool_arg_types(resp, {"tools": [schema]})
|
|
104
|
+
self.assertEqual(n, 0)
|
|
105
|
+
self.assertEqual(_args_of(out)["payload"], '[{"a": 1}]')
|
|
106
|
+
|
|
107
|
+
def test_unparseable_string_is_left_for_the_retry_path(self):
|
|
108
|
+
resp = _resp("TodoWrite", json.dumps({"todos": "[{not json"}))
|
|
109
|
+
out, n = proxy._repair_tool_arg_types(resp, {"tools": [TODO_SCHEMA]})
|
|
110
|
+
self.assertEqual(n, 0)
|
|
111
|
+
|
|
112
|
+
def test_object_string_coerced_only_when_object_is_declared(self):
|
|
113
|
+
schema = {
|
|
114
|
+
"name": "Cfg",
|
|
115
|
+
"input_schema": {
|
|
116
|
+
"type": "object",
|
|
117
|
+
"properties": {"opts": {"type": "object"}},
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
resp = _resp("Cfg", json.dumps({"opts": '{"a": 1}'}))
|
|
121
|
+
out, n = proxy._repair_tool_arg_types(resp, {"tools": [schema]})
|
|
122
|
+
self.assertEqual(n, 1)
|
|
123
|
+
self.assertEqual(_args_of(out)["opts"], {"a": 1})
|
|
124
|
+
|
|
125
|
+
def test_array_declared_but_object_string_supplied_is_not_coerced(self):
|
|
126
|
+
"""Type must match what the schema declares — never force a shape."""
|
|
127
|
+
resp = _resp("TodoWrite", json.dumps({"todos": '{"a": 1}'}))
|
|
128
|
+
out, n = proxy._repair_tool_arg_types(resp, {"tools": [TODO_SCHEMA]})
|
|
129
|
+
self.assertEqual(n, 0)
|
|
130
|
+
|
|
131
|
+
def test_nullable_union_type_still_coerces(self):
|
|
132
|
+
schema = {
|
|
133
|
+
"name": "T",
|
|
134
|
+
"input_schema": {
|
|
135
|
+
"type": "object",
|
|
136
|
+
"properties": {"items": {"type": ["array", "null"]}},
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
resp = _resp("T", json.dumps({"items": "[1,2]"}))
|
|
140
|
+
out, n = proxy._repair_tool_arg_types(resp, {"tools": [schema]})
|
|
141
|
+
self.assertEqual(n, 1)
|
|
142
|
+
self.assertEqual(_args_of(out)["items"], [1, 2])
|
|
143
|
+
|
|
144
|
+
def test_quoted_scalars_coerced_to_declared_scalar_types(self):
|
|
145
|
+
schema = {
|
|
146
|
+
"name": "N",
|
|
147
|
+
"input_schema": {
|
|
148
|
+
"type": "object",
|
|
149
|
+
"properties": {
|
|
150
|
+
"limit": {"type": "integer"},
|
|
151
|
+
"ratio": {"type": "number"},
|
|
152
|
+
"deep": {"type": "boolean"},
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
resp = _resp("N", json.dumps({"limit": "10", "ratio": "1.5", "deep": "true"}))
|
|
157
|
+
out, n = proxy._repair_tool_arg_types(resp, {"tools": [schema]})
|
|
158
|
+
self.assertEqual(n, 1)
|
|
159
|
+
self.assertEqual(_args_of(out), {"limit": 10, "ratio": 1.5, "deep": True})
|
|
160
|
+
|
|
161
|
+
def test_untyped_property_is_left_alone(self):
|
|
162
|
+
schema = {
|
|
163
|
+
"name": "U",
|
|
164
|
+
"input_schema": {"type": "object", "properties": {"blob": {}}},
|
|
165
|
+
}
|
|
166
|
+
resp = _resp("U", json.dumps({"blob": "[1,2]"}))
|
|
167
|
+
out, n = proxy._repair_tool_arg_types(resp, {"tools": [schema]})
|
|
168
|
+
self.assertEqual(n, 0)
|
|
169
|
+
|
|
170
|
+
def test_unknown_tool_and_no_tool_calls_are_no_ops(self):
|
|
171
|
+
resp = _resp("Mystery", json.dumps({"todos": "[]"}))
|
|
172
|
+
_, n = proxy._repair_tool_arg_types(resp, {"tools": [TODO_SCHEMA]})
|
|
173
|
+
self.assertEqual(n, 0)
|
|
174
|
+
_, n2 = proxy._repair_tool_arg_types({"choices": []}, {"tools": [TODO_SCHEMA]})
|
|
175
|
+
self.assertEqual(n2, 0)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class TestErrorLoopRemedy(unittest.TestCase):
|
|
179
|
+
def test_directory_misuse_does_not_advise_rereading_a_file(self):
|
|
180
|
+
r = proxy._error_loop_remedy('error: error: path "<path> is a directory.')
|
|
181
|
+
self.assertIn("TOOL MISUSE", r)
|
|
182
|
+
self.assertIn("LS", r)
|
|
183
|
+
self.assertNotIn("re-read the ENTIRE failing file", r)
|
|
184
|
+
|
|
185
|
+
def test_missing_path_advises_listing_not_retyping(self):
|
|
186
|
+
r = proxy._error_loop_remedy("error: path does not exist: <path>")
|
|
187
|
+
self.assertIn("TOOL MISUSE", r)
|
|
188
|
+
self.assertIn("Glob", r)
|
|
189
|
+
|
|
190
|
+
def test_enum_violation_advises_schema_not_code(self):
|
|
191
|
+
r = proxy._error_loop_remedy(
|
|
192
|
+
"error: todo item #: status must be pending, in_progress, or completed"
|
|
193
|
+
)
|
|
194
|
+
self.assertIn("SCHEMA violation", r)
|
|
195
|
+
self.assertNotIn("re-read the ENTIRE failing file", r)
|
|
196
|
+
|
|
197
|
+
def test_genuine_code_error_keeps_the_generic_remedy(self):
|
|
198
|
+
r = proxy._error_loop_remedy("error: command failed (exit code: #)")
|
|
199
|
+
self.assertIn("re-read the ENTIRE failing file", r)
|
|
200
|
+
|
|
201
|
+
def test_empty_signature_is_safe(self):
|
|
202
|
+
self.assertIn("re-read the ENTIRE failing file", proxy._error_loop_remedy(""))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class TestRepairIsWiredIntoTheGuardrailPipeline(unittest.TestCase):
|
|
206
|
+
"""Unit-testing the repair function alone would not catch a wiring mistake.
|
|
207
|
+
|
|
208
|
+
This drives the real `_apply_malformed_tool_guardrail` chain — the code path a
|
|
209
|
+
live response actually takes — so the test fails if the repair is ever dropped
|
|
210
|
+
from it or ordered after the preflight that consumes its output.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
def test_double_encoded_arg_is_fixed_through_the_real_chain(self):
|
|
214
|
+
import asyncio
|
|
215
|
+
|
|
216
|
+
inner = [{"content": "Set up worktree", "status": "in_progress"}]
|
|
217
|
+
resp = _resp("TodoWrite", json.dumps({"todos": json.dumps(inner)}))
|
|
218
|
+
monitor = proxy.SessionMonitor()
|
|
219
|
+
out = asyncio.run(
|
|
220
|
+
proxy._apply_malformed_tool_guardrail(
|
|
221
|
+
None,
|
|
222
|
+
resp,
|
|
223
|
+
{"tool_choice": "auto"},
|
|
224
|
+
{"tools": [TODO_SCHEMA]},
|
|
225
|
+
monitor,
|
|
226
|
+
"sess-test",
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
self.assertEqual(_args_of(out)["todos"], inner)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
unittest.main()
|