Skip to content

Commit d70db8b

Browse files
committed
feat(selfHosted): support interrupt and multimodal read
Hand-written change from the internal SDK repository. The original commit message is intentionally not mirrored; see the source repository for details. Sync-Source-Commit: d5a5db91ff33a125c72154e09f51f187866a57c9 Hand-Written-Reason: No Ark-APIs provenance marker; treated as a hand-written source commit. Release-Version: 0.9.0
1 parent 5689920 commit d70db8b

7 files changed

Lines changed: 920 additions & 51 deletions

File tree

‎src/main/java/com/volcengine/ark/runtime/selfhosted/DefaultTools.java‎

Lines changed: 282 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
import java.io.File;
1212
import java.io.IOException;
1313
import java.io.InputStream;
14+
import java.nio.ByteBuffer;
15+
import java.nio.charset.CharacterCodingException;
16+
import java.nio.charset.CodingErrorAction;
1417
import java.nio.charset.StandardCharsets;
1518
import java.nio.file.AtomicMoveNotSupportedException;
1619
import java.nio.file.Files;
@@ -19,9 +22,12 @@
1922
import java.nio.file.Paths;
2023
import java.nio.file.StandardCopyOption;
2124
import java.nio.file.attribute.PosixFilePermission;
25+
import java.util.ArrayList;
26+
import java.util.Base64;
2227
import java.util.Collections;
2328
import java.util.Iterator;
2429
import java.util.LinkedHashMap;
30+
import java.util.List;
2531
import java.util.Locale;
2632
import java.util.Map;
2733
import java.util.Set;
@@ -33,6 +39,8 @@
3339
public final class DefaultTools {
3440
private static final ObjectMapper MAPPER = ArkService.defaultObjectMapper();
3541
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;
3644
private static final int MAX_SEARCH_MATCHES = 1000;
3745
private static final long PROCESS_TERMINATION_GRACE_MILLIS = 1000L;
3846

@@ -125,9 +133,68 @@ public String name() {
125133
public ToolResult execute(Object input, ToolContext context) {
126134
try {
127135
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));
131198
} catch (Exception e) {
132199
return ToolResult.error(e.getMessage());
133200
}
@@ -298,6 +365,218 @@ static Map<String, Object> asMap(Object input) {
298365
return Collections.emptyMap();
299366
}
300367

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+
301580
private static int appendMatches(
302581
Pattern regex,
303582
ToolContext context,

‎src/main/java/com/volcengine/ark/runtime/selfhosted/Event.java‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,10 @@ public Object getInput() {
221221
return input;
222222
}
223223

224+
public String getProcessedAt() {
225+
return processedAt;
226+
}
227+
224228
public String getEvaluatedPermission() {
225229
return evaluatedPermission;
226230
}

‎src/main/java/com/volcengine/ark/runtime/selfhosted/SelfHostedConstants.java‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public final class SelfHostedConstants {
1010
public static final String EVENT_TYPE_AGENT_TOOL_USE = "agent.tool_use";
1111
public static final String EVENT_TYPE_AGENT_CUSTOM_TOOL_USE = "agent.custom_tool_use";
1212
public static final String EVENT_TYPE_USER_TOOL_CONFIRMATION = "user.tool_confirmation";
13+
public static final String EVENT_TYPE_USER_INTERRUPT = "user.interrupt";
1314
public static final String EVENT_TYPE_USER_TOOL_RESULT = "user.tool_result";
1415
public static final String EVENT_TYPE_USER_CUSTOM_TOOL_RESULT = "user.custom_tool_result";
1516
public static final String EVENT_TYPE_SESSION_STATUS_IDLE = "session.status_idle";
@@ -31,6 +32,8 @@ public final class SelfHostedConstants {
3132

3233
public static final long DEFAULT_MAX_IDLE_MILLIS = 60000L;
3334
public static final long DEFAULT_TOOL_TIMEOUT_MILLIS = 120000L;
35+
public static final long DEFAULT_MAX_INPUT_FILE_BYTES = 100000L;
36+
public static final long DEFAULT_MAX_MEDIA_FILE_BYTES = 7L << 20;
3437
public static final long DEFAULT_HEARTBEAT_MILLIS = 30000L;
3538
public static final int DEFAULT_POLL_BLOCK_MILLIS = 999;
3639

0 commit comments

Comments
 (0)