@@ -1202,3 +1202,123 @@ def func_with_metadata() -> Annotated[int, Field(gt=1)]: ... # pragma: no branc
12021202
12031203 assert meta .output_schema is not None
12041204 assert meta .output_schema ["properties" ]["result" ] == {"exclusiveMinimum" : 1 , "title" : "Result" , "type" : "integer" }
1205+
1206+
1207+ def test_bool_string_coercion_lowercase ():
1208+ """Test that string 'false'/'true' are coerced to Python booleans.
1209+
1210+ This tests the fix for issue #1843: LLM clients sometimes serialize
1211+ booleans as strings ("false" instead of false in JSON), and these
1212+ need to be properly coerced to Python booleans.
1213+ """
1214+
1215+ def my_tool (flag : bool = True ) -> str : # pragma: no cover
1216+ return f"flag is { flag } "
1217+
1218+ meta = func_metadata (my_tool )
1219+
1220+ # Test lowercase
1221+ result = meta .pre_parse_json ({"flag" : "false" })
1222+ assert result ["flag" ] is False
1223+
1224+ result = meta .pre_parse_json ({"flag" : "true" })
1225+ assert result ["flag" ] is True
1226+
1227+
1228+ def test_bool_string_coercion_case_insensitive ():
1229+ """Test case-insensitive boolean coercion."""
1230+
1231+ def my_tool (flag : bool ) -> str : # pragma: no cover
1232+ return str (flag )
1233+
1234+ meta = func_metadata (my_tool )
1235+
1236+ # Test various case combinations for true
1237+ for true_val in ["true" , "True" , "TRUE" , "tRuE" ]:
1238+ result = meta .pre_parse_json ({"flag" : true_val })
1239+ assert result ["flag" ] is True , f"Expected True for { true_val !r} "
1240+
1241+ # Test various case combinations for false
1242+ for false_val in ["false" , "False" , "FALSE" , "fAlSe" ]:
1243+ result = meta .pre_parse_json ({"flag" : false_val })
1244+ assert result ["flag" ] is False , f"Expected False for { false_val !r} "
1245+
1246+
1247+ def test_bool_native_values_unchanged ():
1248+ """Test that native boolean values pass through unchanged."""
1249+
1250+ def my_tool (flag : bool ) -> str : # pragma: no cover
1251+ return str (flag )
1252+
1253+ meta = func_metadata (my_tool )
1254+
1255+ # Native booleans should pass through
1256+ result = meta .pre_parse_json ({"flag" : True })
1257+ assert result ["flag" ] is True
1258+
1259+ result = meta .pre_parse_json ({"flag" : False })
1260+ assert result ["flag" ] is False
1261+
1262+
1263+ def test_bool_string_coercion_non_boolean_strings ():
1264+ """Test that non-boolean strings for bool params fall through to Pydantic validation."""
1265+
1266+ def my_tool (flag : bool ) -> str : # pragma: no cover
1267+ return str (flag )
1268+
1269+ meta = func_metadata (my_tool )
1270+
1271+ # Non-boolean strings should not be modified by pre_parse_json
1272+ # (Pydantic validation will handle/reject them later)
1273+ result = meta .pre_parse_json ({"flag" : "yes" })
1274+ assert result ["flag" ] == "yes"
1275+
1276+ result = meta .pre_parse_json ({"flag" : "1" })
1277+ assert result ["flag" ] == "1"
1278+
1279+ result = meta .pre_parse_json ({"flag" : "no" })
1280+ assert result ["flag" ] == "no"
1281+
1282+
1283+ @pytest .mark .anyio
1284+ async def test_bool_string_coercion_runtime_validation ():
1285+ """Test that boolean string coercion works in full runtime validation."""
1286+
1287+ def tool_with_bool (enabled : bool , name : str ) -> str : # pragma: no cover
1288+ assert isinstance (enabled , bool ), f"Expected bool, got { type (enabled )} "
1289+ return f"enabled={ enabled } , name={ name } "
1290+
1291+ meta = func_metadata (tool_with_bool )
1292+
1293+ # Test with string "false" - should be coerced to Python False
1294+ result = await meta .call_fn_with_arg_validation (
1295+ tool_with_bool ,
1296+ fn_is_async = False ,
1297+ arguments_to_validate = {"enabled" : "false" , "name" : "test" },
1298+ arguments_to_pass_directly = None ,
1299+ )
1300+ assert result == "enabled=False, name=test"
1301+
1302+ # Test with string "True" (capitalized) - should be coerced to Python True
1303+ result = await meta .call_fn_with_arg_validation (
1304+ tool_with_bool ,
1305+ fn_is_async = False ,
1306+ arguments_to_validate = {"enabled" : "True" , "name" : "test" },
1307+ arguments_to_pass_directly = None ,
1308+ )
1309+ assert result == "enabled=True, name=test"
1310+
1311+
1312+ def test_bool_string_coercion_does_not_affect_other_types ():
1313+ """Test that boolean string coercion only applies to bool-annotated params."""
1314+
1315+ def my_tool (text : str , count : int , flag : bool ) -> str : # pragma: no cover
1316+ return f"{ text } , { count } , { flag } "
1317+
1318+ meta = func_metadata (my_tool )
1319+
1320+ # "false" for a str param should remain a string
1321+ result = meta .pre_parse_json ({"text" : "false" , "count" : 5 , "flag" : "true" })
1322+ assert result ["text" ] == "false" # Unchanged (str annotation)
1323+ assert result ["count" ] == 5 # Unchanged (int annotation)
1324+ assert result ["flag" ] is True # Coerced (bool annotation)
0 commit comments