diff --git a/README.md b/README.md index b93e779..e1a3a01 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=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. +- 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..2340de9 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,8 @@ 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' 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..be12984 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryConfiguration.java @@ -0,0 +1,29 @@ +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", matchIfMissing = 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..8b211a4 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/tools/search/DynamicToolDiscoveryProperties.java @@ -0,0 +1,18 @@ +package ai.javaclaw.tools.search; + +import org.springframework.boot.context.properties.ConfigurationProperties; + + +@ConfigurationProperties(prefix = "javaclaw.tools.dynamic-discovery") +public record DynamicToolDiscoveryProperties( + 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 new file mode 100644 index 0000000..b78f67e --- /dev/null +++ b/base/src/test/java/ai/javaclaw/tools/search/DynamicToolDiscoveryConfigurationTest.java @@ -0,0 +1,49 @@ +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 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 + .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); + } + } +}