Skip to content
Open
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
@@ -0,0 +1,54 @@
/*
* 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 java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.regex.Pattern;

/** Combines literal matchers without changing each tool's existing case-matching semantics. */
final class KeywordMatcher {
private static final Pattern WHITESPACE =
Pattern.compile("\\s+", Pattern.UNICODE_CHARACTER_CLASS);

private KeywordMatcher() {}

static Predicate<String> compile(
String query, String matchMode, Function<String, Predicate<String>> literalMatcher) {
String mode = matchMode == null ? "phrase" : matchMode;
if (mode.equals("phrase")) {
return literalMatcher.apply(query);
}
if (!mode.equals("all") && !mode.equals("any")) {
throw new IllegalArgumentException("matchMode must be one of: phrase, all, any");

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.

[Info] Returning an explicit error for an unknown mode is the right call for a model-supplied parameter (the alternative, silently falling back to phrase, produces "no results" that the agent cannot diagnose). Worth a line in the docs table, since #3056 chooses the silent-fallback behaviour for the same input — if both land, the flag would mean different things depending on which merged.

}
List<Predicate<String>> terms =
Arrays.stream(WHITESPACE.split(query))
.filter(term -> !term.isEmpty())
.distinct()
.map(literalMatcher)
.toList();
// Do not let an empty ALL query match every record.
if (terms.isEmpty()) {
throw new IllegalArgumentException("query must contain at least one keyword");
}
return mode.equals("all")
? text -> terms.stream().allMatch(term -> term.test(text))
: text -> terms.stream().anyMatch(term -> term.test(text));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import io.agentscope.harness.agent.workspace.WorkspaceManager;
import java.util.List;
import java.util.StringJoiner;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -42,6 +43,11 @@ public MemorySearchTool(WorkspaceManager workspaceManager) {
this.workspaceManager = workspaceManager;
}

/** Retains the original Java API and its literal phrase matching behavior. */
public String memorySearch(RuntimeContext runtimeContext, String query) {
return memorySearch(runtimeContext, query, null);
}

@Tool(
name = "memory_search",
readOnly = true,
Expand All @@ -51,22 +57,46 @@ public MemorySearchTool(WorkspaceManager workspaceManager) {
+ " work, decisions, dates, people, preferences, or todos.")
public String memorySearch(
RuntimeContext runtimeContext,
@ToolParam(name = "query", description = "Keywords to search for in memory files")
String query) {
@ToolParam(
name = "query",
description =
"Literal phrase, or whitespace-separated keywords when"
+ " matchMode is all/any; no automatic Chinese word"
+ " segmentation")
String query,
@ToolParam(
name = "matchMode",
description =
"phrase (default): exact substring; all: every keyword in the"
+ " same memory line; any: at least one keyword in that"
+ " line. Case-insensitive literal matching.",
required = false)
String matchMode) {
if (query == null || query.isBlank()) {
return "No query provided";
}

RuntimeContext rc = runtimeContext != null ? runtimeContext : RuntimeContext.empty();
return keywordSearch(rc, query);
Predicate<String> matcher;
try {
matcher =
KeywordMatcher.compile(
query,
matchMode,
term ->
Pattern.compile(Pattern.quote(term), Pattern.CASE_INSENSITIVE)
.asPredicate());

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.

[Warning] The memory path matches via Pattern.compile(Pattern.quote(term), CASE_INSENSITIVE).asPredicate(), but SessionSearchTool (line 107) matches via text.toLowerCase().contains(lowerTerm). Both are correct today, yet they encode different notions of case-insensitivity: toLowerCase() is locale-sensitive (Turkish-I dotless issue) while CASE_INSENSITIVE regex is not, so the same query can behave differently between the two tools on non-ASCII text.

Consider routing both through the same literal matcher, or at minimum toLowerCase(Locale.ROOT), so memory_search and session_search cannot drift.

} catch (IllegalArgumentException e) {
return "Error: " + e.getMessage();
}
return keywordSearch(rc, query, matcher);
}

private String keywordSearch(RuntimeContext rc, String query) {
private String keywordSearch(RuntimeContext rc, String query, Predicate<String> matcher) {
StringJoiner results = new StringJoiner("\n");
int matchCount = 0;

List<String> memoryPaths = workspaceManager.listMemoryFilePaths(rc);
Pattern pattern = Pattern.compile(Pattern.quote(query), Pattern.CASE_INSENSITIVE);

for (String relativePath : memoryPaths) {
String content = workspaceManager.readManagedWorkspaceFileUtf8(rc, relativePath);
Expand All @@ -75,7 +105,7 @@ 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()) {
if (matcher.test(lines[i])) {
results.add(String.format("Source: %s#%d: %s", relativePath, i + 1, lines[i]));
matchCount++;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -49,6 +50,12 @@ public SessionSearchTool(WorkspaceManager workspaceManager) {
this.workspaceManager = workspaceManager;
}

/** Retains the original Java API and its literal phrase matching behavior. */
public String sessionSearch(
RuntimeContext runtimeContext, String query, String agentId, Integer maxResults) {
return sessionSearch(runtimeContext, query, agentId, maxResults, null);
}

@Tool(
name = "session_search",
readOnly = true,
Expand All @@ -57,7 +64,12 @@ public SessionSearchTool(WorkspaceManager workspaceManager) {
+ " Returns matching entries with session context.")
public String sessionSearch(
RuntimeContext runtimeContext,
@ToolParam(name = "query", description = "Search query (keyword or phrase)")
@ToolParam(
name = "query",
description =
"Literal phrase, or whitespace-separated keywords when"
+ " matchMode is all/any; no automatic Chinese word"
+ " segmentation")
String query,
@ToolParam(
name = "agentId",
Expand All @@ -68,15 +80,35 @@ public String sessionSearch(
name = "maxResults",
description = "Maximum number of results to return (default: 10)",
required = false)
Integer maxResults) {
Integer maxResults,
@ToolParam(
name = "matchMode",
description =
"phrase (default): exact substring; all: every keyword in the"
+ " same session entry; any: at least one keyword in that"
+ " entry. Case-insensitive literal matching.",
required = false)
String matchMode) {
if (query == null || query.isBlank()) {
return "Error: query is required";
}

RuntimeContext rc = runtimeContext != null ? runtimeContext : RuntimeContext.empty();
int limit = maxResults != null && maxResults > 0 ? maxResults : 10;
String effectiveAgentId = agentId != null && !agentId.isBlank() ? agentId : null;
String lowerQuery = query.toLowerCase();
Predicate<String> matcher;
try {
matcher =
KeywordMatcher.compile(
query,
matchMode,
term -> {
String lowerTerm = term.toLowerCase();
return text -> text.toLowerCase().contains(lowerTerm);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: each term predicate lowercases the full session entry independently, so an all/any query with N terms can allocate N lowercase copies per entry. Could we normalize content once per entry (while preserving the existing locale behavior) and run the predicates against that normalized string?

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.

[Warning] Same point as the memory-side comment: text.toLowerCase() here uses the default locale, and is also evaluated per term per record (for mode=all with N keywords, the same text is lowercased N times). Resolving the text once per record and using Locale.ROOT fixes both the allocation and the locale sensitivity.

});
} catch (IllegalArgumentException e) {
return "Error: " + e.getMessage();
}

List<String> results = new ArrayList<>();

Expand All @@ -85,7 +117,7 @@ public String sessionSearch(
if (results.size() >= limit) {
break;
}
searchInSessionFile(file, lowerQuery, results, limit);
searchInSessionFile(file, matcher, results, limit);
}

if (results.isEmpty()) {
Expand Down Expand Up @@ -267,7 +299,7 @@ private void collectLogFiles(Path sessionDir, List<Path> collector) {
}

private void searchInSessionFile(
Path logFile, String lowerQuery, List<String> results, int limit) {
Path logFile, Predicate<String> matcher, List<String> results, int limit) {
try {
Path contextFile =
logFile.resolveSibling(
Expand All @@ -285,7 +317,7 @@ private void searchInSessionFile(
break;
}
String content = searchableText(entry);
if (content != null && content.toLowerCase().contains(lowerQuery)) {
if (content != null && matcher.test(content)) {
String preview =
content.length() > 200 ? content.substring(0, 200) + "..." : content;
String roleLabel =
Expand Down
Loading
Loading