open-swe/tests/test_sanitize_tool_inputs.py
langsmith-forge[bot] 48965a82a6
fix: coerce malformed integer strings in read_file offset/limit params (#1216)
- Root cause: LLM occasionally generates strings like '1, 80' or '170, "limit": 60'
  for integer fields, causing a Pydantic ValidationError and wasting an LLM turn
- Change: add SanitizeToolInputsMiddleware in agent/middleware/sanitize_tool_inputs.py
  that extracts the leading integer from any string value in offset/limit before
  the call reaches Pydantic validation; registered before ToolErrorMiddleware in server.py
- Verified: 14 unit tests covering all three production trace patterns pass

Co-authored-by: LangSmith Forge <forge-agent@langsmith.ai>
Co-authored-by: Johannes du Plessis <johannes@langchain.dev>
2026-05-01 14:29:48 -07:00

76 lines
2.9 KiB
Python

"""Unit tests for SanitizeToolInputsMiddleware.
Guards against the regression where the LLM generates a string value for an
integer field in read_file (e.g. offset='1, 80'), causing a Pydantic
ValidationError and an unnecessary retry.
"""
from __future__ import annotations
from agent.middleware.sanitize_tool_inputs import _coerce_int, _sanitize_read_file_args
class TestCoerceInt:
def test_already_int_passes_through(self) -> None:
assert _coerce_int(1) == 1
def test_none_passes_through(self) -> None:
assert _coerce_int(None) is None
def test_extracts_leading_integer_from_comma_string(self) -> None:
# Production trace 1: offset='1, 80'
assert _coerce_int("1, 80") == 1
def test_extracts_leading_integer_from_embedded_json(self) -> None:
# Production trace 2: offset='170, "limit": 60'
assert _coerce_int('170, "limit": 60') == 170
def test_extracts_leading_integer_from_trailing_comma(self) -> None:
# Production trace 3: offset='1504, '
assert _coerce_int("1504, ") == 1504
def test_returns_none_when_no_digits(self) -> None:
assert _coerce_int("abc") is None
def test_handles_leading_whitespace(self) -> None:
assert _coerce_int(" 42, extra") == 42
class TestSanitizeReadFileArgs:
def test_coerces_offset_string_to_int(self) -> None:
args = {"file_path": "foo.ts", "offset": "1, 80", "limit": 80}
result = _sanitize_read_file_args(args)
assert result["offset"] == 1
assert result["limit"] == 80
assert result["file_path"] == "foo.ts"
def test_coerces_offset_with_embedded_json(self) -> None:
args = {"file_path": "bar.tsx", "offset": '170, "limit": 60', "limit": 60}
result = _sanitize_read_file_args(args)
assert result["offset"] == 170
def test_coerces_offset_with_trailing_comma(self) -> None:
args = {"file_path": "baz.go", "offset": "1504, ", "limit": 200}
result = _sanitize_read_file_args(args)
assert result["offset"] == 1504
def test_int_offset_unchanged(self) -> None:
args = {"file_path": "foo.ts", "offset": 42, "limit": 80}
result = _sanitize_read_file_args(args)
assert result["offset"] == 42
def test_missing_offset_unchanged(self) -> None:
args = {"file_path": "foo.ts"}
result = _sanitize_read_file_args(args)
assert "offset" not in result
def test_uncoercible_offset_passed_through_unchanged(self) -> None:
# If no digits at all, we leave the value alone so ToolErrorMiddleware handles it.
args = {"file_path": "foo.ts", "offset": "bad"}
result = _sanitize_read_file_args(args)
assert result["offset"] == "bad"
def test_does_not_mutate_original_dict(self) -> None:
args = {"file_path": "foo.ts", "offset": "1, 80"}
_ = _sanitize_read_file_args(args)
assert args["offset"] == "1, 80"