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
Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -24,11 +26,11 @@
* <p>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.
*
* <p>The hash computation uses a sampling strategy to avoid iterating over large lists:
*
* <p>The hash computation covers all elements to guarantee that any modification
* (including edits to middle elements) is reliably detected:
* <ul>
* <li>For small lists (≤5 elements): all elements are included
* <li>For large lists: samples at positions 0, 1/4, 1/2, 3/4, and last
* <li>Uses SHA-256 over each element's {@code hashCode()} to avoid 32-bit collision risk
* <li>Includes list size in the hash to detect shrink operations
* </ul>
*
* <p>Usage in Session implementations:
Expand All @@ -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.
*
* <p>The hash includes:
*
* <ul>
* <li>List size
* <li>Hash codes of sampled elements
* </ul>
* Compute a SHA-256 hash value for a list of state objects.
*
* <p>This method is designed to be lightweight and fast, using sampling for large lists to
* avoid O(n) iteration.
* <p>遍历所有元素计算 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
Expand All @@ -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.
*
* <p>Sampling strategy:
*
* <ul>
* <li>For size ≤ 5: returns all indices [0, 1, 2, ..., size-1]
* <li>For size > 5: returns [0, size/4, size/2, size*3/4, size-1]
* </ul>
*
* @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
};
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,16 @@ static int[] calculateViewRanges(
/**
* View a specific range of lines from a text file.
*
* @param filePath The file path
* <p>使用已验证的 {@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<String> lines = Files.readAllLines(Paths.get(filePath), StandardCharsets.UTF_8);
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
StringBuilder result = new StringBuilder();

int start = Math.max(0, startLine - 1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,7 @@ public Mono<ToolResultBlock> 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(
Expand Down Expand Up @@ -328,6 +327,19 @@ public Mono<ToolResultBlock> 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: {}",
Expand All @@ -352,9 +364,9 @@ public Mono<ToolResultBlock> 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,
Expand All @@ -373,7 +385,7 @@ public Mono<ToolResultBlock> writeTextFile(
// Get content snippet
String snippet =
FileToolUtils.viewTextFile(
filePath, viewRange[0], viewRange[1]);
path, viewRange[0], viewRange[1]);

return ToolResultBlock.text(
String.format(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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]);
}
Expand Down
Loading