This is an automated email from the ASF dual-hosted git repository.
wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git
The following commit(s) were added to refs/heads/main by this push:
new 5ba3f0b9 [runtime] Do not retry or persist interrupted tool calls on
cancellation (#1111)
5ba3f0b9 is described below
commit 5ba3f0b92584c969be6be173eb3c0a0427700beb
Author: Ashfaq <[email protected]>
AuthorDate: Wed Sep 16 12:49:09 2026 +0530
[runtime] Do not retry or persist interrupted tool calls on cancellation
(#1111)
Generated-by: Claude Code 2.1.272 (Claude Sonnet 5)
---
.../flink/agents/plan/actions/ToolCallAction.java | 25 ++++++-
.../agents/plan/actions/ToolCallActionTest.java | 77 +++++++++++++++++++-
.../runtime/context/JavaRunnerContextImpl.java | 24 ++++++-
.../agents/runtime/context/RunnerContextImpl.java | 6 ++
...vaRunnerContextImplDurableExecuteAsyncTest.java | 81 ++++++++++++++++++++++
.../RunnerContextImplDurableExecuteTest.java | 40 +++++++++++
6 files changed, 247 insertions(+), 6 deletions(-)
diff --git
a/plan/src/main/java/org/apache/flink/agents/plan/actions/ToolCallAction.java
b/plan/src/main/java/org/apache/flink/agents/plan/actions/ToolCallAction.java
index a67571a8..e9ea9573 100644
---
a/plan/src/main/java/org/apache/flink/agents/plan/actions/ToolCallAction.java
+++
b/plan/src/main/java/org/apache/flink/agents/plan/actions/ToolCallAction.java
@@ -64,7 +64,8 @@ public class ToolCallAction {
List.of(ToolRequestEvent.EVENT_TYPE));
}
- public static void processToolRequest(Event event, RunnerContext ctx) {
+ public static void processToolRequest(Event event, RunnerContext ctx)
+ throws InterruptedException {
ToolRequestEvent toolRequest = ToolRequestEvent.fromEvent(event);
boolean toolCallAsync =
ctx.getConfig().get(AgentExecutionOptions.TOOL_CALL_ASYNC);
int toolCallParallelism =
ctx.getConfig().get(AgentExecutionOptions.TOOL_CALL_PARALLELISM);
@@ -76,6 +77,10 @@ public class ToolCallAction {
List<ToolCallExecution> executions =
buildToolCallExecutions(toolRequest, ctx, externalIds,
success, error, responses);
+ // executeParallel/executeSequentially let InterruptedException
propagate rather than
+ // recording it as a tool error, so a cancellation here skips
sendEvent below entirely:
+ // no ToolResponseEvent goes out, no further chat call gets driven off
a cancelled tool
+ // call, and the action is never persisted as completed on the back of
it.
if (toolCallAsync && toolCallParallelism > 1 && executions.size() > 1)
{
executeParallel(executions, ctx, success, error, responses);
} else {
@@ -196,7 +201,8 @@ public class ToolCallAction {
RunnerContext ctx,
Map<String, Boolean> success,
Map<String, String> error,
- Map<String, ToolResponse> responses) {
+ Map<String, ToolResponse> responses)
+ throws InterruptedException {
List<DurableCallable<ToolResponse>> callables = new
ArrayList<>(executions.size());
for (ToolCallExecution execution : executions) {
callables.add(execution.callable);
@@ -206,6 +212,12 @@ public class ToolCallAction {
for (int i = 0; i < outcomes.size(); i++) {
recordOutcome(executions.get(i), outcomes.get(i), ctx,
success, error, responses);
}
+ } catch (InterruptedException e) {
+ // A cancellation signal, not a batch failure: propagate
immediately instead of
+ // recording every execution as a tool error and letting the
caller send a
+ // ToolResponseEvent that drives the action loop onward.
+ Thread.currentThread().interrupt();
+ throw e;
} catch (Exception e) {
for (ToolCallExecution execution : executions) {
recordExecutionException(execution, e, success, error,
responses);
@@ -230,7 +242,8 @@ public class ToolCallAction {
RunnerContext ctx,
Map<String, Boolean> success,
Map<String, String> error,
- Map<String, ToolResponse> responses) {
+ Map<String, ToolResponse> responses)
+ throws InterruptedException {
for (ToolCallExecution execution : executions) {
try {
ToolResponse response =
@@ -253,6 +266,12 @@ public class ToolCallAction {
execution.name,
execution.entityMetadata);
}
+ } catch (InterruptedException e) {
+ // A cancellation signal, not a tool failure: propagate
immediately instead of
+ // recording it as a tool error and letting the loop move on
to (or past) the
+ // remaining executions and the caller send a
ToolResponseEvent for it.
+ Thread.currentThread().interrupt();
+ throw e;
} catch (Exception e) {
recordExecutionException(execution, e, success, error,
responses);
ExecutionReporters.failed(
diff --git
a/plan/src/test/java/org/apache/flink/agents/plan/actions/ToolCallActionTest.java
b/plan/src/test/java/org/apache/flink/agents/plan/actions/ToolCallActionTest.java
index 57e152a9..8be03fdc 100644
---
a/plan/src/test/java/org/apache/flink/agents/plan/actions/ToolCallActionTest.java
+++
b/plan/src/test/java/org/apache/flink/agents/plan/actions/ToolCallActionTest.java
@@ -51,6 +51,7 @@ import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
public class ToolCallActionTest {
@@ -428,7 +429,7 @@ public class ToolCallActionTest {
}
@Test
- void processToolRequestUsesFallbackWhenMissingToolErrorHasNoMessage() {
+ void processToolRequestUsesFallbackWhenMissingToolErrorHasNoMessage()
throws Exception {
FakeRunnerContext ctx =
new FakeRunnerContext() {
@Override
@@ -560,6 +561,80 @@ public class ToolCallActionTest {
assertThat(response.getError()).containsEntry("call-2", "persist
failed");
}
+ @Test
+ void processToolRequestPropagatesInterruptionInsteadOfRecordingToolError()
{
+ FakeRunnerContext ctx =
+ new FakeRunnerContext() {
+ @Override
+ public <T> T durableExecute(DurableCallable<T> callable)
throws Exception {
+ throw new InterruptedException("cancelled");
+ }
+ }.withToolCallAsync(false);
+
+ Thread.interrupted();
+
+ assertThatExceptionOfType(InterruptedException.class)
+ .isThrownBy(
+ () ->
ToolCallAction.processToolRequest(toolRequest("queryOrder"), ctx));
+
+ assertThat(Thread.interrupted()).as("interrupt status should be
restored").isTrue();
+ // No ToolResponseEvent should go out for a cancelled call: the loop
must not fold the
+ // interruption into a tool-error response and drive a further chat
call off it.
+ assertThat(ctx.sentEvents).isEmpty();
+ }
+
+ @Test
+ void
processToolRequestStopsSequentialLoopOnFirstInterruptionWithoutRunningLaterTools()
{
+ AtomicInteger calls = new AtomicInteger();
+ FakeRunnerContext ctx =
+ new FakeRunnerContext() {
+ @Override
+ public <T> T durableExecute(DurableCallable<T> callable)
throws Exception {
+ if (calls.incrementAndGet() == 1) {
+ throw new InterruptedException("cancelled");
+ }
+ return callable.call();
+ }
+ }.withToolCallAsync(false);
+
+ Thread.interrupted();
+
+ assertThatExceptionOfType(InterruptedException.class)
+ .isThrownBy(
+ () ->
+ ToolCallAction.processToolRequest(
+ toolRequest("queryOrder", "call-1",
"call-2"), ctx));
+
+ Thread.interrupted();
+ assertThat(calls.get())
+ .as("the second tool call must not run once the first is
interrupted")
+ .isEqualTo(1);
+ assertThat(ctx.sentEvents).isEmpty();
+ }
+
+ @Test
+ void
processToolRequestPropagatesInterruptionFromParallelBatchWithoutRecordingToolErrors()
{
+ FakeRunnerContext ctx =
+ new FakeRunnerContext() {
+ @Override
+ public <T> List<Outcome<T>> durableExecuteAllAsync(
+ List<DurableCallable<T>> callables) throws
Exception {
+ throw new InterruptedException("cancelled");
+ }
+ };
+
+ Thread.interrupted();
+
+ assertThatExceptionOfType(InterruptedException.class)
+ .isThrownBy(
+ () ->
+ ToolCallAction.processToolRequest(
+ toolRequest("queryOrder", "call-1",
"call-2"), ctx));
+
+ assertThat(Thread.interrupted()).as("interrupt status should be
restored").isTrue();
+ assertThat(ctx.sentEvents).isEmpty();
+ }
+
private static ToolRequestEvent toolRequest(String toolName) {
return new ToolRequestEvent("model", List.of(toolCall(toolName,
"call-1", "order-1")));
}
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImpl.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImpl.java
index 5b028012..7e241596 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImpl.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImpl.java
@@ -192,6 +192,15 @@ public class JavaRunnerContextImpl extends
RunnerContextImpl {
plan.outcomes.set(callIndex, outcome);
continue;
}
+ if (outcome.isFailure() && outcome.getError() instanceof
InterruptedException) {
+ // A cancellation signal a batch executor caught and folded
into a failed
+ // Outcome, not a genuine tool failure: leave this slot and
every slot after it
+ // in the batch unfinalized (pending), so recovery re-executes
them, and
+ // propagate immediately instead of persisting it as a durable
failure. Slots
+ // finalized earlier in this loop keep their persisted outcome.
+ Thread.currentThread().interrupt();
+ throw (InterruptedException) outcome.getError();
+ }
try {
finalizeCallAt(
base + callIndex,
@@ -218,12 +227,23 @@ public class JavaRunnerContextImpl extends
RunnerContextImpl {
}
}
- private <T> List<Outcome<T>>
executeAllWithoutDurableState(List<DurableCallable<T>> callables) {
+ private <T> List<Outcome<T>>
executeAllWithoutDurableState(List<DurableCallable<T>> callables)
+ throws InterruptedException {
List<Callable<T>> suppliers = new ArrayList<>();
for (DurableCallable<T> callable : callables) {
suppliers.add(callable::call);
}
- return executeOutcomeSuppliers(suppliers).getOutcomes();
+ List<Outcome<T>> outcomes =
executeOutcomeSuppliers(suppliers).getOutcomes();
+ for (Outcome<T> outcome : outcomes) {
+ if (outcome.isFailure() && outcome.getError() instanceof
InterruptedException) {
+ // Without a durable store there is nothing to leave pending,
but a cancellation
+ // signal folded into a failed Outcome must still propagate as
an interruption
+ // instead of being handed to the caller as an ordinary tool
failure.
+ Thread.currentThread().interrupt();
+ throw (InterruptedException) outcome.getError();
+ }
+ }
+ return outcomes;
}
private <T> BatchExecutionResult<T>
executeOutcomeSuppliers(List<Callable<T>> suppliers) {
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/context/RunnerContextImpl.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/context/RunnerContextImpl.java
index fe1c4ccf..e626afaa 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/context/RunnerContextImpl.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/context/RunnerContextImpl.java
@@ -502,6 +502,12 @@ public class RunnerContextImpl implements RunnerContext,
ExecutionReporter {
for (DurableCallable<T> callable : callables) {
try {
outcomes.add(Outcome.success(durableExecute(callable)));
+ } catch (InterruptedException e) {
+ // A cancellation signal, not a genuine call failure: stop
scheduling the
+ // remaining callables and propagate immediately, instead of
recording it as a
+ // failed outcome and continuing on to the rest of the batch.
+ Thread.currentThread().interrupt();
+ throw e;
} catch (Exception e) {
outcomes.add(Outcome.failure(e));
}
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImplDurableExecuteAsyncTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImplDurableExecuteAsyncTest.java
index adc75fa9..2aa26a73 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImplDurableExecuteAsyncTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImplDurableExecuteAsyncTest.java
@@ -527,6 +527,87 @@ class JavaRunnerContextImplDurableExecuteAsyncTest {
executor.close();
}
+ @Test
+ void
testDurableExecuteAllAsyncInterruptionLeavesRemainingSlotsPendingAndPropagates()
+ throws Exception {
+ InspectingContinuationActionExecutor executor = new
InspectingContinuationActionExecutor();
+ JavaRunnerContextImpl context = createContext(new ActionState(null),
executor);
+ TestDurableCallable<String> first =
+ new TestDurableCallable<>("batch-1", String.class, () ->
"one");
+ TestDurableCallable<String> second =
+ new TestDurableCallable<>(
+ "batch-2",
+ String.class,
+ () -> {
+ throw new InterruptedException("cancelled");
+ });
+ TestDurableCallable<String> third =
+ new TestDurableCallable<>("batch-3", String.class, () ->
"three");
+ Thread.interrupted(); // clear any stray interrupt flag left over from
another test
+
+ InterruptedException thrown =
+ assertThrows(
+ InterruptedException.class,
+ () -> context.durableExecuteAllAsync(List.of(first,
second, third)));
+
+ assertEquals("cancelled", thrown.getMessage());
+ assertTrue(
+ Thread.interrupted(),
+ "the calling thread's interrupt flag must be restored, not
swallowed");
+ List<CallResult> persisted =
+
context.getDurableExecutionContext().getActionState().getCallResults();
+ assertEquals("batch-1", persisted.get(0).getFunctionId());
+ assertTrue(
+ persisted.get(0).isSuccess(),
+ "the slot finalized before the interruption was observed must
keep its outcome");
+ assertTrue(
+ persisted.get(1).isPending(),
+ "the interrupted slot must not be persisted as a durable tool
failure");
+ assertTrue(
+ persisted.get(2).isPending(),
+ "slots after the interrupted one must stay pending, not be
finalized");
+ assertEquals(
+ 0,
+ context.getDurableExecutionContext().getCurrentCallIndex(),
+ "the call index must not advance past an interrupted batch, so
recovery re-reads"
+ + " the pending slots instead of skipping them");
+ }
+
+ @Test
+ void testDurableExecuteAllAsyncWithoutDurableStateInterruptionPropagates()
throws Exception {
+ InspectingContinuationActionExecutor executor = new
InspectingContinuationActionExecutor();
+ JavaRunnerContextImpl context =
+ new JavaRunnerContextImpl(
+ metricGroup,
+ () -> {},
+ new AgentPlan(new HashMap<>(), new HashMap<>()),
+ null,
+ "test-job",
+ executor);
+ context.setContinuationContext(new ContinuationContext());
+ TestDurableCallable<String> first =
+ new TestDurableCallable<>("batch-1", String.class, () ->
"one");
+ TestDurableCallable<String> second =
+ new TestDurableCallable<>(
+ "batch-2",
+ String.class,
+ () -> {
+ throw new InterruptedException("cancelled");
+ });
+ Thread.interrupted(); // clear any stray interrupt flag left over from
another test
+
+ InterruptedException thrown =
+ assertThrows(
+ InterruptedException.class,
+ () -> context.durableExecuteAllAsync(List.of(first,
second)));
+
+ assertEquals("cancelled", thrown.getMessage());
+ assertTrue(
+ Thread.interrupted(),
+ "the calling thread's interrupt flag must be restored, not
swallowed, even"
+ + " without a durable store to persist pending slots
into");
+ }
+
@Test
void
testDurableExecuteAllAsyncFinalizeFailureReturnsOutcomeAndKeepsSlotPending()
throws Exception {
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/context/RunnerContextImplDurableExecuteTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/context/RunnerContextImplDurableExecuteTest.java
index 816b874d..228eee78 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/context/RunnerContextImplDurableExecuteTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/context/RunnerContextImplDurableExecuteTest.java
@@ -31,6 +31,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
+import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
@@ -127,6 +128,45 @@ class RunnerContextImplDurableExecuteTest {
assertTrue(pending.isPending(), "interrupted pending slot should
remain unfinalized");
}
+ @Test
+ void
testDurableExecuteAllAsyncStopsBatchOnInterruptionInsteadOfRecordingFailure() {
+ RunnerContextImpl context = createContext(new ActionState(null));
+ TestDurableCallable<String> first =
+ new TestDurableCallable<>("batch-call-1", String.class, () ->
"ok");
+ TestDurableCallable<String> second =
+ new TestDurableCallable<>(
+ "batch-call-2",
+ String.class,
+ () -> {
+ throw new InterruptedException("cancelled");
+ });
+ TestDurableCallable<String> third =
+ new TestDurableCallable<>(
+ "batch-call-3",
+ String.class,
+ () -> fail("later callables must not run once the
batch is interrupted"));
+
+ Thread.interrupted();
+
+ assertThrows(
+ InterruptedException.class,
+ () -> context.durableExecuteAllAsync(List.of(first, second,
third)));
+
+ assertTrue(Thread.interrupted(), "interrupt status should be restored
on the thread");
+ assertEquals(1, first.getCallCount());
+ assertEquals(1, second.getCallCount());
+ assertEquals(0, third.getCallCount(), "callables after the interrupted
one must not run");
+ // batch-call-1 genuinely completed before the interruption, so it's
correctly persisted;
+ // batch-call-2's interruption must not be recorded as a failed
Outcome, though — it
+ // should propagate out of durableExecuteAllAsync instead, same as any
other
+ // durableExecute call, leaving nothing persisted for it.
+ assertEquals(1,
context.getDurableExecutionContext().getActionState().getCallResultCount());
+ CallResult persisted =
+
context.getDurableExecutionContext().getActionState().getCallResults().get(0);
+ assertEquals("batch-call-1", persisted.getFunctionId());
+ assertTrue(persisted.isSuccess());
+ }
+
@Test
void testDurableExecuteReconcilableSuccessCall() throws Exception {
RunnerContextImpl context = createContext(new ActionState(null));