Skip to content

Commit a3f97e2

Browse files
authored
Add Tool Search advisor for dynamic tool discovery (#54) Fixes #49
* feat(agent): add Tool Search advisor for dynamic tool discovery * Polish * enable tool search dynamic discovery by default
1 parent 2dbb061 commit a3f97e2

7 files changed

Lines changed: 193 additions & 1 deletion

File tree

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,36 @@ Key properties in `application.yaml`:
143143
| `agent.workspace` | Path to the workspace root (default: `file:./workspace/`) |
144144
| `agent.onboarding.completed` | Set to `true` after onboarding is done |
145145
| `spring.ai.model.chat` | Active LLM provider/model |
146+
| `javaclaw.tools.dynamic-discovery.enabled` | Enable dynamic tool discovery (Tool Search Tool pattern) instead of exposing all tools up front |
146147
| `jobrunr.dashboard.port` | JobRunr dashboard port (default: `8081`) |
147148
| `jobrunr.background-job-server.worker-count` | Concurrent job workers (default: `1`) |
148149

150+
### Dynamic Tool Discovery
151+
152+
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.
153+
154+
Use it when:
155+
- You have many tools (plugins, MCP servers, skills) and prompts are getting large.
156+
- The model picks the wrong tool because the tool list is too big or too similar.
157+
158+
```yaml
159+
javaclaw:
160+
tools:
161+
dynamic-discovery:
162+
enabled: true
163+
# Optional tuning:
164+
max-results: 8
165+
lucene-min-score-threshold: 0.25
166+
```
167+
168+
Flag behavior:
169+
- `enabled=true` (default): uses the Tool Search advisor (dynamic discovery, Lucene keyword search).
170+
- `enabled=false`: eager tool exposure (legacy behavior).
171+
172+
Notes:
173+
- Tool search quality depends on `@Tool(description = "...")`. Keep descriptions specific and disambiguating.
174+
- 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.
175+
149176
## Running Tests
150177

151178
```bash

base/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ dependencies {
77
implementation 'org.springframework.modulith:spring-modulith-starter-core'
88
implementation 'org.jobrunr:jobrunr-spring-boot-4-starter:8.5.1'
99
implementation 'org.apache.commons:commons-lang3'
10+
implementation 'com.fasterxml.jackson.core:jackson-core'
1011

1112
// No idea?
1213
runtimeOnly 'io.netty:netty-resolver-dns-native-macos:4.2.10.Final'
@@ -15,6 +16,8 @@ dependencies {
1516
implementation 'org.springframework.ai:spring-ai-starter-mcp-client'
1617

1718
implementation 'org.springaicommunity:spring-ai-agent-utils:0.6.0-SNAPSHOT'
19+
implementation 'org.springaicommunity:tool-search-tool:2.0.1'
20+
implementation 'org.springaicommunity:tool-searcher-lucene:2.0.1'
1821

1922
runtimeOnly 'com.h2database:h2'
2023
testImplementation 'org.springframework.boot:spring-boot-starter-test'

base/src/main/java/ai/javaclaw/JavaClawConfiguration.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import org.springaicommunity.agent.tools.FileSystemTools;
1111
import org.springaicommunity.agent.tools.SkillsTool;
1212
import org.springaicommunity.agent.tools.SmartWebFetchTool;
13+
import org.springaicommunity.tool.search.ToolSearchToolCallAdvisor;
1314
import org.springframework.ai.chat.client.ChatClient;
1415
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
1516
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
@@ -69,6 +70,7 @@ public ChatClient.Builder chatClientBuilder(ObjectProvider<ChatModel> chatModelP
6970
@DependsOn({"mcpHeaderCustomizer"})
7071
public ChatClient chatClient(ChatClient.Builder chatClientBuilder,
7172
ChatMemory chatMemory,
73+
ObjectProvider<ToolSearchToolCallAdvisor> toolSearchToolCallAdvisorProvider,
7274
SyncMcpToolCallbackProvider mcpToolProvider,
7375
TaskManager taskManager,
7476
ConfigurationManager configurationManager,
@@ -83,6 +85,11 @@ public ChatClient chatClient(ChatClient.Builder chatClientBuilder,
8385
String agentPrompt = agentMd.getContentAsString(StandardCharsets.UTF_8) + System.lineSeparator()
8486
+ workspace.createRelative("INFO.md").getContentAsString(StandardCharsets.UTF_8) + System.lineSeparator();
8587

88+
ToolCallAdvisor toolCallAdvisor = toolSearchToolCallAdvisorProvider.getIfAvailable();
89+
if (toolCallAdvisor == null) {
90+
toolCallAdvisor = ToolCallAdvisor.builder().build();
91+
}
92+
8693
chatClientBuilder
8794
.defaultAdvisors(new SimpleLoggerAdvisor())
8895
.defaultSystem(p -> p.text(agentPrompt).param(AgentEnvironment.ENVIRONMENT_INFO_KEY, AgentEnvironment.info()))
@@ -99,7 +106,7 @@ public ChatClient chatClient(ChatClient.Builder chatClientBuilder,
99106
// Smart web fetch tool
100107
SmartWebFetchTool.builder(chatClientBuilder.clone().build()).build())
101108
.defaultAdvisors(
102-
ToolCallAdvisor.builder().build(),
109+
toolCallAdvisor,
103110
MessageChatMemoryAdvisor.builder(chatMemory).build()
104111
);
105112

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package ai.javaclaw.tools.search;
2+
3+
import org.springaicommunity.tool.search.ToolSearchToolCallAdvisor;
4+
import org.springaicommunity.tool.search.ToolSearcher;
5+
import org.springaicommunity.tool.searcher.LuceneToolSearcher;
6+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
7+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
8+
import org.springframework.context.annotation.Bean;
9+
import org.springframework.context.annotation.Configuration;
10+
11+
@Configuration
12+
@EnableConfigurationProperties(DynamicToolDiscoveryProperties.class)
13+
@ConditionalOnProperty(name = "javaclaw.tools.dynamic-discovery.enabled", havingValue = "true", matchIfMissing = true)
14+
public class DynamicToolDiscoveryConfiguration {
15+
16+
@Bean(destroyMethod = "close")
17+
public ToolSearcher toolSearcher(DynamicToolDiscoveryProperties properties) {
18+
return new LuceneToolSearcher(properties.luceneMinScoreThreshold());
19+
}
20+
21+
@Bean
22+
public ToolSearchToolCallAdvisor toolSearchToolCallAdvisor(ToolSearcher toolSearcher,
23+
DynamicToolDiscoveryProperties properties) {
24+
return ToolSearchToolCallAdvisor.builder()
25+
.toolSearcher(toolSearcher)
26+
.maxResults(properties.maxResults())
27+
.build();
28+
}
29+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package ai.javaclaw.tools.search;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
5+
6+
@ConfigurationProperties(prefix = "javaclaw.tools.dynamic-discovery")
7+
public record DynamicToolDiscoveryProperties(
8+
Boolean enabled,
9+
Integer maxResults,
10+
Float luceneMinScoreThreshold
11+
) {
12+
13+
public DynamicToolDiscoveryProperties {
14+
if (enabled == null) enabled = true;
15+
if (maxResults == null) maxResults = 8;
16+
if (luceneMinScoreThreshold == null) luceneMinScoreThreshold = 0.25f;
17+
}
18+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package ai.javaclaw.tools.search;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.springaicommunity.tool.search.ToolSearchToolCallAdvisor;
5+
import org.springaicommunity.tool.search.ToolSearcher;
6+
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
7+
8+
import static org.assertj.core.api.Assertions.assertThat;
9+
10+
class DynamicToolDiscoveryConfigurationTest {
11+
12+
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
13+
.withUserConfiguration(DynamicToolDiscoveryConfiguration.class);
14+
15+
@Test
16+
void whenPropertyIsMissing_defaultsToEnabled() {
17+
contextRunner
18+
.run(context -> {
19+
assertThat(context).hasSingleBean(ToolSearcher.class);
20+
assertThat(context).hasSingleBean(ToolSearchToolCallAdvisor.class);
21+
assertThat(context.getBean(DynamicToolDiscoveryProperties.class).enabled()).isTrue();
22+
});
23+
}
24+
25+
@Test
26+
void whenEnabled_registersToolSearcherAndAdvisor() {
27+
contextRunner
28+
.withPropertyValues(
29+
"javaclaw.tools.dynamic-discovery.enabled=true",
30+
"javaclaw.tools.dynamic-discovery.max-results=7",
31+
"javaclaw.tools.dynamic-discovery.lucene-min-score-threshold=0.0"
32+
)
33+
.run(context -> {
34+
assertThat(context).hasSingleBean(ToolSearcher.class);
35+
assertThat(context).hasSingleBean(ToolSearchToolCallAdvisor.class);
36+
assertThat(context.getBean(DynamicToolDiscoveryProperties.class).enabled()).isTrue();
37+
});
38+
}
39+
40+
@Test
41+
void whenDisabled_doesNotRegisterToolSearcherOrAdvisor() {
42+
contextRunner
43+
.withPropertyValues("javaclaw.tools.dynamic-discovery.enabled=false")
44+
.run(context -> {
45+
assertThat(context).doesNotHaveBean(ToolSearcher.class);
46+
assertThat(context).doesNotHaveBean(ToolSearchToolCallAdvisor.class);
47+
});
48+
}
49+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package ai.javaclaw.tools.search;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.springaicommunity.tool.search.ToolReference;
5+
import org.springaicommunity.tool.search.ToolSearchRequest;
6+
import org.springaicommunity.tool.searcher.LuceneToolSearcher;
7+
8+
import static org.assertj.core.api.Assertions.assertThat;
9+
10+
class LuceneToolSearcherTest {
11+
12+
@Test
13+
void returnsRelevantToolsForQuery() throws Exception {
14+
try (LuceneToolSearcher searcher = new LuceneToolSearcher(0.0f)) {
15+
String sessionId = "s1";
16+
searcher.indexTool(sessionId, new ToolReference("fileSystem", null,
17+
"Read, write, and edit local files in the workspace. Use for file operations, patches, and edits."));
18+
searcher.indexTool(sessionId, new ToolReference("webFetch", null,
19+
"Fetch a URL and extract readable content from web pages. Use for scraping and summarization."));
20+
searcher.indexTool(sessionId, new ToolReference("shell", null,
21+
"Execute shell commands to inspect the repository, run builds/tests, and automate development tasks."));
22+
23+
var response = searcher.search(new ToolSearchRequest(sessionId, "edit a local file", 5, null));
24+
25+
assertThat(response.toolReferences()).isNotEmpty();
26+
assertThat(response.toolReferences().getFirst().toolName()).isEqualTo("fileSystem");
27+
}
28+
}
29+
30+
@Test
31+
void ranksMoreRelevantToolHigherBasedOnDescription() throws Exception {
32+
try (LuceneToolSearcher searcher = new LuceneToolSearcher(0.0f)) {
33+
String sessionId = "s2";
34+
searcher.indexTool(sessionId, new ToolReference("webFetch", null,
35+
"Fetch a URL and extract page contents. Good for reading articles when you already have a URL."));
36+
searcher.indexTool(sessionId, new ToolReference("braveSearch", null,
37+
"Search the web by keyword query and return results. Use when you do not have a URL yet."));
38+
39+
var response = searcher.search(new ToolSearchRequest(sessionId, "search the web for spring ai docs", 5, null));
40+
41+
assertThat(response.toolReferences()).isNotEmpty();
42+
assertThat(response.toolReferences().getFirst().toolName()).isEqualTo("braveSearch");
43+
}
44+
}
45+
46+
@Test
47+
void honorsMaxResults() throws Exception {
48+
try (LuceneToolSearcher searcher = new LuceneToolSearcher(0.0f)) {
49+
String sessionId = "s3";
50+
for (int i = 0; i < 10; i++) {
51+
searcher.indexTool(sessionId, new ToolReference("tool-" + i, null, "tool number " + i + " for testing"));
52+
}
53+
54+
var response = searcher.search(new ToolSearchRequest(sessionId, "tool testing", 3, null));
55+
56+
assertThat(response.toolReferences().size()).isLessThanOrEqualTo(3);
57+
}
58+
}
59+
}

0 commit comments

Comments
 (0)