Repository navigation
Support multi-keyword queries in memory search #3282
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,7 +19,9 @@ | |
| import io.agentscope.core.tool.Tool; | ||
| import io.agentscope.core.tool.ToolParam; | ||
| import io.agentscope.harness.agent.workspace.WorkspaceManager; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.StringJoiner; | ||
| import java.util.regex.Pattern; | ||
| import org.slf4j.Logger; | ||
|
|
@@ -62,11 +64,21 @@ public String memorySearch( | |
| } | ||
|
|
||
| private String keywordSearch(RuntimeContext rc, String query) { | ||
| StringJoiner results = new StringJoiner("\n"); | ||
| int matchCount = 0; | ||
| String[] rawTokens = query.split("[\\s,,;;||、/\\\\]+"); | ||
| List<Pattern> patterns = new ArrayList<>(); | ||
| for (String t : rawTokens) { | ||
| if (!t.isEmpty()) { | ||
| patterns.add(Pattern.compile(Pattern.quote(t), Pattern.CASE_INSENSITIVE)); | ||
| } | ||
| } | ||
| if (patterns.isEmpty()) { | ||
| return "No matching memories found for: " + query; | ||
| } | ||
|
|
||
| List<String> memoryPaths = workspaceManager.listMemoryFilePaths(rc); | ||
| Pattern pattern = Pattern.compile(Pattern.quote(query), Pattern.CASE_INSENSITIVE); | ||
| StringJoiner andResults = new StringJoiner("\n"); | ||
| int andCount = 0; | ||
| List<Map.Entry<Integer, String>> orCandidates = new ArrayList<>(); | ||
|
|
||
| for (String relativePath : memoryPaths) { | ||
| String content = workspaceManager.readManagedWorkspaceFileUtf8(rc, relativePath); | ||
|
|
@@ -75,16 +87,41 @@ private String keywordSearch(RuntimeContext rc, String query) { | |
| } | ||
| String[] lines = content.split("\n", -1); | ||
| for (int i = 0; i < lines.length; i++) { | ||
| if (pattern.matcher(lines[i]).find()) { | ||
| results.add(String.format("Source: %s#%d: %s", relativePath, i + 1, lines[i])); | ||
| matchCount++; | ||
| int hits = hitCount(lines[i], patterns); | ||
| if (hits == 0) { | ||
| continue; | ||
| } | ||
| String label = String.format("Source: %s#%d: %s", relativePath, i + 1, lines[i]); | ||
| if (hits == patterns.size()) { | ||
| andResults.add(label); | ||
| andCount++; | ||
| } else { | ||
| orCandidates.add(Map.entry(hits, label)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (matchCount == 0) { | ||
| if (andCount > 0) { | ||
| return "Found " + andCount + " matches:\n\n" + andResults; | ||
| } | ||
| if (orCandidates.isEmpty()) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OR fallback is unbounded and can defeat the purpose of the fix. When none of the lines contains all tokens, every line matching any single token is emitted. For an English-word query that happens to contain one common token (e.g. Suggest capping the fallback ( |
||
| return "No matching memories found for: " + query; | ||
| } | ||
| return "Found " + matchCount + " matches:\n\n" + results; | ||
| orCandidates.sort((a, b) -> Integer.compare(b.getKey(), a.getKey())); | ||
| StringJoiner orResults = new StringJoiner("\n"); | ||
| for (Map.Entry<Integer, String> e : orCandidates) { | ||
| orResults.add(e.getValue()); | ||
| } | ||
| return "Found " + orCandidates.size() + " matches:\n\n" + orResults; | ||
| } | ||
|
|
||
| private int hitCount(String line, List<Pattern> patterns) { | ||
| int count = 0; | ||
| for (Pattern p : patterns) { | ||
| if (p.matcher(line).find()) { | ||
| count++; | ||
| } | ||
| } | ||
| return count; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| /* | ||
| * Copyright 2024-2026 the original author or authors. | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
| package io.agentscope.harness.agent.tool; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertTrue; | ||
|
|
||
| import io.agentscope.harness.agent.workspace.WorkspaceManager; | ||
| import java.io.IOException; | ||
| import java.nio.file.Files; | ||
| import java.nio.file.Path; | ||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.api.io.TempDir; | ||
|
|
||
| /** Tests for multi-keyword search in {@link MemorySearchTool}. */ | ||
| class MemorySearchToolTest { | ||
|
|
||
| @TempDir Path workspace; | ||
|
|
||
| private MemorySearchTool tool; | ||
|
|
||
| @BeforeEach | ||
| void setUp() { | ||
| tool = new MemorySearchTool(new WorkspaceManager(workspace)); | ||
| } | ||
|
|
||
| @Test | ||
| void singleKeywordMatches() throws IOException { | ||
| Files.writeString(workspace.resolve("MEMORY.md"), "- Contains Markdown formatting\n"); | ||
| String result = tool.memorySearch(null, "Markdown"); | ||
| assertTrue(result.startsWith("Found "), result); | ||
| } | ||
|
|
||
| @Test | ||
| void multiKeywordAndPassMatches() throws IOException { | ||
| Files.writeString( | ||
| workspace.resolve("MEMORY.md"), "- 用户要求创作诗歌并以 Markdown 文件形式交付,交付物为《秋日书怀》(七言律诗)\n"); | ||
| String result = tool.memorySearch(null, "秋日书怀 七言律诗"); | ||
| assertTrue(result.startsWith("Found "), result); | ||
| } | ||
|
|
||
| @Test | ||
| void multiKeywordThreeTokensAndPass() throws IOException { | ||
| Files.writeString( | ||
| workspace.resolve("MEMORY.md"), "- 用户要求创作诗歌并以 Markdown 文件形式交付,交付物为《秋日书怀》(七言律诗)\n"); | ||
| String result = tool.memorySearch(null, "诗歌 Markdown 交付"); | ||
| assertTrue(result.startsWith("Found "), result); | ||
| } | ||
|
|
||
| @Test | ||
| void orFallbackWhenNotAllTokensOnOneLine() throws IOException { | ||
| Files.writeString( | ||
| workspace.resolve("MEMORY.md"), | ||
| "- line about 诗歌 creation\n- another line about 交付\n"); | ||
| String result = tool.memorySearch(null, "诗歌 交付"); | ||
| // No single line has both; OR fallback returns lines with any match | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Assertions like |
||
| assertTrue(result.startsWith("Found "), result); | ||
| } | ||
|
|
||
| @Test | ||
| void noMatchReturnsMessage() throws IOException { | ||
| Files.writeString(workspace.resolve("MEMORY.md"), "- some unrelated content\n"); | ||
| String result = tool.memorySearch(null, "absent keyword"); | ||
| assertTrue(result.startsWith("No matching"), result); | ||
| } | ||
|
|
||
| @Test | ||
| void nullQueryReturnsNoQuery() { | ||
| String result = tool.memorySearch(null, null); | ||
| assertTrue(result.contains("No query"), result); | ||
| } | ||
|
|
||
| @Test | ||
| void blankQueryReturnsNoQuery() { | ||
| String result = tool.memorySearch(null, " "); | ||
| assertTrue(result.contains("No query"), result); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Splitting on
/and\can fragment tokens that are semantically single words — paths (a/b), dates (12/31), URLs. Combined with the OR fallback, a fragment like31will then match unrelated lines.Two smaller points on the same block:
Patternper token but only ever usePattern.quote(t)+CASE_INSENSITIVE(a literal containment test).String.toLowerCase()+line.contains(token)— or at least hoisting the compiled patterns — would avoidPattern.compileper token per call, since this runs on everymemory_searchinvocation.StringJoinerwith duplicate labels); the old code had the same behavior, so just a note.