From c3006a040e2fa9b0abee10774269b864512c34a8 Mon Sep 17 00:00:00 2001 From: arefbehboudi Date: Mon, 13 Apr 2026 23:53:06 +0300 Subject: [PATCH 1/4] feat(agent): add Tool Search advisor for dynamic tool discovery --- README.md | 27 +++++++++ base/build.gradle | 4 ++ .../ai/javaclaw/JavaClawConfiguration.java | 9 ++- .../DynamicToolDiscoveryConfiguration.java | 30 ++++++++++ .../DynamicToolDiscoveryProperties.java | 24 ++++++++ ...DynamicToolDiscoveryConfigurationTest.java | 40 +++++++++++++ .../tools/search/LuceneToolSearcherTest.java | 59 +++++++++++++++++++ 7 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryConfiguration.java create mode 100644 base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryProperties.java create mode 100644 base/src/test/java/ai/javaclaw/tools/search/DynamicToolDiscoveryConfigurationTest.java create mode 100644 base/src/test/java/ai/javaclaw/tools/search/LuceneToolSearcherTest.java diff --git a/README.md b/README.md index b93e779..1de2459 100644 --- a/README.md +++ b/README.md @@ -143,9 +143,36 @@ Key properties in `application.yaml`: | `agent.workspace` | Path to the workspace root (default: `file:./workspace/`) | | `agent.onboarding.completed` | Set to `true` after onboarding is done | | `spring.ai.model.chat` | Active LLM provider/model | +| `javaclaw.tools.dynamic-discovery.enabled` | Enable dynamic tool discovery (Tool Search Tool pattern) instead of exposing all tools up front | | `jobrunr.dashboard.port` | JobRunr dashboard port (default: `8081`) | | `jobrunr.background-job-server.worker-count` | Concurrent job workers (default: `1`) | +### Dynamic Tool Discovery + +When enabled, JavaClaw uses Spring AI's "Tool Search Tool" pattern ("tool search") so the model discovers relevant tools at runtime instead of receiving every tool definition up front. + +Use it when: +- You have many tools (plugins, MCP servers, skills) and prompts are getting large. +- The model picks the wrong tool because the tool list is too big or too similar. + +```yaml +javaclaw: + tools: + dynamic-discovery: + enabled: true + # Optional tuning: + max-results: 8 + lucene-min-score-threshold: 0.25 +``` + +Flag behavior: +- `enabled=false` (default): eager tool exposure (legacy behavior). +- `enabled=true`: uses the Tool Search advisor (dynamic discovery, Lucene keyword search). + +Notes: +- Tool search quality depends on `@Tool(description = "...")`. Keep descriptions specific and disambiguating. +- Tuning: raise `lucene-min-score-threshold` to be stricter; lower it if tools are not found. Adjust `max-results` to control how many tools get surfaced. + ## Running Tests ```bash diff --git a/base/build.gradle b/base/build.gradle index 9114197..33da184 100644 --- a/base/build.gradle +++ b/base/build.gradle @@ -7,6 +7,7 @@ dependencies { implementation 'org.springframework.modulith:spring-modulith-starter-core' implementation 'org.jobrunr:jobrunr-spring-boot-4-starter:8.5.1' implementation 'org.apache.commons:commons-lang3' + implementation 'com.fasterxml.jackson.core:jackson-core' // No idea? runtimeOnly 'io.netty:netty-resolver-dns-native-macos:4.2.10.Final' @@ -15,6 +16,9 @@ dependencies { implementation 'org.springframework.ai:spring-ai-starter-mcp-client' implementation 'org.springaicommunity:spring-ai-agent-utils:0.6.0-SNAPSHOT' + // Dynamic tool discovery (Issue #49): Tool Search Tool pattern + Lucene keyword searcher. + implementation 'org.springaicommunity:tool-search-tool:2.0.1' + implementation 'org.springaicommunity:tool-searcher-lucene:2.0.1' runtimeOnly 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java b/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java index f818d3e..f17c60c 100644 --- a/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java +++ b/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java @@ -10,6 +10,7 @@ import org.springaicommunity.agent.tools.FileSystemTools; import org.springaicommunity.agent.tools.SkillsTool; import org.springaicommunity.agent.tools.SmartWebFetchTool; +import org.springaicommunity.tool.search.ToolSearchToolCallAdvisor; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; @@ -69,6 +70,7 @@ public ChatClient.Builder chatClientBuilder(ObjectProvider chatModelP @DependsOn({"mcpHeaderCustomizer"}) public ChatClient chatClient(ChatClient.Builder chatClientBuilder, ChatMemory chatMemory, + ObjectProvider toolSearchToolCallAdvisorProvider, SyncMcpToolCallbackProvider mcpToolProvider, TaskManager taskManager, ConfigurationManager configurationManager, @@ -83,6 +85,11 @@ public ChatClient chatClient(ChatClient.Builder chatClientBuilder, String agentPrompt = agentMd.getContentAsString(StandardCharsets.UTF_8) + System.lineSeparator() + workspace.createRelative("INFO.md").getContentAsString(StandardCharsets.UTF_8) + System.lineSeparator(); + ToolCallAdvisor toolCallAdvisor = toolSearchToolCallAdvisorProvider.getIfAvailable(); + if (toolCallAdvisor == null) { + toolCallAdvisor = ToolCallAdvisor.builder().build(); + } + chatClientBuilder .defaultAdvisors(new SimpleLoggerAdvisor()) .defaultSystem(p -> p.text(agentPrompt).param(AgentEnvironment.ENVIRONMENT_INFO_KEY, AgentEnvironment.info())) @@ -99,7 +106,7 @@ public ChatClient chatClient(ChatClient.Builder chatClientBuilder, // Smart web fetch tool SmartWebFetchTool.builder(chatClientBuilder.clone().build()).build()) .defaultAdvisors( - ToolCallAdvisor.builder().build(), + toolCallAdvisor, MessageChatMemoryAdvisor.builder(chatMemory).build() ); diff --git a/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryConfiguration.java b/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryConfiguration.java new file mode 100644 index 0000000..1321747 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryConfiguration.java @@ -0,0 +1,30 @@ +package ai.javaclaw.tools.search; + +import org.springaicommunity.tool.search.ToolSearchToolCallAdvisor; +import org.springaicommunity.tool.search.ToolSearcher; +import org.springaicommunity.tool.searcher.LuceneToolSearcher; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(DynamicToolDiscoveryProperties.class) +@ConditionalOnProperty(name = "javaclaw.tools.dynamic-discovery.enabled", havingValue = "true") +public class DynamicToolDiscoveryConfiguration { + + @Bean(destroyMethod = "close") + public ToolSearcher toolSearcher(DynamicToolDiscoveryProperties properties) { + return new LuceneToolSearcher(properties.luceneMinScoreThreshold()); + } + + @Bean + public ToolSearchToolCallAdvisor toolSearchToolCallAdvisor(ToolSearcher toolSearcher, + DynamicToolDiscoveryProperties properties) { + return ToolSearchToolCallAdvisor.builder() + .toolSearcher(toolSearcher) + .maxResults(properties.maxResults()) + .build(); + } +} + diff --git a/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryProperties.java b/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryProperties.java new file mode 100644 index 0000000..b6f7ae5 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryProperties.java @@ -0,0 +1,24 @@ +package ai.javaclaw.tools.search; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Dynamic tool discovery configuration (Issue #49). + * + * When enabled, JavaClaw uses Spring AI's Tool Search Tool pattern so the model can + * discover tools at runtime rather than receiving the full tool set up front. + */ +@ConfigurationProperties(prefix = "javaclaw.tools.dynamic-discovery") +public record DynamicToolDiscoveryProperties( + boolean enabled, + Integer maxResults, + Float luceneMinScoreThreshold +) { + + public DynamicToolDiscoveryProperties { + // Defaults keep the feature low-risk and predictable. + if (maxResults == null) maxResults = 8; + if (luceneMinScoreThreshold == null) luceneMinScoreThreshold = 0.25f; + } +} + diff --git a/base/src/test/java/ai/javaclaw/tools/search/DynamicToolDiscoveryConfigurationTest.java b/base/src/test/java/ai/javaclaw/tools/search/DynamicToolDiscoveryConfigurationTest.java new file mode 100644 index 0000000..91cf8a8 --- /dev/null +++ b/base/src/test/java/ai/javaclaw/tools/search/DynamicToolDiscoveryConfigurationTest.java @@ -0,0 +1,40 @@ +package ai.javaclaw.tools.search; + +import org.junit.jupiter.api.Test; +import org.springaicommunity.tool.search.ToolSearchToolCallAdvisor; +import org.springaicommunity.tool.search.ToolSearcher; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +class DynamicToolDiscoveryConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(DynamicToolDiscoveryConfiguration.class); + + @Test + void whenEnabled_registersToolSearcherAndAdvisor() { + contextRunner + .withPropertyValues( + "javaclaw.tools.dynamic-discovery.enabled=true", + "javaclaw.tools.dynamic-discovery.max-results=7", + "javaclaw.tools.dynamic-discovery.lucene-min-score-threshold=0.0" + ) + .run(context -> { + assertThat(context).hasSingleBean(ToolSearcher.class); + assertThat(context).hasSingleBean(ToolSearchToolCallAdvisor.class); + assertThat(context.getBean(DynamicToolDiscoveryProperties.class).enabled()).isTrue(); + }); + } + + @Test + void whenDisabled_doesNotRegisterToolSearcherOrAdvisor() { + contextRunner + .withPropertyValues("javaclaw.tools.dynamic-discovery.enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean(ToolSearcher.class); + assertThat(context).doesNotHaveBean(ToolSearchToolCallAdvisor.class); + }); + } +} + diff --git a/base/src/test/java/ai/javaclaw/tools/search/LuceneToolSearcherTest.java b/base/src/test/java/ai/javaclaw/tools/search/LuceneToolSearcherTest.java new file mode 100644 index 0000000..41913d7 --- /dev/null +++ b/base/src/test/java/ai/javaclaw/tools/search/LuceneToolSearcherTest.java @@ -0,0 +1,59 @@ +package ai.javaclaw.tools.search; + +import org.junit.jupiter.api.Test; +import org.springaicommunity.tool.search.ToolReference; +import org.springaicommunity.tool.search.ToolSearchRequest; +import org.springaicommunity.tool.searcher.LuceneToolSearcher; + +import static org.assertj.core.api.Assertions.assertThat; + +class LuceneToolSearcherTest { + + @Test + void returnsRelevantToolsForQuery() throws Exception { + try (LuceneToolSearcher searcher = new LuceneToolSearcher(0.0f)) { + String sessionId = "s1"; + searcher.indexTool(sessionId, new ToolReference("fileSystem", null, + "Read, write, and edit local files in the workspace. Use for file operations, patches, and edits.")); + searcher.indexTool(sessionId, new ToolReference("webFetch", null, + "Fetch a URL and extract readable content from web pages. Use for scraping and summarization.")); + searcher.indexTool(sessionId, new ToolReference("shell", null, + "Execute shell commands to inspect the repository, run builds/tests, and automate development tasks.")); + + var response = searcher.search(new ToolSearchRequest(sessionId, "edit a local file", 5, null)); + + assertThat(response.toolReferences()).isNotEmpty(); + assertThat(response.toolReferences().getFirst().toolName()).isEqualTo("fileSystem"); + } + } + + @Test + void ranksMoreRelevantToolHigherBasedOnDescription() throws Exception { + try (LuceneToolSearcher searcher = new LuceneToolSearcher(0.0f)) { + String sessionId = "s2"; + searcher.indexTool(sessionId, new ToolReference("webFetch", null, + "Fetch a URL and extract page contents. Good for reading articles when you already have a URL.")); + searcher.indexTool(sessionId, new ToolReference("braveSearch", null, + "Search the web by keyword query and return results. Use when you do not have a URL yet.")); + + var response = searcher.search(new ToolSearchRequest(sessionId, "search the web for spring ai docs", 5, null)); + + assertThat(response.toolReferences()).isNotEmpty(); + assertThat(response.toolReferences().getFirst().toolName()).isEqualTo("braveSearch"); + } + } + + @Test + void honorsMaxResults() throws Exception { + try (LuceneToolSearcher searcher = new LuceneToolSearcher(0.0f)) { + String sessionId = "s3"; + for (int i = 0; i < 10; i++) { + searcher.indexTool(sessionId, new ToolReference("tool-" + i, null, "tool number " + i + " for testing")); + } + + var response = searcher.search(new ToolSearchRequest(sessionId, "tool testing", 3, null)); + + assertThat(response.toolReferences().size()).isLessThanOrEqualTo(3); + } + } +} From 407a86cb16fc2747c76b73fca5638c94599d1886 Mon Sep 17 00:00:00 2001 From: arefbehboudi Date: Mon, 13 Apr 2026 23:58:44 +0300 Subject: [PATCH 2/4] Polish --- .../tools/search/DynamicToolDiscoveryProperties.java | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryProperties.java b/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryProperties.java index b6f7ae5..a739efd 100644 --- a/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryProperties.java +++ b/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryProperties.java @@ -2,12 +2,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; -/** - * Dynamic tool discovery configuration (Issue #49). - * - * When enabled, JavaClaw uses Spring AI's Tool Search Tool pattern so the model can - * discover tools at runtime rather than receiving the full tool set up front. - */ + @ConfigurationProperties(prefix = "javaclaw.tools.dynamic-discovery") public record DynamicToolDiscoveryProperties( boolean enabled, @@ -16,7 +11,6 @@ public record DynamicToolDiscoveryProperties( ) { public DynamicToolDiscoveryProperties { - // Defaults keep the feature low-risk and predictable. if (maxResults == null) maxResults = 8; if (luceneMinScoreThreshold == null) luceneMinScoreThreshold = 0.25f; } From 49604d8cf79aa906a6f62a70b988d93d8a42faf3 Mon Sep 17 00:00:00 2001 From: arefbehboudi Date: Tue, 14 Apr 2026 00:00:25 +0300 Subject: [PATCH 3/4] Polish --- base/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base/build.gradle b/base/build.gradle index 33da184..ae453b5 100644 --- a/base/build.gradle +++ b/base/build.gradle @@ -16,7 +16,7 @@ dependencies { implementation 'org.springframework.ai:spring-ai-starter-mcp-client' implementation 'org.springaicommunity:spring-ai-agent-utils:0.6.0-SNAPSHOT' - // Dynamic tool discovery (Issue #49): Tool Search Tool pattern + Lucene keyword searcher. + implementation 'org.springaicommunity:tool-search-tool:2.0.1' implementation 'org.springaicommunity:tool-searcher-lucene:2.0.1' From 4287869c5717179f9603741166f1fd05ca4eb789 Mon Sep 17 00:00:00 2001 From: arefbehboudi Date: Tue, 14 Apr 2026 22:08:09 +0300 Subject: [PATCH 4/4] enable tool search dynamic discovery by default --- README.md | 4 ++-- base/build.gradle | 1 - .../search/DynamicToolDiscoveryConfiguration.java | 3 +-- .../tools/search/DynamicToolDiscoveryProperties.java | 4 ++-- .../search/DynamicToolDiscoveryConfigurationTest.java | 11 ++++++++++- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 1de2459..e1a3a01 100644 --- a/README.md +++ b/README.md @@ -166,8 +166,8 @@ javaclaw: ``` Flag behavior: -- `enabled=false` (default): eager tool exposure (legacy behavior). -- `enabled=true`: uses the Tool Search advisor (dynamic discovery, Lucene keyword search). +- `enabled=true` (default): uses the Tool Search advisor (dynamic discovery, Lucene keyword search). +- `enabled=false`: eager tool exposure (legacy behavior). Notes: - Tool search quality depends on `@Tool(description = "...")`. Keep descriptions specific and disambiguating. diff --git a/base/build.gradle b/base/build.gradle index ae453b5..2340de9 100644 --- a/base/build.gradle +++ b/base/build.gradle @@ -16,7 +16,6 @@ dependencies { implementation 'org.springframework.ai:spring-ai-starter-mcp-client' implementation 'org.springaicommunity:spring-ai-agent-utils:0.6.0-SNAPSHOT' - implementation 'org.springaicommunity:tool-search-tool:2.0.1' implementation 'org.springaicommunity:tool-searcher-lucene:2.0.1' diff --git a/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryConfiguration.java b/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryConfiguration.java index 1321747..be12984 100644 --- a/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryConfiguration.java +++ b/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryConfiguration.java @@ -10,7 +10,7 @@ @Configuration @EnableConfigurationProperties(DynamicToolDiscoveryProperties.class) -@ConditionalOnProperty(name = "javaclaw.tools.dynamic-discovery.enabled", havingValue = "true") +@ConditionalOnProperty(name = "javaclaw.tools.dynamic-discovery.enabled", havingValue = "true", matchIfMissing = true) public class DynamicToolDiscoveryConfiguration { @Bean(destroyMethod = "close") @@ -27,4 +27,3 @@ public ToolSearchToolCallAdvisor toolSearchToolCallAdvisor(ToolSearcher toolSear .build(); } } - diff --git a/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryProperties.java b/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryProperties.java index a739efd..8b211a4 100644 --- a/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryProperties.java +++ b/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryProperties.java @@ -5,14 +5,14 @@ @ConfigurationProperties(prefix = "javaclaw.tools.dynamic-discovery") public record DynamicToolDiscoveryProperties( - boolean enabled, + Boolean enabled, Integer maxResults, Float luceneMinScoreThreshold ) { public DynamicToolDiscoveryProperties { + if (enabled == null) enabled = true; if (maxResults == null) maxResults = 8; if (luceneMinScoreThreshold == null) luceneMinScoreThreshold = 0.25f; } } - diff --git a/base/src/test/java/ai/javaclaw/tools/search/DynamicToolDiscoveryConfigurationTest.java b/base/src/test/java/ai/javaclaw/tools/search/DynamicToolDiscoveryConfigurationTest.java index 91cf8a8..b78f67e 100644 --- a/base/src/test/java/ai/javaclaw/tools/search/DynamicToolDiscoveryConfigurationTest.java +++ b/base/src/test/java/ai/javaclaw/tools/search/DynamicToolDiscoveryConfigurationTest.java @@ -12,6 +12,16 @@ class DynamicToolDiscoveryConfigurationTest { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withUserConfiguration(DynamicToolDiscoveryConfiguration.class); + @Test + void whenPropertyIsMissing_defaultsToEnabled() { + contextRunner + .run(context -> { + assertThat(context).hasSingleBean(ToolSearcher.class); + assertThat(context).hasSingleBean(ToolSearchToolCallAdvisor.class); + assertThat(context.getBean(DynamicToolDiscoveryProperties.class).enabled()).isTrue(); + }); + } + @Test void whenEnabled_registersToolSearcherAndAdvisor() { contextRunner @@ -37,4 +47,3 @@ void whenDisabled_doesNotRegisterToolSearcherOrAdvisor() { }); } } -