agentscope-ai / agentscope-ai/agentscope-java

[Bug] enablePendingToolRecovery mutates the shared ReActAgent.Builder.hooks on every build(), racing under concurrent A2A requests (AIOOBE / NPE during ReActAgent construction)

Aberta
#2,539 4 comentários 0 reações 0 responsáveis Ver no GitHub
area/build area/core/agent area/ext/integration area/ext/spring-boot bug
Linguagem predominante
Java
Estrelas
5.6k
Forks
1.3k
Merge médio
4d 12h
PRs com merge (30d)
77

Descrição

### Environment
- agentscope: `1.0.12`(另反编译确认 `1.1.0-RC2` 同样存在,见文末)
- Spring Boot 3 · `agentscope-extensions-a2a-server` · `a2a-java-sdk-server-common 0.3.3.Final`
- Topology: the `ReActAgent.Builder` is exposed as a shared Spring **singleton** bean to the A2A server; the client sends **concurrent** A2A streaming requests (≥2) to the same server.

### Problem

When `ReActAgent.Builder.enablePendingToolRecovery(true)` is set, `Builder.build()` executes:

```java
if (enablePendingToolRecovery) {
this.hooks.add(new PendingToolRecoveryHook()); // mutates the SHARED builder's hooks Set
}
```

In the A2A server, `ReActAgentWithBuilderRunner.buildReActAgent()` calls `build()` on the **same shared builder bean for every incoming request**. When requests arrive concurrently, multiple threads mutate `builder.hooks` (a plain, non-thread-safe `HashSet`) at the same time as `ReActAgent.` iterates it via `new ArrayList<>(builder.hooks)`. This produces two failure modes, both aborting agent construction (`AgentScopeAgentExecutor` reports "Agent execution failed"):

1. `java.util.LinkedHashMap.keysToArray` → `ArrayIndexOutOfBoundsException: Index 23 out of bounds for length 23` — concurrent modification while converting the backing map to an array.
2. `NullPointerException` in `AgentBase.sortHooks` (`Comparator.comparingInt` on a partially-initialized element copied from the racing set).

### Root cause (bytecode)

`Builder.build()` in 1.0.12 (and 1.1.0-RC2) contains:

```
if (enablePendingToolRecovery) { this.hooks.add(new PendingToolRecoveryHook()); }
```

i.e. `build()` **mutates shared builder state**, which is unsafe when the builder is reused across concurrent builds (the A2A server's design).

### Impact
- Any concurrent A2A request can fail to construct its agent, failing the whole call.
- Secondary: `PendingToolRecoveryHook` has no `equals()`/`hashCode()`, so the shared `hooks` set **grows by one instance per build** (unbounded leak + increasing per-build copy cost).

### Logs

Two servers, same millisecond, 3 concurrent `Handling streaming request` on different threads (`ctor-http-nio-8/9/10`):

```text
2026-08-03T18:03:56.603+08:00 INFO 35244 --- [reader-server] [tor-http-nio-10] i.a.c.a.s.t.j.JsonRpcTransportWrapper : Handling streaming request, returning SSE Flux
2026-08-03T18:03:56.603+08:00 INFO 35244 --- [reader-server] [ctor-http-nio-9] i.a.c.a.s.t.j.JsonRpcTransportWrapper : Handling streaming request, returning SSE Flux
2026-08-03T18:03:56.603+08:00 INFO 35244 --- [reader-server] [ctor-http-nio-8] i.a.c.a.s.t.j.JsonRpcTransportWrapper : Handling streaming request, returning SSE Flux
2026-08-03T18:03:56.604+08:00 ERROR 35244 --- [reader-server] [ool-10-thread-1] i.a.c.a.s.e.AgentScopeAgentExecutor : [c0e2c3ba-a990-4055-8a9f-d30f6cb65314] Agent execution failed

java.lang.NullPointerException: null
at java.base/java.util.Comparator.lambda$comparingInt$7b0bb60$1(Comparator.java:494) ~[na:na]
at java.base/java.util.TimSort.countRunAndMakeAscending(TimSort.java:360) ~[na:na]
at java.base/java.util.TimSort.sort(TimSort.java:220) ~[na:na]
at java.base/java.util.Arrays.sort(Arrays.java:1308) ~[na:na]
at java.base/java.util.concurrent.CopyOnWriteArrayList.sortRange(CopyOnWriteArrayList.java:980) ~[na:na]
at java.base/java.util.concurrent.CopyOnWriteArrayList.sort(CopyOnWriteArrayList.java:972) ~[na:na]
at io.agentscope.core.agent.AgentBase.sortHooks(AgentBase.java:529) ~[agentscope-1.0.12.jar:na]
at io.agentscope.core.agent.AgentBase.(AgentBase.java:145) ~[agentscope-1.0.12.jar:na]
at io.agentscope.core.agent.StructuredOutputCapableAgent.(StructuredOutputCapableAgent.java:97) ~[agentscope-1.0.12.jar:na]
at io.agentscope.core.ReActAgent.(ReActAgent.java:158) ~[agentscope-1.0.12.jar:na]
at io.agentscope.core.ReActAgent$Builder.build(ReActAgent.java:1594) ~[agentscope-1.0.12.jar:na]
at io.agentscope.core.a2a.server.executor.runner.ReActAgentWithBuilderRunner.buildReActAgent(ReActAgentWithBuilderRunner.java:40) ~[agentscope-1.0.12.jar:na]
at io.agentscope.core.a2a.server.executor.runner.BaseReActAgentRunner.stream(BaseReActAgentRunner.java:57) ~[agentscope-1.0.12.jar:na]
at io.agentscope.core.a2a.server.executor.AgentScopeAgentExecutor.execute(AgentScopeAgentExecutor.java:101) ~[agentscope-1.0.12.jar:na]
at io.a2a.server.requesthandlers.DefaultRequestHandler$2.run(DefaultRequestHandler.java:640) ~[a2a-java-sdk-server-common-0.3.3.Final.jar:na]
at java.base/java.util.concurrent.CompletableFuture$AsyncRun.run(CompletableFuture.java:1804) ~[na:na]
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144) ~[na:na]
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642) ~[na:na]
at java.base/java.lang.Thread.run(Thread.java:1583) ~[na:na]

2026-08-03T18:03:56.579+08:00 INFO 42664 --- [researcher-server] [ctor-http-nio-8] i.a.c.a.s.t.j.JsonRpcTransportWrapper : Handling streaming request, returning SSE Flux
2026-08-03T18:03:56.579+08:00 INFO 42664 --- [researcher-server] [tor-http-nio-10] i.a.c.a.s.t.j.JsonRpcTransportWrapper : Handling streaming request, returning SSE Flux
2026-08-03T18:03:56.579+08:00 INFO 42664 --- [researcher-server] [ctor-http-nio-9] i.a.c.a.s.t.j.JsonRpcTransportWrapper : Handling streaming request, returning SSE Flux
2026-08-03T18:03:56.580+08:00 ERROR 42664 --- [researcher-server] [ool-10-thread-6] i.a.c.a.s.e.AgentScopeAgentExecutor : [4942d095-3cd5-4c91-b576-d575a0e9f51c] Agent execution failed

java.lang.ArrayIndexOutOfBoundsException: Index 23 out of bounds for length 23
at java.base/java.util.LinkedHashMap.keysToArray(LinkedHashMap.java:673) ~[na:na]
at java.base/java.util.LinkedHashMap.keysToArray(LinkedHashMap.java:661) ~[na:na]
at java.base/java.util.HashSet.toArray(HashSet.java:376) ~[na:na]
at java.base/java.util.ArrayList.(ArrayList.java:181) ~[na:na]
at io.agentscope.core.ReActAgent.(ReActAgent.java:158) ~[agentscope-1.0.12.jar:na]
at io.agentscope.core.ReActAgent$Builder.build(ReActAgent.java:1594) ~[agentscope-1.0.12.jar:na]
at io.agentscope.core.a2a.server.executor.runner.ReActAgentWithBuilderRunner.buildReActAgent(ReActAgentWithBuilderRunner.java:40) ~[agentscope-1.0.12.jar:na]
at io.agentscope.core.a2a.server.executor.runner.BaseReActAgentRunner.stream(BaseReActAgentRunner.java:57) ~[agentscope-1.0.12.jar:na]
at io.agentscope.core.a2a.server.executor.AgentScopeAgentExecutor.execute(AgentScopeAgentExecutor.java:101) ~[agentscope-1.0.12.jar:na]
at io.a2a.server.requesthandlers.DefaultRequestHandler$2.run(DefaultRequestHandler.java:640) ~[a2a-java-sdk-server-common-0.3.3.Final.jar:na]
at java.base/java.util.concurrent.CompletableFuture$AsyncRun.run(CompletableFuture.java:1804) ~[na:na]
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144) ~[na:na]
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642) ~[na:na]
at java.base/java.lang.Thread.run(Thread.java:1583) ~[na:na]
```

### Expected behavior
`build()` must not mutate shared builder state. Building an agent from a shared builder concurrently should be safe (all reads, no writes to the builder's collections).

### Suggested fix
Move the `if (enablePendingToolRecovery) this.hooks.add(new PendingToolRecoveryHook())` from `Builder.build()` into `ReActAgent.`, operating on the per-instance `new ArrayList<>(builder.hooks)` copy — i.e., what the 2.0 rewrite already does (`build()` no longer touches `this.hooks`; the flag is handled at execution time).

### Version status
- `1.0.12`(latest stable 1.x):**affected** — verified by bytecode.
- `1.1.0-RC2`(latest 1.x RC):**still affected** — verified by bytecode, `build()` still does `this.hooks.add(new PendingToolRecoveryHook())`.
- `2.0.0`(full rewrite):**fixed** — `build()` no longer mutates `this.hooks`; but upgrading to 2.0 is a breaking migration, not a drop-in fix.

Guia de contribuição

Abrir o guia de contribuição

Avaliação

Esta issue ainda não foi avaliada.

Receba novas issues na sua caixa de entrada

Um resumo curto de issues do GitHub para quem está começando.