|
11 | 11 | import java.io.File; |
12 | 12 | import java.io.IOException; |
13 | 13 | import java.io.InputStream; |
| 14 | +import java.nio.ByteBuffer; |
| 15 | +import java.nio.charset.CharacterCodingException; |
| 16 | +import java.nio.charset.CodingErrorAction; |
14 | 17 | import java.nio.charset.StandardCharsets; |
15 | 18 | import java.nio.file.AtomicMoveNotSupportedException; |
16 | 19 | import java.nio.file.Files; |
|
19 | 22 | import java.nio.file.Paths; |
20 | 23 | import java.nio.file.StandardCopyOption; |
21 | 24 | import java.nio.file.attribute.PosixFilePermission; |
| 25 | +import java.util.ArrayList; |
| 26 | +import java.util.Base64; |
22 | 27 | import java.util.Collections; |
23 | 28 | import java.util.Iterator; |
24 | 29 | import java.util.LinkedHashMap; |
| 30 | +import java.util.List; |
25 | 31 | import java.util.Locale; |
26 | 32 | import java.util.Map; |
27 | 33 | import java.util.Set; |
|
33 | 39 | public final class DefaultTools { |
34 | 40 | private static final ObjectMapper MAPPER = ArkService.defaultObjectMapper(); |
35 | 41 | private static final int MAX_OUTPUT_BYTES = 100000; |
| 42 | + private static final int DEFAULT_READ_LINES = 2000; |
| 43 | + private static final int MAX_READ_LINE_CHARS = 2000; |
36 | 44 | private static final int MAX_SEARCH_MATCHES = 1000; |
37 | 45 | private static final long PROCESS_TERMINATION_GRACE_MILLIS = 1000L; |
38 | 46 |
|
@@ -125,9 +133,68 @@ public String name() { |
125 | 133 | public ToolResult execute(Object input, ToolContext context) { |
126 | 134 | try { |
127 | 135 | Map<String, Object> args = asMap(input); |
128 | | - Path path = safePath(context, firstNonEmpty(stringValue(args.get("path")), stringValue(args.get("file")))); |
129 | | - byte[] data = readBounded(path, MAX_OUTPUT_BYTES); |
130 | | - return ToolResult.text(new String(data, StandardCharsets.UTF_8)); |
| 136 | + Path path = safePath(context, readFilePath(args)); |
| 137 | + List<Long> viewRange = readViewRange(args.get("view_range")); |
| 138 | + boolean hasViewRange = viewRange != null && !viewRange.isEmpty(); |
| 139 | + Long offset = optionalLong(args, "offset"); |
| 140 | + Long lineLimit = optionalLong(args, "limit"); |
| 141 | + if (hasViewRange && (offset != null || lineLimit != null)) { |
| 142 | + return ToolResult.error("view_range cannot be combined with offset or limit"); |
| 143 | + } |
| 144 | + MediaInfo media = detectReadMedia(path); |
| 145 | + if (media != null) { |
| 146 | + if (hasViewRange || offset != null || lineLimit != null) { |
| 147 | + return ToolResult.error( |
| 148 | + "view_range, offset, and limit are only supported for text files"); |
| 149 | + } |
| 150 | + long size = Files.size(path); |
| 151 | + long mediaLimit = context.getMaxMediaFileBytes(); |
| 152 | + if (mediaLimit == 0L) { |
| 153 | + mediaLimit = context.getMaxInputFileBytes(); |
| 154 | + } |
| 155 | + if (mediaLimit > 0L && size > mediaLimit) { |
| 156 | + return ToolResult.error("media file too large: " + size + " bytes"); |
| 157 | + } |
| 158 | + int readLimit = mediaLimit > 0L |
| 159 | + ? (int) Math.min(mediaLimit + 1L, Integer.MAX_VALUE) |
| 160 | + : Integer.MAX_VALUE; |
| 161 | + byte[] data = mediaLimit > 0L |
| 162 | + ? readBounded(path, readLimit) |
| 163 | + : Files.readAllBytes(path); |
| 164 | + if (mediaLimit > 0L && data.length > mediaLimit) { |
| 165 | + return ToolResult.error("media file too large: " + Math.max(size, data.length) + " bytes"); |
| 166 | + } |
| 167 | + ContentBlock block = new ContentBlock(); |
| 168 | + block.setType(media.blockType); |
| 169 | + Map<String, Object> source = new LinkedHashMap<>(); |
| 170 | + source.put("type", "base64"); |
| 171 | + source.put("media_type", media.mediaType); |
| 172 | + source.put("data", Base64.getEncoder().encodeToString(data)); |
| 173 | + block.setSource(source); |
| 174 | + return new ToolResult(Collections.singletonList(block), false); |
| 175 | + } |
| 176 | + long configuredLimit = context.getMaxInputFileBytes(); |
| 177 | + int byteLimit = configuredLimit > 0L |
| 178 | + ? (int) Math.min(configuredLimit, Integer.MAX_VALUE) |
| 179 | + : MAX_OUTPUT_BYTES; |
| 180 | + long size = Files.size(path); |
| 181 | + if (byteLimit > 0 && size > byteLimit) { |
| 182 | + return ToolResult.error("file too large: " + size + " bytes"); |
| 183 | + } |
| 184 | + String text; |
| 185 | + try { |
| 186 | + text = decodeUTF8(readBounded(path, byteLimit)); |
| 187 | + } catch (CharacterCodingException e) { |
| 188 | + return ToolResult.error("binary file cannot be read directly"); |
| 189 | + } |
| 190 | + if (hasViewRange) { |
| 191 | + return ToolResult.text(renderViewRange(text, viewRange)); |
| 192 | + } |
| 193 | + if (offset != null && offset < 1L) { |
| 194 | + return ToolResult.error( |
| 195 | + "offset is the 1-based start line and must be >= 1, got " + offset); |
| 196 | + } |
| 197 | + return ToolResult.text(renderReadLines(text, offset, lineLimit)); |
131 | 198 | } catch (Exception e) { |
132 | 199 | return ToolResult.error(e.getMessage()); |
133 | 200 | } |
@@ -298,6 +365,218 @@ static Map<String, Object> asMap(Object input) { |
298 | 365 | return Collections.emptyMap(); |
299 | 366 | } |
300 | 367 |
|
| 368 | + private static MediaInfo detectReadMedia(Path path) throws IOException { |
| 369 | + byte[] header = readBounded(path, 512); |
| 370 | + if (startsWith(header, new byte[] {(byte) 0xff, (byte) 0xd8, (byte) 0xff})) { |
| 371 | + return new MediaInfo("image", "image/jpeg"); |
| 372 | + } |
| 373 | + if (startsWith(header, new byte[] {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', (byte) 0x1a, '\n'})) { |
| 374 | + return new MediaInfo("image", "image/png"); |
| 375 | + } |
| 376 | + if (startsWith(header, "GIF87a".getBytes(StandardCharsets.US_ASCII)) |
| 377 | + || startsWith(header, "GIF89a".getBytes(StandardCharsets.US_ASCII))) { |
| 378 | + return new MediaInfo("image", "image/gif"); |
| 379 | + } |
| 380 | + if (header.length >= 12 |
| 381 | + && startsWith(header, "RIFF".getBytes(StandardCharsets.US_ASCII)) |
| 382 | + && matchesAt(header, 8, "WEBP".getBytes(StandardCharsets.US_ASCII))) { |
| 383 | + return new MediaInfo("image", "image/webp"); |
| 384 | + } |
| 385 | + if (startsWith(header, "%PDF-".getBytes(StandardCharsets.US_ASCII))) { |
| 386 | + return new MediaInfo("document", "application/pdf"); |
| 387 | + } |
| 388 | + if (!looksBinary(header)) { |
| 389 | + return null; |
| 390 | + } |
| 391 | + String name = path.getFileName().toString().toLowerCase(Locale.ROOT); |
| 392 | + if (name.endsWith(".jpg") || name.endsWith(".jpeg")) { |
| 393 | + return new MediaInfo("image", "image/jpeg"); |
| 394 | + } |
| 395 | + if (name.endsWith(".png")) { |
| 396 | + return new MediaInfo("image", "image/png"); |
| 397 | + } |
| 398 | + if (name.endsWith(".gif")) { |
| 399 | + return new MediaInfo("image", "image/gif"); |
| 400 | + } |
| 401 | + if (name.endsWith(".webp")) { |
| 402 | + return new MediaInfo("image", "image/webp"); |
| 403 | + } |
| 404 | + if (name.endsWith(".pdf")) { |
| 405 | + return new MediaInfo("document", "application/pdf"); |
| 406 | + } |
| 407 | + return null; |
| 408 | + } |
| 409 | + |
| 410 | + private static boolean startsWith(byte[] value, byte[] prefix) { |
| 411 | + return matchesAt(value, 0, prefix); |
| 412 | + } |
| 413 | + |
| 414 | + private static boolean matchesAt(byte[] value, int offset, byte[] expected) { |
| 415 | + if (value.length - offset < expected.length) { |
| 416 | + return false; |
| 417 | + } |
| 418 | + for (int index = 0; index < expected.length; index++) { |
| 419 | + if (value[offset + index] != expected[index]) { |
| 420 | + return false; |
| 421 | + } |
| 422 | + } |
| 423 | + return true; |
| 424 | + } |
| 425 | + |
| 426 | + private static boolean looksBinary(byte[] value) { |
| 427 | + for (byte item : value) { |
| 428 | + if (item == 0) { |
| 429 | + return true; |
| 430 | + } |
| 431 | + } |
| 432 | + try { |
| 433 | + StandardCharsets.UTF_8 |
| 434 | + .newDecoder() |
| 435 | + .onMalformedInput(CodingErrorAction.REPORT) |
| 436 | + .onUnmappableCharacter(CodingErrorAction.REPORT) |
| 437 | + .decode(ByteBuffer.wrap(value)); |
| 438 | + return false; |
| 439 | + } catch (CharacterCodingException error) { |
| 440 | + return true; |
| 441 | + } |
| 442 | + } |
| 443 | + |
| 444 | + private static long longValue(Object value, String name) { |
| 445 | + if (value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long) { |
| 446 | + return ((Number) value).longValue(); |
| 447 | + } |
| 448 | + if (value == null || value.toString().isEmpty()) { |
| 449 | + return 0L; |
| 450 | + } |
| 451 | + try { |
| 452 | + return Long.parseLong(value.toString()); |
| 453 | + } catch (NumberFormatException error) { |
| 454 | + throw new IllegalArgumentException(name + " must be an integer", error); |
| 455 | + } |
| 456 | + } |
| 457 | + |
| 458 | + private static String readFilePath(Map<String, Object> args) { |
| 459 | + String path = ""; |
| 460 | + for (String name : new String[] {"file_path", "path", "file"}) { |
| 461 | + Object value = args.get(name); |
| 462 | + if (value != null && !(value instanceof String)) { |
| 463 | + throw new IllegalArgumentException(name + " must be a string"); |
| 464 | + } |
| 465 | + String candidate = stringValue(value); |
| 466 | + if (candidate.isEmpty()) { |
| 467 | + continue; |
| 468 | + } |
| 469 | + if (!path.isEmpty() && !path.equals(candidate)) { |
| 470 | + throw new IllegalArgumentException("file_path, path, and file must not conflict"); |
| 471 | + } |
| 472 | + path = candidate; |
| 473 | + } |
| 474 | + if (path.isEmpty()) { |
| 475 | + throw new IllegalArgumentException("file_path is required"); |
| 476 | + } |
| 477 | + return path; |
| 478 | + } |
| 479 | + |
| 480 | + private static Long optionalLong(Map<String, Object> args, String name) { |
| 481 | + return args.containsKey(name) && args.get(name) != null ? longValue(args.get(name), name) : null; |
| 482 | + } |
| 483 | + |
| 484 | + private static List<Long> readViewRange(Object value) { |
| 485 | + if (value == null) { |
| 486 | + return null; |
| 487 | + } |
| 488 | + if (!(value instanceof List)) { |
| 489 | + throw new IllegalArgumentException("view_range must be [start_line, end_line]"); |
| 490 | + } |
| 491 | + List<?> raw = (List<?>) value; |
| 492 | + if (raw.isEmpty()) { |
| 493 | + return Collections.emptyList(); |
| 494 | + } |
| 495 | + if (raw.size() != 2) { |
| 496 | + throw new IllegalArgumentException("view_range must be [start_line, end_line]"); |
| 497 | + } |
| 498 | + List<Long> result = new ArrayList<>(2); |
| 499 | + result.add(longValue(raw.get(0), "view_range")); |
| 500 | + result.add(longValue(raw.get(1), "view_range")); |
| 501 | + return result; |
| 502 | + } |
| 503 | + |
| 504 | + private static String decodeUTF8(byte[] data) throws CharacterCodingException { |
| 505 | + return StandardCharsets.UTF_8 |
| 506 | + .newDecoder() |
| 507 | + .onMalformedInput(CodingErrorAction.REPORT) |
| 508 | + .onUnmappableCharacter(CodingErrorAction.REPORT) |
| 509 | + .decode(ByteBuffer.wrap(data)) |
| 510 | + .toString(); |
| 511 | + } |
| 512 | + |
| 513 | + private static String renderViewRange(String text, List<Long> viewRange) { |
| 514 | + String[] lines = text.split("\n", -1); |
| 515 | + long startLine = viewRange.get(0); |
| 516 | + long zeroBasedStart = startLine <= 1L ? 0L : startLine - 1L; |
| 517 | + int start = (int) Math.min(zeroBasedStart, lines.length); |
| 518 | + int end = lines.length; |
| 519 | + if (viewRange.get(1) > 0L) { |
| 520 | + end = (int) Math.min(viewRange.get(1), lines.length); |
| 521 | + } |
| 522 | + if (end < start) { |
| 523 | + throw new IllegalArgumentException( |
| 524 | + "view_range end line " + viewRange.get(1) |
| 525 | + + " is before start line " + viewRange.get(0)); |
| 526 | + } |
| 527 | + StringBuilder output = new StringBuilder(); |
| 528 | + for (int index = start; index < end; index++) { |
| 529 | + if (index > start) { |
| 530 | + output.append('\n'); |
| 531 | + } |
| 532 | + output.append(lines[index]); |
| 533 | + } |
| 534 | + return output.toString(); |
| 535 | + } |
| 536 | + |
| 537 | + private static String renderReadLines(String text, Long offset, Long limit) { |
| 538 | + String[] lines = text.split("\n", -1); |
| 539 | + int total = lines.length; |
| 540 | + if (total > 0 && lines[total - 1].isEmpty() && text.endsWith("\n")) { |
| 541 | + total--; |
| 542 | + } |
| 543 | + long startValue = offset == null ? 0L : offset - 1L; |
| 544 | + int start = (int) Math.min(startValue, total); |
| 545 | + long count = limit != null && limit > 0L ? limit : DEFAULT_READ_LINES; |
| 546 | + int end = count >= total - start ? total : start + (int) count; |
| 547 | + StringBuilder output = new StringBuilder(); |
| 548 | + for (int index = start; index < end; index++) { |
| 549 | + output.append(String.format(Locale.ROOT, "%6d\t%s\n", index + 1, truncateLine(lines[index]))); |
| 550 | + } |
| 551 | + if (end < total) { |
| 552 | + output.append(String.format( |
| 553 | + Locale.ROOT, |
| 554 | + "\n[truncated: showing lines %d-%d of %d]\n", |
| 555 | + start + 1, |
| 556 | + end, |
| 557 | + total)); |
| 558 | + } |
| 559 | + return output.toString(); |
| 560 | + } |
| 561 | + |
| 562 | + private static String truncateLine(String line) { |
| 563 | + if (line.codePointCount(0, line.length()) <= MAX_READ_LINE_CHARS) { |
| 564 | + return line; |
| 565 | + } |
| 566 | + return line.substring(0, line.offsetByCodePoints(0, MAX_READ_LINE_CHARS)) |
| 567 | + + " [line truncated]"; |
| 568 | + } |
| 569 | + |
| 570 | + private static class MediaInfo { |
| 571 | + final String blockType; |
| 572 | + final String mediaType; |
| 573 | + |
| 574 | + MediaInfo(String blockType, String mediaType) { |
| 575 | + this.blockType = blockType; |
| 576 | + this.mediaType = mediaType; |
| 577 | + } |
| 578 | + } |
| 579 | + |
301 | 580 | private static int appendMatches( |
302 | 581 | Pattern regex, |
303 | 582 | ToolContext context, |
|
0 commit comments