Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,,;;||、/\\\\]+");

Copy link
Copy Markdown
Collaborator

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 like 31 will then match unrelated lines.

Two smaller points on the same block:

  • Lines 70-72 build one Pattern per token but only ever use Pattern.quote(t) + CASE_INSENSITIVE (a literal containment test). String.toLowerCase() + line.contains(token) — or at least hoisting the compiled patterns — would avoid Pattern.compile per token per call, since this runs on every memory_search invocation.
  • No dedup when a memory file contains the same line twice (StringJoiner with duplicate labels); the old code had the same behavior, so just a note.

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);
Expand All @@ -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()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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. 配置 issue or fix bug), this can dump hundreds of lines straight into the agent context window.

Suggest capping the fallback (limit(N) after sorting by hit count) and reporting truncation, e.g. "Found 132 matches (showing top 20):". This also keeps output predictable for the LLM.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assertions like result.startsWith("Found ") pass on both the AND path and the OR-fallback path, so a regression that accidentally always falls back to OR would not be caught. Suggest asserting on the specific path (and on ordering, since ranking is the main new behavior), e.g. check that the line containing both keywords appears before a line containing only one of them.

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);
}
}
Loading