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:
*
- * - For small lists (≤5 elements): all elements are included
- *
- For large lists: samples at positions 0, 1/4, 1/2, 3/4, and last
+ *
- Uses SHA-256 over each element's {@code hashCode()} to avoid 32-bit collision risk
+ *
- Includes list size in the hash to detect shrink operations
*
*
* 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:
- *
- *
- * - List size
- *
- Hash codes of sampled elements
- *
+ * 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 extends State> 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:
- *
- *
- * - For size ≤ 5: returns all indices [0, 1, 2, ..., size-1]
- *
- For size > 5: returns [0, size/4, size/2, size*3/4, size-1]
- *
- *
- * @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]);
}