Repository: langchain4j/langchain4j (2026-07-27T21:45:21Z)

Issue:
[BUG] SimpleToolSearchStrategy fails to match tools under Turkish locale
**Describe the bug**

`SimpleToolSearchStrategy` lowercases tool names, descriptions and LLM search terms with `String#toLowerCase()` without a `Locale`, so matching depends on the JVM default locale. Under `tr-TR`, `"listInvoices".toLowerCase()` yields `"listınvoices"` (dotless i), which no longer contains the term `"invoices"`. The tool is dropped from the search results and the LLM receives `"No matching tools found"`.

The class Javadoc declares "simple case-insensitive `contains` matching", so this breaks the documented contract.

**Log and Stack trace**

N/A — no exception is thrown.

**To Reproduce**

```java
Locale.setDefault(Locale.forLanguageTag("tr-TR"));

SimpleToolSearchStrategy strategy = new SimpleToolSearchStrategy();
ToolSpecification tool = ToolSpecification.builder()
        .name("listInvoices")
        .description("Returns all Invoices for a customer")
        .build();

// score(tool, List.of("invoices")) returns 0 instead of 3
```

**Expected behavior**

Tool search results should not depend on the JVM default locale. `DefaultToolExecutor` and `EnumOutputParser` already use `Locale.ROOT`.

**Please complete the following information:**
- LangChain4j version: 1.19.0-SNAPSHOT (main)
- LLM(s) used: N/A
- Java version: 17
- Spring Boot version (if applicable): N/A

**Additional context**

Affects `#lower` and `#clean`. #5778 fixed the same bug class in `DefaultToolExecutor` only. I have a fix ready and will open a PR.




Discussion:

Patch:
diff --git a/langchain4j/src/main/java/dev/langchain4j/service/tool/search/simple/SimpleToolSearchStrategy.java b/langchain4j/src/main/java/dev/langchain4j/service/tool/search/simple/SimpleToolSearchStrategy.java
index 8be1db4b930..655cfe1bc48 100644
--- a/langchain4j/src/main/java/dev/langchain4j/service/tool/search/simple/SimpleToolSearchStrategy.java
+++ b/langchain4j/src/main/java/dev/langchain4j/service/tool/search/simple/SimpleToolSearchStrategy.java
@@ -14,6 +14,7 @@
 import dev.langchain4j.service.tool.search.ToolSearchStrategy;
 
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
 import java.util.function.Function;
@@ -136,7 +137,7 @@ protected List<String> clean(List<String> terms) {
                 .flatMap(term -> stream(term.split("\\s+")))
                 .map(String::trim)
                 .filter(s -> !s.isEmpty())
-                .map(String::toLowerCase)
+                .map(SimpleToolSearchStrategy::lower)
                 .distinct()
                 .toList();
     }
@@ -183,7 +184,7 @@ private void throwArgumentException(String message, Exception e) {
     }
 
     private static String lower(String value) {
-        return value == null ? null : value.toLowerCase();
+        return value == null ? null : value.toLowerCase(Locale.ROOT);
     }
 
     private record ScoredTool(ToolSpecification tool, int score) {


Test patch:
diff --git a/langchain4j/src/test/java/dev/langchain4j/service/tool/search/simple/SimpleToolSearchStrategyLocaleTest.java b/langchain4j/src/test/java/dev/langchain4j/service/tool/search/simple/SimpleToolSearchStrategyLocaleTest.java
new file mode 100644
index 00000000000..82784f5316c
--- /dev/null
+++ b/langchain4j/src/test/java/dev/langchain4j/service/tool/search/simple/SimpleToolSearchStrategyLocaleTest.java
@@ -0,0 +1,67 @@
+package dev.langchain4j.service.tool.search.simple;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import dev.langchain4j.agent.tool.ToolSpecification;
+import java.util.List;
+import java.util.Locale;
+import org.junit.jupiter.api.parallel.Execution;
+import org.junit.jupiter.api.parallel.ExecutionMode;
+import org.junit.jupiter.api.parallel.Isolated;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * These tests mutate the JVM default {@link Locale}, so the whole class runs {@link Isolated}
+ * and single-threaded to avoid races with the otherwise parallel test suite.
+ * <p>
+ * {@code tr} and {@code az} lowercase {@code 'I'} to the dotless {@code 'ı'} (U+0131),
+ * {@code lt} applies its own dot rules, and {@code en-US} is the baseline.
+ */
+@Isolated
+@Execution(ExecutionMode.SAME_THREAD)
+class SimpleToolSearchStrategyLocaleTest {
+
+    private static final ToolSpecification LIST_INVOICES = ToolSpecification.builder()
+            .name("listInvoices")
+            .description("Returns all Invoices for a customer")
+            .build();
+
+    private final SimpleToolSearchStrategy strategy =
+            SimpleToolSearchStrategy.builder().build();
+
+    @ParameterizedTest
+    @ValueSource(strings = {"tr-TR", "az-AZ", "lt-LT", "en-US"})
+    void score_should_be_locale_independent(String languageTag) {
+        // Without Locale.ROOT, "listInvoices".toLowerCase() yields "listınvoices" under tr/az,
+        // which no longer contains the term "invoices", so the tool scores 0 and is dropped.
+        withDefaultLocale(languageTag, () -> {
+            int score = strategy.score(LIST_INVOICES, List.of("invoices"));
+
+            // name (+2) + description (+1)
+            assertThat(score).isEqualTo(3);
+        });
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"tr-TR", "az-AZ", "lt-LT", "en-US"})
+    void dotless_term_should_not_match_locale_independently(String languageTag) {
+        // The mirror image: without Locale.ROOT this term matches under tr/az (score 3),
+        // because the tool name and the term are both folded to the dotless form.
+        withDefaultLocale(languageTag, () -> {
+            int score = strategy.score(LIST_INVOICES, List.of("ınvoices"));
+
+            assertThat(score).isZero();
+        });
+    }
+
+    private static void withDefaultLocale(String languageTag, Runnable action) {
+        Locale previousDefault = Locale.getDefault();
+        try {
+            Locale.setDefault(Locale.forLanguageTag(languageTag));
+            action.run();
+        } finally {
+            Locale.setDefault(previousDefault);
+        }
+    }
+}