agentscope-ai / agentscope-ai/agentscope-java
[Bug]: AgentSpawnTool retains every non-persistent spawned Agent indefinitely
- Ngôn ngữ chính
- Java
- Star
- 5.6k
- Fork
- 1.3k
- Merge trung bình
- 4 ngày 12 giờ
- Pull request đã merge (30 ngày)
- 77
Mô tả
## Describe the bug
`AgentSpawnTool` retains every spawned non-persistent subagent in `agentsByKey` for the lifetime of the parent tool.
Each `SpawnedAgent` strongly references the actual `Agent` instance:
```java
private record SpawnedAgent(
String key,
String agentId,
String sessionId,
String label,
Agent agent,
int depth) {}
private final ConcurrentHashMap agentsByKey =
new ConcurrentHashMap<>();
```
For a non-persistent subagent, every spawn generates a new random key:
```java
key = "agent:" + agentId + ":" + UUID.randomUUID();
```
The new entry is then stored in `agentsByKey`:
```java
agentsByKey.put(key, spawned);
```
I could not find any corresponding `agentsByKey.remove(...)` or `agentsByKey.clear()` lifecycle path.
As a result, a long-lived parent agent that continuously spawns subagents retains all historical child `Agent` instances, even after their work has finished. Since those instances remain strongly reachable, they cannot be garbage-collected.
This is related to #1911, but that issue mainly asks about `persistSession=true`. This report provides a concrete reproduction showing that non-persistent subagents are also retained indefinitely.
## To reproduce
A diagnostic test placed at:
```text
agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnRetentionDiagnosticTest.java
```
```java
/*
* Copyright 2024-2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.agentscope.harness.agent.tool;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.agentscope.core.ReActAgent;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.model.ChatResponse;
import io.agentscope.core.model.GenerateOptions;
import io.agentscope.core.model.Model;
import io.agentscope.core.model.ToolSchema;
import io.agentscope.core.state.AgentState;
import io.agentscope.harness.agent.middleware.SubagentEntry;
import io.agentscope.harness.agent.subagent.DefaultAgentManager;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
class AgentSpawnRetentionDiagnosticTest {
@Test
void finishedNonPersistentSpawnsRemainStronglyRegistered() {
AtomicInteger created = new AtomicInteger();
DefaultAgentManager manager =
new DefaultAgentManager(
List.of(
new SubagentEntry(
"worker",
"worker",
rc -> {
int index = created.incrementAndGet();
return ReActAgent.builder()
.name("worker-" + index)
.sysPrompt("worker")
.model(replyingModel())
.build();
})),
null);
AgentSpawnTool tool = new AgentSpawnTool(manager, null, 0);
AgentState parentState = AgentState.builder().build();
RuntimeContext context =
RuntimeContext.builder()
.userId("diagnostic-user")
.sessionId("diagnostic-parent")
.build();
int spawnCount = 256;
for (int i = 0; i < spawnCount; i++) {
String result =
tool.agentSpawn(context, parentState, "worker", null, null, 1, null).block();
assertTrue(result.contains("status: accepted"));
}
assertEquals(spawnCount, created.get());
assertTrue(tool.agentList().startsWith("Active subagents (" + spawnCount + "):"));
assertEquals(spawnCount, parentState.getToolContext().getSpawnRegistry().size());
}
private static Model replyingModel() {
return new Model() {
@Override
public String getModelName() {
return "diagnostic-model";
}
@Override
public Flux stream(
List messages, List tools, GenerateOptions options) {
return Flux.just(
new ChatResponse(
"diagnostic-model",
List.of(TextBlock.builder().text("done").build()),
null,
java.util.Map.of(),
"stop"));
}
};
}
}
```
The test:
1. Creates one `AgentSpawnTool`.
2. Spawns 256 distinct non-persistent `ReActAgent` instances.
3. Passes no task to `agentSpawn`, so every call returns immediately and no background subagent execution remains active.
4. Checks the factory invocation count, `agent_list`, and the persisted spawn registry.
The key assertions are:
```java
assertEquals(spawnCount, created.get());
assertTrue(
tool.agentList()
.startsWith("Active subagents (" + spawnCount + "):"));
assertEquals(
spawnCount,
parentState.getToolContext().getSpawnRegistry().size());
```
Run it with:
```bash
mvn -pl agentscope-harness -am \
-Dtest=AgentSpawnRetentionDiagnosticTest \
-Dsurefire.failIfNoSpecifiedTests=false \
test
```
Observed result:
```text
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
After all 256 spawn calls have returned:
- 256 distinct child agents were created.
- `agent_list` still reports all 256 entries as active.
- `agentsByKey` still contains all 256 `SpawnedAgent` entries.
- The parent `AgentState.spawnRegistry` contains 256 entries.
- There are no active tasks in this reproduction.
The agents are not still executing, but their instances and associated object graphs remain retained.
## Expected behavior
The framework should provide a bounded or explicit lifecycle for spawned subagents.
Possible designs include:
- an explicit `agent_release` / `agent_close` operation;
- configurable idle TTL or LRU eviction.
Automatically removing a child immediately after one task completes may not be compatible with subsequent `agent_send` calls, so the exact lifecycle policy probably requires discussion.
## Potential impact
In a short-lived request-scoped parent agent, the retained object graph may eventually become unreachable together with the parent.
However, for a long-lived parent agent, the following structures can grow without an apparent bound:
- live child `Agent` instances referenced by `agentsByKey`;
- `agentsByKey`;
- `labelToKey` when labels are used;
- serialized `spawnRegistry` metadata.
Depending on the spawn rate and resources owned by each child agent, this may eventually cause excessive heap usage or OOM.
This report confirms the unbounded retention behavior. It does not claim that a production OOM has already been reproduced or quantify the retained heap size per child agent.
## Environment
- AgentScope-Java: current `main`, commit `8d96bb0d`
- Java: OpenJDK 21.0.2
- OS: Linux 6.8.0-138-generic x86_64
Hướng dẫn đóng góp
Đánh giá
Issue này chưa được đánh giá.