Repository: langchain4j/langchain4j (2026-07-11T12:48:23Z)

Issue:
[BUG] DefaultToolExecutor silently loses precision for long/BigInteger/BigDecimal tool arguments (routed through double)
**Describe the bug**

When an LLM calls a `@Tool` method that has a `long`, `int`, `short`, `byte`, `BigInteger`, or `BigDecimal` parameter, `DefaultToolExecutor.coerceArgument(...)` converts the argument by routing it through a `double`, which silently corrupts values that cannot be represented exactly as a `double`.

The tool-call argument JSON is deserialized into a `Map<String, Object>`, so a JSON integer above 2^53 arrives as a `Long` (or `BigInteger` above `Long.MAX_VALUE`). But:

- `getBoundedLongValue(...)` (used for `long`/`int`/`short`/`byte`) does `(long) ((Number) argument).doubleValue()`.
- The `BigInteger` branch does `BigDecimal.valueOf(getNonFractionalDoubleValue(...)).toBigInteger()`.
- The `BigDecimal` branch does `BigDecimal.valueOf(getDoubleValue(...))`.

All three lose precision because `double` has only 53 bits of mantissa.

**Log and Stack trace**

Not an exception — silent data corruption.

**To Reproduce**

Given a tool `void lookup(@P("id") long id)` and an LLM tool call `{"id": 9007199254740993}` (2^53 + 1):

```java
// what DefaultToolExecutor.coerceArgument effectively does today:
Long arg = 9007199254740993L;
long result = (long) arg.doubleValue(); // 9007199254740992  <-- off by one, silently
```

Similarly:
- `BigInteger` param, arg `9223372036854775817` → becomes `9223372036854776000`.
- `BigDecimal` param, arg `1234567890.123456789` → becomes `1234567890.1234567`.

**Expected behavior**

Integer-family and arbitrary-precision parameters should preserve the exact value:
- `long`/`int`/`short`/`byte`/`BigInteger`: convert the integral argument exactly (e.g. via `BigInteger`/`longValue()`), only bounds-checking, never round-tripping through `double`.
- `BigDecimal`: build from the argument's exact value (`Number.toString()` / string) instead of `BigDecimal.valueOf(double)`.

**Please complete the following information:**
- LangChain4j version: main (1.x)
- Java version: 17

**Additional context**

Same class of bug as the already-merged **#5503** ("prevent integer overflow in AwsDocumentConverter for long and BigInteger tool arguments"), but in the shared `DefaultToolExecutor` path (`langchain4j` module) rather than the Bedrock-specific `AwsDocumentConverter`. Found via a code audit, not a specific user report. Will open a PR that converts these types via `BigDecimal`/`BigInteger` exactly, preserving the existing whitespace-tolerance and range/fractional error semantics (also used by the numeric `*OutputParser`s that call `getBoundedLongValue`).


Discussion:

Patch:
diff --git a/langchain4j/src/main/java/dev/langchain4j/service/tool/DefaultToolExecutor.java b/langchain4j/src/main/java/dev/langchain4j/service/tool/DefaultToolExecutor.java
index c27fe72f22a..fc21ee1c8e2 100644
--- a/langchain4j/src/main/java/dev/langchain4j/service/tool/DefaultToolExecutor.java
+++ b/langchain4j/src/main/java/dev/langchain4j/service/tool/DefaultToolExecutor.java
@@ -364,7 +364,7 @@ static Object coerceArgument(Object argument, String parameterName, Class<?> par
         }
 
         if (parameterClass == BigDecimal.class) {
-            return BigDecimal.valueOf(getDoubleValue(argument, parameterName, parameterClass));
+            return getBigDecimalValue(argument, parameterName, parameterClass);
         }
 
         if (parameterClass == Integer.class || parameterClass == int.class) {
@@ -386,8 +386,7 @@ static Object coerceArgument(Object argument, String parameterName, Class<?> par
         }
 
         if (parameterClass == BigInteger.class) {
-            return BigDecimal.valueOf(getNonFractionalDoubleValue(argument, parameterName, parameterClass))
-                    .toBigInteger();
+            return getBigIntegerValue(argument, parameterName, parameterClass);
         }
 
         if (Collection.class.isAssignableFrom(parameterClass) || Map.class.isAssignableFrom(parameterClass)) {
@@ -423,16 +422,6 @@ private static double getDoubleValue(Object argument, String parameterName, Clas
         return ((Number) argument).doubleValue();
     }
 
-    private static double getNonFractionalDoubleValue(Object argument, String parameterName, Class<?> parameterType) {
-        double doubleValue = getDoubleValue(argument, parameterName, parameterType);
-        if (!hasNoFractionalPart(doubleValue)) {
-            throw new IllegalArgumentException(String.format(
-                    "Argument \"%s\" has non-integer value for %s: <%s>",
-                    parameterName, parameterType.getName(), argument));
-        }
-        return doubleValue;
-    }
-
     private static void checkBounds(
             double doubleValue, String parameterName, Class<?> parameterType, double minValue, double maxValue) {
         if (doubleValue < minValue || doubleValue > maxValue) {
@@ -444,13 +433,63 @@ private static void checkBounds(
 
     public static long getBoundedLongValue(
             Object argument, String parameterName, Class<?> parameterType, long minValue, long maxValue) {
-        double doubleValue = getNonFractionalDoubleValue(argument, parameterName, parameterType);
-        checkBounds(doubleValue, parameterName, parameterType, minValue, maxValue);
-        return (long) doubleValue;
+        BigInteger bigIntegerValue = getBigIntegerValue(argument, parameterName, parameterType);
+        if (bigIntegerValue.compareTo(BigInteger.valueOf(minValue)) < 0
+                || bigIntegerValue.compareTo(BigInteger.valueOf(maxValue)) > 0) {
+            throw new IllegalArgumentException(String.format(
+                    "Argument \"%s\" is out of range for %s: <%s>", parameterName, parameterType.getName(), argument));
+        }
+        return bigIntegerValue.longValue();
     }
 
-    static boolean hasNoFractionalPart(Double doubleValue) {
-        return doubleValue.equals(Math.floor(doubleValue));
+    /**
+     * Converts the argument to a {@link BigInteger} preserving its exact value.
+     * Going through {@code double} would silently lose precision for magnitudes above 2^53
+     * (e.g. a long 9007199254740993 would become 9007199254740992).
+     */
+    private static BigInteger getBigIntegerValue(Object argument, String parameterName, Class<?> parameterType) {
+        BigDecimal bigDecimalValue = getBigDecimalValue(argument, parameterName, parameterType);
+        try {
+            return bigDecimalValue.toBigIntegerExact();
+        } catch (ArithmeticException e) {
+            throw new IllegalArgumentException(String.format(
+                    "Argument \"%s\" has non-integer value for %s: <%s>",
+                    parameterName, parameterType.getName(), argument));
+        }
+    }
+
+    /**
+     * Converts the argument to a {@link BigDecimal} preserving its exact value.
+     * Unlike converting through {@code double}, this does not lose precision for large integers
+     * or introduce floating-point representation error.
+     */
+    private static BigDecimal getBigDecimalValue(Object argument, String parameterName, Class<?> parameterType) {
+        if (argument instanceof BigDecimal bigDecimal) {
+            return bigDecimal;
+        }
+        if (argument instanceof BigInteger bigInteger) {
+            return new BigDecimal(bigInteger);
+        }
+        // Long/Integer/Short/Byte have exact string representations; Double/Float are rendered via
+        // Number.toString() (matching the behavior of IsEqualTo's numeric comparison).
+        if (argument instanceof Number number) {
+            return new BigDecimal(number.toString());
+        }
+        if (argument instanceof String) {
+            try {
+                // Trim to tolerate surrounding whitespace, matching the leniency of Double.parseDouble
+                // that the previous double-based conversion relied on.
+                return new BigDecimal(argument.toString().trim());
+            } catch (NumberFormatException e) {
+                // fall through to the error below
+            }
+        }
+        throw new IllegalArgumentException(String.format(
+                "Argument \"%s\" is not convertable to %s, got %s: <%s>",
+                parameterName,
+                parameterType.getName(),
+                argument == null ? "null" : argument.getClass().getName(),
+                argument));
     }
 
     public static Builder builder() {


Test patch:
diff --git a/langchain4j/src/test/java/dev/langchain4j/service/tool/DefaultToolExecutorTest.java b/langchain4j/src/test/java/dev/langchain4j/service/tool/DefaultToolExecutorTest.java
index ea8c3bef091..d32179adb9f 100644
--- a/langchain4j/src/test/java/dev/langchain4j/service/tool/DefaultToolExecutorTest.java
+++ b/langchain4j/src/test/java/dev/langchain4j/service/tool/DefaultToolExecutorTest.java
@@ -28,14 +28,6 @@
 
 class DefaultToolExecutorTest implements WithAssertions {
 
-    @Test
-    void tesT_hasNoFractionalPart() {
-        assertThat(DefaultToolExecutor.hasNoFractionalPart(3.0)).isTrue();
-        assertThat(DefaultToolExecutor.hasNoFractionalPart(-3.0)).isTrue();
-        assertThat(DefaultToolExecutor.hasNoFractionalPart(3.5)).isFalse();
-        assertThat(DefaultToolExecutor.hasNoFractionalPart(-3.5)).isFalse();
-    }
-
     public enum ExampleEnum {
         A,
         B,
@@ -284,6 +276,52 @@ void coerce_argument() {
                 .isEqualTo(singletonMap("A", 1));
     }
 
+    @Test
+    void coerce_argument_preserves_precision_of_large_long() {
+        // A JSON integer above 2^53 is deserialized as a Long by Jackson. Converting via double
+        // would silently corrupt it (9007199254740993 -> 9007199254740992).
+        long largeLong = 9007199254740993L; // 2^53 + 1
+
+        assertThat(coerceArgument(largeLong, "arg", long.class, null)).isEqualTo(largeLong);
+        assertThat(coerceArgument(largeLong, "arg", Long.class, null)).isEqualTo(largeLong);
+        assertThat(coerceArgument(Long.MAX_VALUE, "arg", long.class, null)).isEqualTo(Long.MAX_VALUE);
+        assertThat(coerceArgument(Long.MIN_VALUE, "arg", long.class, null)).isEqualTo(Long.MIN_VALUE);
+    }
+
+    @Test
+    void coerce_argument_preserves_precision_of_large_big_integer() {
+        // A JSON integer larger than Long.MAX_VALUE is deserialized as a BigInteger by Jackson.
+        BigInteger largeBigInteger = BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.TEN);
+
+        assertThat(coerceArgument(largeBigInteger, "arg", BigInteger.class, null))
+                .isEqualTo(largeBigInteger);
+        // A large integral value must not be routed through double for a BigInteger parameter.
+        BigInteger aboveDoublePrecision = new BigInteger("9007199254740993"); // 2^53 + 1
+        assertThat(coerceArgument(aboveDoublePrecision, "arg", BigInteger.class, null))
+                .isEqualTo(aboveDoublePrecision);
+    }
+
+    @Test
+    void coerce_argument_preserves_precision_of_big_decimal() {
+        // 0.1 has no exact double representation; converting via new BigDecimal(double) would yield
+        // 0.1000000000000000055511151231257827021181583404541015625. Rendering via Number.toString()
+        // (as BigDecimal.valueOf(double) does) keeps it as "0.1".
+        assertThat(coerceArgument(0.1, "arg", BigDecimal.class, null)).isEqualTo(new BigDecimal("0.1"));
+        // A BigDecimal argument must be preserved exactly.
+        BigDecimal preciseValue = new BigDecimal("1234567890.123456789");
+        assertThat(coerceArgument(preciseValue, "arg", BigDecimal.class, null)).isEqualTo(preciseValue);
+    }
+
+    @Test
+    void coerce_argument_rejects_fractional_value_for_integer_types() {
+        assertThatExceptionOfType(IllegalArgumentException.class)
+                .isThrownBy(() -> coerceArgument(1.5, "arg", long.class, null))
+                .withMessageContaining("has non-integer value");
+        assertThatExceptionOfType(IllegalArgumentException.class)
+                .isThrownBy(() -> coerceArgument(1.5, "arg", BigInteger.class, null))
+                .withMessageContaining("has non-integer value");
+    }
+
     private static class TestTool {
 
         @Tool
diff --git a/langchain4j/src/test/java/dev/langchain4j/service/tool/ToolExecutorTest.java b/langchain4j/src/test/java/dev/langchain4j/service/tool/ToolExecutorTest.java
index 4ba8f2359af..88c847f4af9 100644
--- a/langchain4j/src/test/java/dev/langchain4j/service/tool/ToolExecutorTest.java
+++ b/langchain4j/src/test/java/dev/langchain4j/service/tool/ToolExecutorTest.java
@@ -128,14 +128,17 @@ void should_fail_when_argument_does_not_fit_into_float_type(String arguments) th
     }
 
     @ParameterizedTest
-    @ValueSource(
-            strings = {
-                "{\"arg0\": 2, \"arg1\": 2}",
-                "{\"arg0\": 2.0, \"arg1\": 2.0}",
-                "{\"arg0\": 1.9, \"arg1\": 2.1}",
-            })
-    void should_execute_tool_with_parameters_of_type_BigDecimal(String arguments) throws NoSuchMethodException {
-        executeAndAssert(arguments, "bigDecimals", BigDecimal.class, BigDecimal.class, "4.0");
+    @CsvSource({
+        // BigDecimal arguments are now converted preserving their exact value/scale instead of being
+        // routed through double, so a JSON integer 2 becomes BigDecimal("2") (2 + 2 = 4) while a JSON
+        // decimal 2.0 becomes BigDecimal("2.0") (2.0 + 2.0 = 4.0).
+        "'{\"arg0\": 2, \"arg1\": 2}', 4",
+        "'{\"arg0\": 2.0, \"arg1\": 2.0}', 4.0",
+        "'{\"arg0\": 1.9, \"arg1\": 2.1}', 4.0",
+    })
+    void should_execute_tool_with_parameters_of_type_BigDecimal(String arguments, String expectedResult)
+            throws NoSuchMethodException {
+        executeAndAssert(arguments, "bigDecimals", BigDecimal.class, BigDecimal.class, expectedResult);
     }
 
     @ParameterizedTest