agentscope-ai / agentscope-ai/agentscope-java
[Bug]: Cancelling a parent run does not stop a sync-spawned HarnessAgent subagent (interruptAgent no-op + wrong ctx)
- Vorherrschende Sprache
- Java
- Sterne
- 5.6k
- Forks
- 1.3k
- Ø Merge
- 4 T. 12 Std.
- Gemergte PRs (30 T.)
- 77
Beschreibung
**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 |
Beitragsleitfaden
Bewertung
Dieses Issue wurde noch nicht bewertet.