agentscope-ai / agentscope-ai/agentscope-java

[Bug]: Cancelling a parent run does not stop a sync-spawned HarnessAgent subagent (interruptAgent no-op + wrong ctx)

Abierto
#2,408 2 comentarios 0 reacciones 0 asignados Ver en GitHub
area/core/agent area/core/tool area/harness bug
Lenguaje dominante
Java
Estrellas
5.6k
Forks
1.3k
Merge medio
4 d 12 h
PR fusionados (30 d)
77

Descripción

**Describe the bug**

When a parent agent run is cancelled (Reactor `dispose()` / user "Stop") while a **sync-spawned** subagent is still executing, the subagent keeps running to completion. The parent run reports `ABORTED`, but the subagent continues making model + tool calls (consuming tokens) for minutes afterward.

Two independent defects in `AgentSpawnTool` / `DefaultAgentManager` (`agentscope-harness`) compound to produce this.

### Defect 1 (primary): `interruptAgent` is a no-op for `HarnessAgent` children

`AgentSpawnTool.interruptAgent` (line 809) gates on `instanceof ReActAgent`:

```java
// AgentSpawnTool.java:809
private void interruptAgent(Agent agent, RuntimeContext ctx) {
if (agent instanceof ReActAgent ra) { // ← HarnessAgent is NOT a ReActAgent
ra.interrupt(ctx);
log.warn("Sub-agent '{}' (id={}) was interrupted ...", ra.getName(), ra.getAgentId());
}
// HarnessAgent falls through -> no-op
}
```

`HarnessAgent` is declared `implements Agent, AutoCloseable` (composition over a `ReActAgent` delegate via `getDelegate()`) - it is **not** a subclass of `ReActAgent`. So for any `HarnessAgent` child, `interruptAgent` silently does nothing: the `doFinally(CANCEL)` -> `interruptAgent` path fires on parent cancel, but the interrupt never reaches the child's `InterruptControl`.

This is not an exotic type combination - it is the **standard harness usage**:

- The framework's own built-in `SubagentFactory` implementations create `HarnessAgent` children: `HarnessAgentBuilderSupport.buildGeneralPurposeFactory` (line 279, builds via `HarnessAgent.builder()` at line 320) and `buildDeclaredFactory` (line 373, builds via `HarnessAgent.builder()` at line 425). Only `RemoteSubagentStub` (remote subagents) is not a `HarnessAgent`.
- `DefaultAgentManager.invokeAgent` (line 195) **explicitly handles `HarnessAgent`** - `if (agent instanceof HarnessAgent harness) return harness.call(...)` - so the framework itself knows subagents can be `HarnessAgent`.

There is an internal inconsistency in the framework: `invokeAgent` recognizes `HarnessAgent` children and dispatches them, but `interruptAgent` does not recognize `HarnessAgent` and silently skips them. The per-session `interrupt(RuntimeContext)` overload the cancel path needs lives on the wrapped `ReActAgent` (reachable via `HarnessAgent.getDelegate()`, line 405), but `interruptAgent` never reaches it.

### Defect 2 (secondary): even for `ReActAgent` children, the wrong `RuntimeContext` is passed

`interruptAgent` is called with the **parent's** `RuntimeContext`, not the child's:

```java
// AgentSpawnTool.java:751-753 (inside execWithTimeoutPromotion's doFinally)
if (signal == SignalType.CANCEL) {
interruptAgent(agent, runtimeContext); // ← runtimeContext = parent ctx
}
```

`execWithTimeoutPromotion` (line 708) receives `runtimeContext` straight from `agentSpawn`'s `runtimeContext` parameter (line 197) - i.e. the **parent call's** `RuntimeContext`, whose `sessionId` is the parent thread id.

But the child agent actually runs under a **different** session - `DefaultAgentManager.invokeAgent` builds a child ctx with `sessionId = "sub-{hash}"`:

```java
// DefaultAgentManager.java:183-196
public Mono invokeAgent(Agent agent, String sessionId, String userId, String prompt, RuntimeContext parentRc) {
RuntimeContext ctx = parentRc != null
? RuntimeContext.builder(parentRc).sessionId(sessionId).userId(userId).build() // sessionId = "sub-{hash}"
: RuntimeContext.builder().sessionId(sessionId).userId(userId).build();
...
}
```

`ReActAgent.interrupt(RuntimeContext)` locates the target by `ctx.getUserId()` / `ctx.getSessionId()` and sets the `InterruptControl` flag on **that** session's `AgentState`:

```java
// ReActAgent.java:724-731
public void interrupt(RuntimeContext ctx, Msg msg) {
String uid = ctx != null ? ctx.getUserId() : null;
String sid = ctx != null ? ctx.getSessionId() : null;
if (sid == null || sid.isBlank()) { sid = defaultSessionId; }
getAgentState(uid, sid).interruptControl().trigger(InterruptSource.USER, msg);
}
```

So even if Defect 1 were fixed, passing the parent ctx sets the interrupt flag on the **parent session**, while the child's reasoning loop checks its own **`sub-{hash}`** session flag - which is never set. The child never observes the interrupt. (Defect 2 applies to both `ReActAgent` and `HarnessAgent` children.)

### Why the subagent isn't stopped by `dispose()` alone

`execWithTimeoutPromotion` deliberately **detaches** the child's execution from the parent subscription using a `CompletableFuture` bridge + fire-and-forget `inner.subscribe()` (line 757+), so that timeout-promotion can keep the in-flight run alive as an async task rather than losing it. Cancel propagation back to the child relies entirely on `sink.onCancel(innerSub)` (line 796) -> `doFinally(CANCEL)` (line 751) -> `interruptAgent`. With `interruptAgent` a no-op (Defect 1) / targeted at the wrong session (Defect 2), `innerSub.dispose()` alone does **not** stop a `ReActAgent`/`HarnessAgent` reasoning loop - the loop is cooperative and only halts at the next `checkInterrupted()` checkpoint, which requires the flag to be set on the **child's** session. So the child runs to natural completion.

**To Reproduce**

1. Parent agent: a `HarnessAgent` with the `agent_spawn` tool, configured with at least one local subagent. (Using the framework's built-in factory path - `buildDeclaredFactory` / `buildGeneralPurposeFactory`, both of which create `HarnessAgent` children - is sufficient to reproduce.) The subagent should do work that takes a while (multiple model + tool calls).
2. Start a parent run that triggers `agent_spawn` (sync, default `timeout_seconds`). The parent blocks in the `agent_spawn` tool call waiting on the child.
3. While the child is mid-execution, cancel the parent run (HTTP cancel / Reactor `dispose()` on the parent SSE Flux - equivalent to a user clicking "Stop").
4. See error: the parent goes `RUNNING -> ABORTED`, but the child continues running to completion.

Trace evidence (real run; timestamps relative to run start `T+0s`, identifiers redacted). Parent run ``, thread ``; child runs under `sub-`:

```
T+0s parent run started PENDING -> RUNNING
T+6s parent: reasoning -> tool_call name=agent_spawn
T+9s HarnessAgent '' built ← child is a HarnessAgent
T+9s child: PRE_CALL (starts running under sub-)

T+88s POST .../runs//cancel ← USER STOPS
T+88s [cancel handler] interrupt triggered sessionId= ← parent session, NOT sub-
T+88s parent: RUNNING -> ABORTED

T+186s child: PRE_REASONING messages=24 ← child STILL running ~1.5min after stop
T+192s child: reasoning -> tool_call name=
T+222s child: tool result state=SUCCESS
T+233s child: POST_CALL ← child finally completes ~2.5min after stop
```

The child made multiple model + tool calls between the stop (`T+88s`) and its natural completion (`T+233s`).

**Expected behavior**

The parent run aborts **and** the sync-spawned subagent stops at its next checkpoint (no further model calls / tool calls / token consumption).

**Error messages**

There is no exception - the bug is a **silent no-op**, which is why it is hard to notice:

- No `InterruptedException` is propagated (the child is not interrupted).
- No `"Sub-agent '...' was interrupted because its parent tool call subscription was cancelled."` warning is ever logged - confirming `interruptAgent`'s `instanceof ReActAgent` branch was never entered (Defect 1). The expected warning lives at `AgentSpawnTool.java:811` inside the branch that `HarnessAgent` children never reach.
- The only observable symptom is the child's continued `PRE_REASONING` / `PRE_ACTING` / `POST_CALL` trace lines after the parent's `RUNNING -> ABORTED`.

**Environment (please complete the following information):**

- AgentScope-Java Version: `2.0.0` (harness module `io.agentscope:agentscope-harness:2.0.0`)
- Java Version: 17
- OS: Windows (reproduced; should be OS-independent - pure Reactor + agent-loop logic)
- Subagent type: `HarnessAgent` (the framework's standard subagent type - both built-in `SubagentFactory` implementations, `buildGeneralPurposeFactory` and `buildDeclaredFactory`, create `HarnessAgent` children)
- Spawn mode: local sync (`execWithTimeoutPromotion`, the default path for `timeout_seconds > 0`)

**Additional context**

### Suggested fix (both in `agentscope-harness`)

**1. Handle `HarnessAgent` in `interruptAgent` (Defect 1)** - reach the delegate, mirroring how the per-session `interrupt(RuntimeContext)` lives on the wrapped `ReActAgent` (and how `DefaultAgentManager.invokeAgent` already recognizes `HarnessAgent`):

```java
// AgentSpawnTool.java:809 - proposed
private void interruptAgent(Agent agent, RuntimeContext ctx) {
ReActAgent target = null;
if (agent instanceof ReActAgent ra) {
target = ra;
} else if (agent instanceof HarnessAgent ha) {
target = ha.getDelegate(); // HarnessAgent composes a ReActAgent
}
if (target != null) {
target.interrupt(ctx);
log.warn("Sub-agent '{}' (id={}) was interrupted because its parent tool call"
+ " subscription was cancelled.", target.getName(), target.getAgentId());
}
}
```

**2. Pass the child's `RuntimeContext`, not the parent's (Defect 2)** - the child session id (`"sub-{hash}"`, ~line 330) is already available in `agentSpawn` / `execWithTimeoutPromotion`; build the child ctx once (matching `DefaultAgentManager.invokeAgent`'s construction) so the flag lands on the child's `AgentState`:

```java
// In execWithTimeoutPromotion - build the child ctx (same as DefaultAgentManager.invokeAgent does)
RuntimeContext childCtx = RuntimeContext.builder(runtimeContext)
.sessionId(sessionId) // "sub-{hash}"
.userId(userId)
.build();
// ...
if (signal == SignalType.CANCEL) {
interruptAgent(agent, childCtx); // ← child ctx, not parent runtimeContext
}
```

(Alternatively, have `DefaultAgentManager.invokeAgent` return / expose the child `RuntimeContext` it constructs, so `AgentSpawnTool` doesn't re-derive it - avoiding drift if the construction logic changes.)

### Notes for maintainers

- `sink.onCancel(innerSub)` (line 796) correctly propagates parent cancel to `innerSub.dispose()`; the gap is solely that `dispose()` does not cooperatively stop the reasoning loop without the `InterruptControl` flag on the **child's** session.
- The same two defects apply to the `stream()` path (`execLocalSync` Path 2, `invokeAgentStream`) - `interruptAgent` is shared, so the fix covers both.
- For `timeout_seconds=0` (fire-and-forget) tasks, cancellation is intentionally not propagated (the task is meant to outlive the parent); this issue is specific to **sync** spawn (`execWithTimeoutPromotion`).
- `DefaultAgentManager.invokeAgent` (line 195) already special-cases `HarnessAgent`, so the framework clearly anticipates `HarnessAgent` subagents - `interruptAgent` not doing the same is the inconsistency.
- Related upstream references already in the code: `interruptAgent`'s Javadoc cites core `SubAgentTool.interruptAgent` (commit `029cc55e`, issue #1783) and harness issue #2062 - the harness-side equivalent may not have carried over the `HarnessAgent` / child-ctx handling.

### Impact

- **Token / cost leak**: stopped runs' subagents keep calling the model and tools until natural completion (observed ~2.5 min of extra model calls after stop).
- **Incorrect UX**: the parent shows "stopped" while background work visibly continues (and may surface results / side-effects the user believed cancelled).
- **Session-state inconsistency**: the child's `AgentState` is mutated by the uninterrupted run; on the next parent call the child session may carry unexpected in-progress state.
- **Workaround at the application layer** is possible but fragile: the application must track `parentSessionId -> [child sub- sessionIds]` itself and issue `interrupt(childCtx)` for each child on cancel.

### Files / symbols (agentscope-harness 2.0.0)

| File | Line | Symbol |
|------|------|--------|
| `io/agentscope/harness/agent/tool/AgentSpawnTool.java` | 809 | `interruptAgent(Agent, RuntimeContext)` - `instanceof ReActAgent` no-op for `HarnessAgent` |
| `io/agentscope/harness/agent/tool/AgentSpawnTool.java` | 752 | `interruptAgent(agent, runtimeContext)` call - passes parent `runtimeContext` |
| `io/agentscope/harness/agent/tool/AgentSpawnTool.java` | 708 | `execWithTimeoutPromotion(...)` - `runtimeContext` param = parent ctx |
| `io/agentscope/harness/agent/tool/AgentSpawnTool.java` | 737 / 751 / 796 | `doFinally(CANCEL)` / `sink.onCancel(innerSub)` - cancel propagation (works; downstream is the gap) |
| `io/agentscope/harness/agent/subagent/DefaultAgentManager.java` | 183–196 | `invokeAgent(...)` - builds child ctx with `sessionId = "sub-{hash}"`; line 195 explicitly dispatches `HarnessAgent` via `harness.call(...)` |
| `io/agentscope/harness/agent/HarnessAgentBuilderSupport.java` | 279 / 320, 373 / 425 | `buildGeneralPurposeFactory` / `buildDeclaredFactory` - framework built-in factories, both create `HarnessAgent` children |
| `io/agentscope/harness/agent/HarnessAgent.java` | 152 / 156 / 405 | `implements Agent` (not `extends ReActAgent`); `getDelegate()` returns the `ReActAgent` |
| `io/agentscope/core/ReActAgent.java` | 724–731 | `interrupt(RuntimeContext, Msg)` - targets `getAgentState(uid, sid)` by ctx's userId/sessionId |

Guía de contribución

Abrir la guía de contribución

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.