agentscope-ai / agentscope-ai/agentscope-java

[Bug]: TaskRepository.cancelTask cannot stop an already-running async subagent

Ouverte
#2,790 1 commentaire 0 réactions 0 personnes assignées Voir sur GitHub
bug
Langage dominant
Java
Étoiles
5.6k
Forks
1.3k
Merge moyen
4 j 12 h
PR mergées (30 j)
77

Description

**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**

1. You code

```java
// 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());
}
}
```

2. 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.

3. 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_ completed, pushed to inbox and enqueued wakeup: session=
```

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:

```java
public void interrupt(String userId, String sessionId, Msg msg)
// -> getAgentState(userId, sessionId).interruptControl().trigger(InterruptSource.USER, msg)
```

and `ReActAgent$CallExecution` already has `private Mono 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.

Guide de contribution

Ouvrir le guide de contribution

Évaluation

Cette issue n'a pas encore été évaluée.

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.