Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions base/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
9 changes: 8 additions & 1 deletion base/src/main/java/ai/javaclaw/JavaClawConfiguration.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -69,6 +70,7 @@ public ChatClient.Builder chatClientBuilder(ObjectProvider<ChatModel> chatModelP
@DependsOn({"mcpHeaderCustomizer"})
public ChatClient chatClient(ChatClient.Builder chatClientBuilder,
ChatMemory chatMemory,
ObjectProvider<ToolSearchToolCallAdvisor> toolSearchToolCallAdvisorProvider,
SyncMcpToolCallbackProvider mcpToolProvider,
TaskManager taskManager,
ConfigurationManager configurationManager,
Expand All @@ -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()))
Expand All @@ -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()
);

Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
});
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading