diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/MediaUtils.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/MediaUtils.java index 6bca3945c..c80aef12b 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/MediaUtils.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/MediaUtils.java @@ -186,6 +186,9 @@ public static String toFileProtocolUrl(String path) throws IOException { * @throws IOException If the resource read failed */ public static InputStream urlToInputStream(String url) throws IOException { + if (url == null || url.isBlank()) { + throw new IOException("URL cannot be null or blank"); + } if (isFileExists(url)) { // Treat as local file return Files.newInputStream(Path.of(url)); diff --git a/agentscope-core/src/main/java/io/agentscope/core/session/ListHashUtil.java b/agentscope-core/src/main/java/io/agentscope/core/session/ListHashUtil.java index ef8e84e3f..be3a3ae86 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/session/ListHashUtil.java +++ b/agentscope-core/src/main/java/io/agentscope/core/session/ListHashUtil.java @@ -16,6 +16,8 @@ package io.agentscope.core.session; import io.agentscope.core.state.State; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.List; /** @@ -24,11 +26,11 @@ *

This class provides hash computation for change detection in Session implementations. The hash * is used to detect if a list has been modified (not just appended) since the last save operation. * - *

The hash computation uses a sampling strategy to avoid iterating over large lists: - * + *

The hash computation covers all elements to guarantee that any modification + * (including edits to middle elements) is reliably detected: *

* *

Usage in Session implementations: @@ -51,25 +53,15 @@ public final class ListHashUtil { /** Empty list hash constant. */ private static final String EMPTY_HASH = "empty:0"; - /** Threshold for using sampling strategy. */ - private static final int SAMPLING_THRESHOLD = 5; - private ListHashUtil() { // Utility class, prevent instantiation } /** - * Compute a hash value for a list of state objects. - * - *

The hash includes: - * - *

+ * Compute a SHA-256 hash value for a list of state objects. * - *

This method is designed to be lightweight and fast, using sampling for large lists to - * avoid O(n) iteration. + *

遍历所有元素计算 hash,确保任意位置的元素变更(包括中间元素)都能被检测到。 + * 使用 SHA-256 代替 {@code String.hashCode()} 以避免 32 位整数碰撞风险。 * * @param values the list of state objects to hash * @return a hex string hash representing the list content @@ -79,47 +71,34 @@ public static String computeHash(List values) { return EMPTY_HASH; } - int size = values.size(); - StringBuilder sb = new StringBuilder(); - sb.append("size:").append(size).append(";"); - - // Get sample indices based on list size - int[] sampleIndices = getSampleIndices(size); - - for (int idx : sampleIndices) { - State item = values.get(idx); - int itemHash = item != null ? item.hashCode() : 0; - sb.append(idx).append(":").append(itemHash).append(","); + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + int size = values.size(); + // 将 size 纳入 hash,防止仅缩短列表时碰撞 + digest.update(intToBytes(size)); + for (State item : values) { + digest.update(intToBytes(item != null ? item.hashCode() : 0)); + } + byte[] hashBytes = digest.digest(); + // 取前8字节(64位)转十六进制,已足够唯一 + StringBuilder hex = new StringBuilder(16); + for (int i = 0; i < 8; i++) { + hex.append(String.format("%02x", hashBytes[i])); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + // SHA-256 是 JVM 标准算法,不会出现此异常 + throw new IllegalStateException("SHA-256 algorithm not available", e); } - - return Integer.toHexString(sb.toString().hashCode()); } /** - * Get the indices to sample from a list of given size. - * - *

Sampling strategy: - * - *

- * - * @param size the size of the list - * @return array of indices to sample + * 将 int 转为大端序 4 字节数组。 */ - private static int[] getSampleIndices(int size) { - if (size <= SAMPLING_THRESHOLD) { - // Small list: sample all elements - int[] indices = new int[size]; - for (int i = 0; i < size; i++) { - indices[i] = i; - } - return indices; - } - - // Large list: sample at key positions - return new int[] {0, size / 4, size / 2, size * 3 / 4, size - 1}; + private static byte[] intToBytes(int value) { + return new byte[] { + (byte) (value >>> 24), (byte) (value >>> 16), (byte) (value >>> 8), (byte) value + }; } /** diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/file/FileToolUtils.java b/agentscope-core/src/main/java/io/agentscope/core/tool/file/FileToolUtils.java index e4a89ccbb..f13b91a94 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/file/FileToolUtils.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/file/FileToolUtils.java @@ -110,14 +110,16 @@ static int[] calculateViewRanges( /** * View a specific range of lines from a text file. * - * @param filePath The file path + *

使用已验证的 {@link Path} 对象读取,避免相对路径基于 CWD 而非 baseDir 解析导致读取到错误文件。 + * + * @param path 已验证的文件绝对路径 * @param startLine Start line number (1-based) * @param endLine End line number (1-based, inclusive) * @return The content with line numbers */ - static String viewTextFile(String filePath, int startLine, int endLine) { + static String viewTextFile(Path path, int startLine, int endLine) { try { - List lines = Files.readAllLines(Paths.get(filePath), StandardCharsets.UTF_8); + List lines = Files.readAllLines(path, StandardCharsets.UTF_8); StringBuilder result = new StringBuilder(); int start = Math.max(0, startLine - 1); diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/file/WriteFileTool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/file/WriteFileTool.java index 3e53f168f..eaca92669 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/file/WriteFileTool.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/file/WriteFileTool.java @@ -196,8 +196,7 @@ public Mono insertTextFile( // Get the content snippet to show String showContent = - FileToolUtils.viewTextFile( - filePath, viewRange[0], viewRange[1]); + FileToolUtils.viewTextFile(path, viewRange[0], viewRange[1]); return ToolResultBlock.text( String.format( @@ -328,6 +327,19 @@ public Mono writeTextFile( logger.debug( "Replacing lines {}-{} in file: {}", start, end, filePath); + // WriteFileTool 行号从 1 开始,不支持负数索引;start 不能大于 end + if (start < 1 || start > end) { + logger.warn( + "Invalid range [{}, {}]: start must be >= 1 and <= end", + start, + end); + return ToolResultBlock.error( + String.format( + "Invalid range [%d, %d]: start must be >= 1" + + " and <= end.", + start, end)); + } + if (start > originalLines.size()) { logger.warn( "Start line {} exceeds file length {} for file: {}", @@ -352,9 +364,9 @@ public Mono writeTextFile( originalLines.subList(end, originalLines.size())); } - // Write the new content - String joinedContent = String.join("\n", newContent); - Files.writeString(path, joinedContent, StandardCharsets.UTF_8); + // 与 insertTextFile 保持一致,用 Files.write 写行列表, + // 每行自动追加系统换行符,并保留文件末尾换行符 + Files.write(path, newContent, StandardCharsets.UTF_8); logger.info( "Successfully replaced lines {}-{} in file: {}", start, @@ -373,7 +385,7 @@ public Mono writeTextFile( // Get content snippet String snippet = FileToolUtils.viewTextFile( - filePath, viewRange[0], viewRange[1]); + path, viewRange[0], viewRange[1]); return ToolResultBlock.text( String.format( diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/file/FileToolUtilsTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/file/FileToolUtilsTest.java index f9b8cccb1..d4f09ca20 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/file/FileToolUtilsTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/file/FileToolUtilsTest.java @@ -113,7 +113,7 @@ void testCalculateViewRanges_Replacement() { @Test @DisplayName("Should view entire file content") void testViewTextFile_EntireFile() { - String content = FileToolUtils.viewTextFile(testFile.toString(), 1, 10); + String content = FileToolUtils.viewTextFile(testFile, 1, 10); assertNotNull(content); assertTrue(content.contains("1: Line 1"), "Should contain line 1"); @@ -124,7 +124,7 @@ void testViewTextFile_EntireFile() { @Test @DisplayName("Should view specific range of file") void testViewTextFile_SpecificRange() { - String content = FileToolUtils.viewTextFile(testFile.toString(), 3, 5); + String content = FileToolUtils.viewTextFile(testFile, 3, 5); assertNotNull(content); assertTrue(content.contains("3: Line 3"), "Should contain line 3"); @@ -138,7 +138,7 @@ void testViewTextFile_SpecificRange() { @Test @DisplayName("Should handle view range beyond file length") void testViewTextFile_RangeBeyondFile() { - String content = FileToolUtils.viewTextFile(testFile.toString(), 5, 20); + String content = FileToolUtils.viewTextFile(testFile, 5, 20); assertNotNull(content); assertTrue(content.contains("5: Line 5"), "Should contain line 5"); @@ -149,7 +149,7 @@ void testViewTextFile_RangeBeyondFile() { @Test @DisplayName("Should handle view with invalid start line") void testViewTextFile_InvalidStartLine() { - String content = FileToolUtils.viewTextFile(testFile.toString(), 0, 5); + String content = FileToolUtils.viewTextFile(testFile, 0, 5); assertNotNull(content); assertTrue(content.contains("1: Line 1"), "Should start from line 1"); @@ -159,7 +159,8 @@ void testViewTextFile_InvalidStartLine() { @Test @DisplayName("Should handle non-existent file") void testViewTextFile_NonExistentFile() { - String content = FileToolUtils.viewTextFile("non_existent.txt", 1, 10); + String content = + FileToolUtils.viewTextFile(java.nio.file.Paths.get("non_existent.txt"), 1, 10); assertNotNull(content); assertTrue(content.contains("Error reading file"), "Should contain error message"); @@ -199,12 +200,11 @@ void testParseRanges_WithSpaces() { } @Test - @DisplayName("Should parse negative range") + @DisplayName("Should parse negative range (negative indices supported by ReadFileTool)") void testParseRanges_NegativeNumbers() { + // parseRanges 只做格式解析,不做语义校验;负数索引由上层工具(ReadFileTool)处理 int[] range = FileToolUtils.parseRanges("-100,-1"); - assertNotNull(range); - assertEquals(2, range.length); assertEquals(-100, range[0]); assertEquals(-1, range[1]); }