Skip to content
Merged
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 @@ -236,7 +236,9 @@ private ToolContext toolContext(String workdir, AtomicBoolean workStop) {
copy.setEnv(new LinkedHashMap<>(context.getEnv()));
}
copy.setUnrestrictedPaths(options.unrestrictedPaths || context.isUnrestrictedPaths());
copy.setToolTimeoutMillis(context.getToolTimeoutMillis());
copy.setToolTimeoutMillis(options.toolTimeoutMillis > 0L
? options.toolTimeoutMillis
: context.getToolTimeoutMillis());
copy.setCancelled(() -> closed.get() || workStop.get());
return copy;
}
Expand Down Expand Up @@ -408,6 +410,7 @@ public static class Options {
private String workdir = ".";
private boolean unrestrictedPaths;
private ToolContext toolContext;
private long toolTimeoutMillis;
private ToolSet tools;
private long maxIdleMillis = SelfHostedConstants.DEFAULT_MAX_IDLE_MILLIS;
private Map<String, Tool> customTools = new LinkedHashMap<>();
Expand Down Expand Up @@ -438,6 +441,11 @@ public Options toolContext(ToolContext toolContext) {
return this;
}

public Options toolTimeoutMillis(long toolTimeoutMillis) {
this.toolTimeoutMillis = toolTimeoutMillis;
return this;
}

public Options tools(ToolSet tools) {
this.tools = tools;
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public class SelfHostedClient {
private static final long LIFECYCLE_TIMEOUT_SECONDS = 10L;

private final ArkApi api;
private final ArkApi streamApi;
private final ArkApi heartbeatApi;
private final ArkApi lifecycleApi;
private final OkHttpClient httpClient;
Expand All @@ -70,6 +71,12 @@ private SelfHostedClient(Builder builder) {
this.skillHubBaseUrl = trimTrailingSlash(builder.skillHubBaseUrl);
Retrofit retrofit = ArkService.defaultRetrofit(this.httpClient, this.mapper, normalizeBaseUrl(builder.baseUrl), null);
this.api = retrofit.create(ArkApi.class);
OkHttpClient streamClient = this.httpClient.newBuilder()
.callTimeout(0L, TimeUnit.MILLISECONDS)
.build();
Retrofit streamRetrofit = ArkService.defaultRetrofit(
streamClient, this.mapper, normalizeBaseUrl(builder.baseUrl), null);
this.streamApi = streamRetrofit.create(ArkApi.class);
OkHttpClient.Builder heartbeatClientBuilder = this.httpClient.newBuilder()
.retryOnConnectionFailure(false)
.connectTimeout(HEARTBEAT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
Expand Down Expand Up @@ -190,12 +197,12 @@ public SkillRef resolveSkill(SkillRef ref) {

public Call<ResponseBody> streamEvents(String sessionId) {
require(sessionId, "sessionId");
return api.streamSessionEvents(sessionId, Collections.<String, String>emptyMap());
return streamApi.streamSessionEvents(sessionId, Collections.<String, String>emptyMap());
}

public EventStream openEventStream(String sessionId) {
require(sessionId, "sessionId");
Call<ResponseBody> call = api.streamSessionEvents(sessionId, Collections.<String, String>emptyMap());
Call<ResponseBody> call = streamApi.streamSessionEvents(sessionId, Collections.<String, String>emptyMap());
try {
return new EventStream(call, call.execute());
} catch (IOException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,38 @@
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;
import java.util.logging.Level;
import java.util.logging.Logger;

public class SessionToolRunner {
private static final int STREAM_QUEUE_SIZE = 256;
private static final long TOOL_WAIT_SLICE_MILLIS = 50L;
private static final Logger LOGGER = Logger.getLogger(SessionToolRunner.class.getName());
private static final AtomicInteger TOOL_THREAD_ID = new AtomicInteger();
private final SelfHostedClient api;
private final String sessionId;
private final Options options;
private final State state = new State();
private final List<ToolCallResult> results = new ArrayList<>();
private final Random random = new Random();
private final ExecutorService toolExecutor = Executors.newCachedThreadPool(runnable -> {
Thread thread = new Thread(runnable, "ma-self-host-tool-" + TOOL_THREAD_ID.incrementAndGet());
thread.setDaemon(true);
return thread;
});
private volatile boolean closed;
private volatile EventStream activeStream;

Expand Down Expand Up @@ -198,6 +214,7 @@ private void consumeList() throws IOException {
public void close() {
closed = true;
closeStream(activeStream);
toolExecutor.shutdownNow();
}

public List<ToolCallResult> getResults() {
Expand Down Expand Up @@ -341,14 +358,75 @@ private void handleToolUse(Event event, boolean custom) throws IOException {
}

private ToolResult executeTool(Event event, boolean custom) {
long timeoutMillis = options.toolContext.getToolTimeoutMillis();
if (timeoutMillis <= 0L) {
timeoutMillis = SelfHostedConstants.DEFAULT_TOOL_TIMEOUT_MILLIS;
}
AtomicBoolean executionCancelled = new AtomicBoolean();
ToolContext context = toolContextForExecution(timeoutMillis, executionCancelled);
Future<ToolResult> future;
try {
future = toolExecutor.submit(() -> executeTool(event, custom, context));
} catch (RejectedExecutionException error) {
return ToolResult.error("tool execution canceled");
}
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
while (true) {
if (context.isCancelled()) {
executionCancelled.set(true);
future.cancel(true);
return ToolResult.error("tool execution canceled");
}
long remaining = deadline - System.nanoTime();
if (remaining <= 0L) {
executionCancelled.set(true);
future.cancel(true);
return ToolResult.error("tool execution timed out after " + timeoutMillis + "ms");
}
long waitNanos = Math.min(remaining, TimeUnit.MILLISECONDS.toNanos(TOOL_WAIT_SLICE_MILLIS));
try {
return future.get(waitNanos, TimeUnit.NANOSECONDS);
} catch (TimeoutException ignored) {
// Continue so cancellation is observed without waiting for the full tool timeout.
} catch (CancellationException error) {
return ToolResult.error("tool execution canceled");
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
executionCancelled.set(true);
future.cancel(true);
return ToolResult.error("tool execution canceled");
} catch (ExecutionException error) {
Throwable cause = error.getCause();
return ToolResult.error(cause == null ? error.toString() : errorText(cause));
}
}
}

private ToolResult executeTool(Event event, boolean custom, ToolContext context) {
if (custom) {
try {
return options.customTools.get(event.getName()).execute(event.getInput(), options.toolContext);
return options.customTools.get(event.getName()).execute(event.getInput(), context);
} catch (RuntimeException error) {
return ToolResult.error(error.getMessage());
return ToolResult.error(errorText(error));
}
}
return options.tools.execute(event.getName(), event.getInput(), options.toolContext);
return options.tools.execute(event.getName(), event.getInput(), context);
}

private ToolContext toolContextForExecution(long timeoutMillis, AtomicBoolean executionCancelled) {
ToolContext source = options.toolContext;
ToolContext context = new ToolContext(source.getWorkdir());
if (source.hasExplicitEnv()) {
context.setEnv(new LinkedHashMap<>(source.getEnv()));
}
context.setUnrestrictedPaths(source.isUnrestrictedPaths());
context.setToolTimeoutMillis(timeoutMillis);
context.setCancelled(() -> executionCancelled.get() || isClosed() || source.isCancelled());
return context;
}

private static String errorText(Throwable error) {
return error.getMessage() == null ? error.toString() : error.getMessage();
}

private void sendResult(String callId, Event source, boolean custom, String confirmation, Event out) throws IOException {
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/com/volcengine/ark/runtime/selfhosted/Tool.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@

package com.volcengine.ark.runtime.selfhosted;

/**
* Tool execution contract.
*
* <p>The runner enforces {@link ToolContext#getToolTimeoutMillis()}. Custom tools should also
* poll {@link ToolContext#isCancelled()} so timed-out work releases resources promptly.
*/
public interface Tool {
String name();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
import java.util.logging.Level;
import java.util.logging.Logger;

/**
* Serial work poller with optional ownership cleanup.
*
* <p>The default auto-stop behavior is intended for iterator-style serial processing. Callers
* dispatching work concurrently must disable it and own heartbeat and stop.
*/
public class WorkPoller implements AutoCloseable {
private static final long POLL_BACKOFF_CAP_MILLIS = 60000L;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
import com.volcengine.ark.runtime.models.environment.HeartbeatWorkResponse;
import com.volcengine.ark.runtime.models.environment.WorkState;
import java.io.IOException;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
Expand Down Expand Up @@ -123,6 +125,58 @@ public void leaseNotExtendedWithoutTtlDoesNotStopWorkOwnedByAnotherWorker() thro
assertEquals(0, client.stops.get());
}

@Test
public void workerToolTimeoutOverridesToolContext() throws Exception {
ToolContext baseContext = new ToolContext(".");
baseContext.setToolTimeoutMillis(5000L);
EnvironmentWorker worker = new EnvironmentWorker(
new SelfHostedClient("test-key"),
new EnvironmentWorker.Options()
.toolContext(baseContext)
.toolTimeoutMillis(20L));
Method method = EnvironmentWorker.class.getDeclaredMethod(
"toolContext", String.class, AtomicBoolean.class);
method.setAccessible(true);

ToolContext context = (ToolContext) method.invoke(worker, ".", new AtomicBoolean(false));

assertEquals(20L, context.getToolTimeoutMillis());
}

@Test
public void workerKeepsToolContextTimeoutWhenUnset() throws Exception {
ToolContext baseContext = new ToolContext(".");
baseContext.setToolTimeoutMillis(5000L);
EnvironmentWorker worker = new EnvironmentWorker(
new SelfHostedClient("test-key"),
new EnvironmentWorker.Options().toolContext(baseContext));
Method method = EnvironmentWorker.class.getDeclaredMethod(
"toolContext", String.class, AtomicBoolean.class);
method.setAccessible(true);

ToolContext context = (ToolContext) method.invoke(worker, ".", new AtomicBoolean(false));

assertEquals(5000L, context.getToolTimeoutMillis());
}

@Test
public void workerKeepsToolContextTimeoutWhenNonpositive() throws Exception {
ToolContext baseContext = new ToolContext(".");
baseContext.setToolTimeoutMillis(5000L);
EnvironmentWorker worker = new EnvironmentWorker(
new SelfHostedClient("test-key"),
new EnvironmentWorker.Options()
.toolContext(baseContext)
.toolTimeoutMillis(-1L));
Method method = EnvironmentWorker.class.getDeclaredMethod(
"toolContext", String.class, AtomicBoolean.class);
method.setAccessible(true);

ToolContext context = (ToolContext) method.invoke(worker, ".", new AtomicBoolean(false));

assertEquals(5000L, context.getToolTimeoutMillis());
}

private static Response response(Request request, String body) throws IOException {
return new Response.Builder()
.request(request)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,33 @@ public void heartbeatUsesOneLeaseBoundedAttempt() {
assertEquals(15L, timeoutSeconds.get());
}

@Test
public void eventStreamPreservesReadTimeoutWithoutTotalTimeout() throws Exception {
AtomicLong readTimeoutMillis = new AtomicLong(-1L);
AtomicLong callTimeoutNanos = new AtomicLong(-1L);
OkHttpClient httpClient = new OkHttpClient.Builder()
.readTimeout(25L, TimeUnit.MILLISECONDS)
.callTimeout(25L, TimeUnit.MILLISECONDS)
.addInterceptor(chain -> {
readTimeoutMillis.set(chain.readTimeoutMillis());
callTimeoutNanos.set(chain.call().timeout().timeoutNanos());
return response(chain.request(), new AtomicReference<>(), "");
})
.build();
SelfHostedClient client = new SelfHostedClient.Builder()
.apiKey("test-api-key")
.httpClient(httpClient)
.build();

try (ResponseBody ignored = client.streamEvents("session-1").execute().body()) {
assertEquals(25L, readTimeoutMillis.get());
assertEquals(0L, callTimeoutNanos.get());
}

assertEquals(25, httpClient.readTimeoutMillis());
assertEquals(25, httpClient.callTimeoutMillis());
}

@Test
public void atomicEnvironmentWorkAPIMatchesOpenAPIContract() throws Exception {
AtomicInteger calls = new AtomicInteger();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,72 @@ public void markSent(String callId) throws IOException {
assertTrue(runner.getResults().get(0).isPosted());
}

@Test
public void builtinToolTimeoutAbandonsNoncooperativeTool() throws Exception {
assertToolTimeoutAbandonsNoncooperativeTool(false);
}

@Test
public void customToolTimeoutAbandonsNoncooperativeTool() throws Exception {
assertToolTimeoutAbandonsNoncooperativeTool(true);
}

private static void assertToolTimeoutAbandonsNoncooperativeTool(boolean custom) throws Exception {
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
Tool tool = new Tool() {
@Override
public String name() {
return "blocking";
}

@Override
public ToolResult execute(Object input, ToolContext context) {
started.countDown();
while (true) {
try {
release.await();
return ToolResult.text("late");
} catch (InterruptedException ignored) {
// Deliberately ignore cancellation to verify the runner's outer deadline.
}
}
}
};
ToolContext context = new ToolContext(Files.createTempDirectory("ark-java-timeout-").toString());
context.setToolTimeoutMillis(20L);
SessionToolRunner.Options options = new SessionToolRunner.Options()
.tools(custom ? new ToolSet() : new ToolSet().add(tool))
.toolContext(context);
if (custom) {
options.customTools(Collections.singletonMap(tool.name(), tool));
}
SessionToolRunner runner = new SessionToolRunner(new SelfHostedClient("test-key"), "session-1", options);
Map<String, Object> raw = new LinkedHashMap<>();
raw.put("id", "tool-1");
raw.put("type", custom ? "agent.custom_tool_use" : "agent.tool_use");
raw.put("name", tool.name());
raw.put(custom ? "custom_tool_use_id" : "tool_use_id", "call-1");
raw.put("input", Collections.emptyMap());
Method execute = SessionToolRunner.class.getDeclaredMethod("executeTool", Event.class, boolean.class);
execute.setAccessible(true);

long startedAt = System.nanoTime();
ToolResult result;
try {
result = (ToolResult) execute.invoke(runner, Event.fromMap(raw), custom);
assertTrue(started.await(1L, TimeUnit.SECONDS));
} finally {
release.countDown();
runner.close();
}

long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
assertTrue("elapsed=" + elapsedMillis, elapsedMillis < 500L);
assertTrue(result.isError());
assertEquals("tool execution timed out after 20ms", result.getContent().get(0).getText());
}

private static SessionToolRunner idleRunner() throws IOException {
return new SessionToolRunner(
new SelfHostedClient("test-key"),
Expand Down
Loading