Describe the bug
When a parent agent is cancelled, async subagents spawned through SubagentsMiddleware
(background task mode) keep running: they continue issuing LLM requests and tool calls until
they finish naturally, then push their results to the inbox and wake the parent up.
TaskRepository.cancelTask(ctx, sessionId, taskId) looks like the API intended for this, but it
has no effect on a local task that has already started executing. It can only mark the task
as cancelled at task boundaries; it cannot interrupt the agent loop that is currently running.
Two independent causes, both verifiable from the bytecode of agentscope-harness-2.0.0.jar.
Cause 1 — BackgroundTask.cancel(boolean) does not interrupt the worker thread.
public boolean cancel(boolean);
0: aload_0
1: iconst_1
2: putfield cancelled:Z
5: aload_0
6: getfield future:Ljava/util/concurrent/CompletableFuture;
9: iload_1
10: invokevirtual java/util/concurrent/CompletableFuture.cancel:(Z)Z
13: ireturn
The whole method body is "set the cancelled flag" plus CompletableFuture.cancel. Per the JDK
contract, CompletableFuture.cancel(mayInterruptIfRunning) ignores mayInterruptIfRunning
(that implementation does not use interrupts to control processing). A supplier already running
on the ws-task-* executor is therefore never interrupted.
Cause 2 — TaskRecord.cancelRequested has no checkpoint inside the agent loop.
cancelTask does call setCancelRequested(true) and persists the record, and that part works.
But scanning every class in the jar (running javap -c on each and searching for
isCancelRequested call sites), only two classes ever read the flag:
io/agentscope/harness/agent/subagent/task/WorkspaceTaskRepository.class
io/agentscope/harness/agent/subagent/task/TaskRecord.class
The checkpoints inside WorkspaceTaskRepository are:
| Location |
When |
runLocalSupplier entry |
before the supplier starts |
runLocalSupplier after supplier returns |
after the supplier ends |
runRemoteTask entry |
before the remote call |
pollRemoteUntilDone loop |
remote tasks, every poll |
updateStatus |
prevents overwriting CANCELLED |
For local tasks (LocalTaskRunSpec), every checkpoint sits outside the supplier. The supplier
body is the entire agent loop (many rounds of LLM calls and tool calls), so once execution enters
it, the flag is never consulted again.
Net effect:
- queued, not-yet-started tasks — entry checkpoint works, genuinely skipped
- currently executing tasks — cannot be interrupted at all, run to completion
- remote tasks (
RemoteTaskRunSpec) — polling checkpoint exists, better than local
To Reproduce
- You code
// 1. A supervisor agent that can spawn subagents
HarnessAgent supervisor = HarnessAgent.builder()
.model(model)
.messageBus(messageBus)
.subagentFactory("worker", name -> workerAgent)
.build();
// 2. Obtaining the TaskRepository currently requires reflection:
// HarnessAgent.subagentMiddleware is private and has no public getter.
Field mwField = HarnessAgent.class.getDeclaredField("subagentMiddleware");
mwField.setAccessible(true);
DynamicSubagentsMiddleware dsm = (DynamicSubagentsMiddleware) mwField.get(supervisor);
TaskRepository repo = dsm.getTaskRepository();
// 3. Ask the supervisor to spawn an async subagent whose work spans several tool calls,
// e.g. a prompt that makes it call a tool repeatedly in background mode.
supervisor.streamEvents(new UserMessage(promptThatSpawnsBackgroundTask), ctx).subscribe();
// 4. Once the subagent is genuinely executing (tool calls visible on a ws-task-* thread),
// cancel every non-terminal task of that session.
RuntimeContext rc = RuntimeContext.builder().sessionId(sessionId).build();
for (BackgroundTask t : repo.listTasks(rc, sessionId, null)) {
TaskStatus st = t.getTaskStatus();
if (st == null || !st.isTerminal()) {
boolean requested = repo.cancelTask(rc, sessionId, t.getTaskId());
System.out.println("cancelTask returned " + requested + " for " + t.getTaskId());
}
}
- How to execute
Run the flow, wait until the subagent has started calling tools, then trigger the
cancelTask loop above. Keep watching the logs after the call returns.
- See error
cancelTask returns true, but the subagent is unaffected and keeps executing.
Expected behavior
After cancelTask returns, the subagent should stop at the next interruptible point
(for example before the next reasoning round or the next tool call) and issue no further
LLM requests or tool calls.
Error messages
There is no exception — that is the problem. cancelTask reports success and execution
continues silently. The only visible evidence is that the task later completes normally:
[ws-task-307] INFO SubagentsMiddleware - Subagent task task_<id> completed, pushed to inbox and enqueued wakeup: session=<sessionId>
Observed in production: the parent run was cancelled at 11:24:08; four subagents kept logging
POST_ACTING ... state=SUCCESS at 11:24:09 and 11:24:10, and only reached completed at
11:24:40 — 32 seconds of LLM and tool work after cancellation.
Environment (please complete the following information):
- AgentScope-Java Version: 2.0.0 (agentscope-harness 2.0.0)
- Java Version: 17
- OS: macos
Additional context
Relation to #1577: that issue covers cancel-propagation breakage for synchronous subagents
(stream() mode), and its impact table explicitly marks "async subagent (timeout=0)" as
"not on the chain, unaffected (needs task_cancel)". This issue is exactly that gap: the
task_cancel capability exists as an API, but does not achieve stop-execution semantics for
already-running local tasks.
Suggested fix. Everything needed already exists inside the SDK; only the wiring is missing.
ReActAgent already has per-session interruption:
public void interrupt(String userId, String sessionId, Msg msg)
// -> getAgentState(userId, sessionId).interruptControl().trigger(InterruptSource.USER, msg)
and ReActAgent$CallExecution already has private Mono<Void> checkInterrupted() with 4 call
sites in the reasoning/acting loop — precisely the interruptible points needed.
Proposal: make cancelTask reach the InterruptControl of the executing subagent. For example,
have WorkspaceTaskRepository.putTask register the task's agent instance (or its AgentState /
InterruptControl) alongside the taskId, and have cancelTask additionally call
interruptControl().trigger(...). Cancellation granularity then matches the existing
checkInterrupted density, with no new checking logic required.
Alternative: inside runLocalSupplier, compose the supplier's reactive chain with
cancelRequested (e.g. takeUntilOther, or periodic checks followed by Mono.error) so the flag
also takes effect while the supplier is running.
Why this cannot be worked around from application code. Subagent instances are created fresh on
every spawn by the subagentFactory and are not cached, so callers cannot obtain a reference to
the running instance and cannot call interrupt themselves. GracefulShutdownManager is a global
singleton with no session dimension, so using it would affect every session in the process. The
only remaining option is for each integrator to add an onActing middleware to the subagent's own
chain that checks an external cancellation flag and throws — duplicated work for everyone, with
granularity limited to tool-call boundaries.
Describe the bug
When a parent agent is cancelled, async subagents spawned through
SubagentsMiddleware(background task mode) keep running: they continue issuing LLM requests and tool calls until
they finish naturally, then push their results to the inbox and wake the parent up.
TaskRepository.cancelTask(ctx, sessionId, taskId)looks like the API intended for this, but ithas no effect on a local task that has already started executing. It can only mark the task
as cancelled at task boundaries; it cannot interrupt the agent loop that is currently running.
Two independent causes, both verifiable from the bytecode of
agentscope-harness-2.0.0.jar.Cause 1 —
BackgroundTask.cancel(boolean)does not interrupt the worker thread.The whole method body is "set the
cancelledflag" plusCompletableFuture.cancel. Per the JDKcontract,
CompletableFuture.cancel(mayInterruptIfRunning)ignoresmayInterruptIfRunning(that implementation does not use interrupts to control processing). A supplier already running
on the
ws-task-*executor is therefore never interrupted.Cause 2 —
TaskRecord.cancelRequestedhas no checkpoint inside the agent loop.cancelTaskdoes callsetCancelRequested(true)and persists the record, and that part works.But scanning every class in the jar (running
javap -con each and searching forisCancelRequestedcall sites), only two classes ever read the flag:io/agentscope/harness/agent/subagent/task/WorkspaceTaskRepository.classio/agentscope/harness/agent/subagent/task/TaskRecord.classThe checkpoints inside
WorkspaceTaskRepositoryare:runLocalSupplierentryrunLocalSupplierafter supplier returnsrunRemoteTaskentrypollRemoteUntilDoneloopupdateStatusFor local tasks (
LocalTaskRunSpec), every checkpoint sits outside the supplier. The supplierbody is the entire agent loop (many rounds of LLM calls and tool calls), so once execution enters
it, the flag is never consulted again.
Net effect:
RemoteTaskRunSpec) — polling checkpoint exists, better than localTo Reproduce
Run the flow, wait until the subagent has started calling tools, then trigger the
cancelTaskloop above. Keep watching the logs after the call returns.cancelTaskreturnstrue, but the subagent is unaffected and keeps executing.Expected behavior
After
cancelTaskreturns, the subagent should stop at the next interruptible point(for example before the next reasoning round or the next tool call) and issue no further
LLM requests or tool calls.
Error messages
There is no exception — that is the problem.
cancelTaskreports success and executioncontinues silently. The only visible evidence is that the task later completes normally:
Observed in production: the parent run was cancelled at
11:24:08; four subagents kept loggingPOST_ACTING ... state=SUCCESSat11:24:09and11:24:10, and only reachedcompletedat11:24:40— 32 seconds of LLM and tool work after cancellation.Environment (please complete the following information):
Additional context
Relation to #1577: that issue covers cancel-propagation breakage for synchronous subagents
(
stream()mode), and its impact table explicitly marks "async subagent (timeout=0)" as"not on the chain, unaffected (needs
task_cancel)". This issue is exactly that gap: thetask_cancelcapability exists as an API, but does not achieve stop-execution semantics foralready-running local tasks.
Suggested fix. Everything needed already exists inside the SDK; only the wiring is missing.
ReActAgentalready has per-session interruption:and
ReActAgent$CallExecutionalready hasprivate Mono<Void> checkInterrupted()with 4 callsites in the reasoning/acting loop — precisely the interruptible points needed.
Proposal: make
cancelTaskreach theInterruptControlof the executing subagent. For example,have
WorkspaceTaskRepository.putTaskregister the task's agent instance (or itsAgentState/InterruptControl) alongside thetaskId, and havecancelTaskadditionally callinterruptControl().trigger(...). Cancellation granularity then matches the existingcheckInterrupteddensity, with no new checking logic required.Alternative: inside
runLocalSupplier, compose the supplier's reactive chain withcancelRequested(e.g.takeUntilOther, or periodic checks followed byMono.error) so the flagalso takes effect while the supplier is running.
Why this cannot be worked around from application code. Subagent instances are created fresh on
every spawn by the
subagentFactoryand are not cached, so callers cannot obtain a reference tothe running instance and cannot call
interruptthemselves.GracefulShutdownManageris a globalsingleton with no session dimension, so using it would affect every session in the process. The
only remaining option is for each integrator to add an
onActingmiddleware to the subagent's ownchain that checks an external cancellation flag and throws — duplicated work for everyone, with
granularity limited to tool-call boundaries.