-
Notifications
You must be signed in to change notification settings - Fork 1.3k
feat(harness): 为记忆与会话检索增加可选多关键词匹配模式 #3062
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
base: main
Are you sure you want to change the base?
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 |
|---|---|---|
| @@ -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"); | ||
| } | ||
| 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 |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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, | ||
|
|
@@ -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()); | ||
|
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. [Warning] The memory path matches via Consider routing both through the same literal matcher, or at minimum |
||
| } 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); | ||
|
|
@@ -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++; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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, | ||
|
|
@@ -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", | ||
|
|
@@ -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); | ||
|
Contributor
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. Non-blocking: each term predicate lowercases the full session entry independently, so an
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. [Warning] Same point as the memory-side comment: |
||
| }); | ||
| } catch (IllegalArgumentException e) { | ||
| return "Error: " + e.getMessage(); | ||
| } | ||
|
|
||
| List<String> results = new ArrayList<>(); | ||
|
|
||
|
|
@@ -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()) { | ||
|
|
@@ -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( | ||
|
|
@@ -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 = | ||
|
|
||
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.
[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.