agentscope-ai / agentscope-ai/agentscope-java
Bug: ToolEmitter progress can be delivered to the wrong concurrent request
- Lenguaje dominante
- Java
- Estrellas
- 5.6k
- Forks
- 1.3k
- Merge medio
- 4 d 12 h
- PR fusionados (30 d)
- 77
Descripción
# [Bug] ToolEmitter progress can be delivered to the wrong concurrent request
Hi! I think I found a concurrency issue around `ToolEmitter` progress events.
## What I am doing
I have one long-lived `HarnessAgent` / `ReActAgent` in a Spring service. Different HTTP requests use different `RuntimeContext` values (`userId` and `sessionId`) and call `streamEvents(...)` concurrently.
One tool sends progress like this:
```java
@Tool(name = "slow_tool", description = "A slow tool with progress")
public String run(
@ToolParam(name = "input") String input,
ToolEmitter emitter) {
emitter.emit(ToolResultBlock.text("progress for " + input));
// Simulate a slow remote call here.
return "done for " + input;
}
```
This follows the ToolEmitter example in the documentation: `emit(...)` for intermediate progress and `return` for the final tool result.
## What I see
When many sessions call the shared agent at the same time, progress from one tool call can appear in another request's event stream.
In my local endpoint test, I sent 20 concurrent requests with different `conversationId`s. All requests reached the knowledge-base tool, which emits streamed progress. The result was:
```text
20 requests total
17 requests received an internal final-result marker
2 requests received no visible result
1 request looked normal
0 HTTP failures
```
The important part is that the tool did emit its progress, but that progress was sometimes observed by another request. The original request then looked as if it had never emitted anything and received the final return value instead.
## Why it looks like a framework-level race
`ToolExecutor` has one mutable field for the internal callback:
```java
private BiConsumer internalChunkCallback;
```
Each `ReActAgent` call does roughly this:
```java
Set chunkedToolIds = ConcurrentHashMap.newKeySet();
toolkit.setInternalChunkCallback((toolUse, chunk) -> {
chunkedToolIds.add(toolUse.getId());
// emit ToolResultTextDeltaEvent to this call's sink
});
```
The next concurrent call overwrites that callback on the same `Toolkit` / `ToolExecutor`.
Also, `getEffectiveChunkCallback()` returns a lambda that reads `internalChunkCallback` when `emitter.emit(...)` is called. So an emitter created for request A can invoke request B's callback if B replaces the field before A emits a chunk.
Then request A's `chunkedToolIds` does not contain its own tool ID. When the tool returns, `emitToolResultDelta(...)` sends the final return value because this check is false:
```java
if (chunkedToolIds.contains(toolId)) {
return;
}
```
I can also reproduce the callback replacement deterministically with reflection against AgentScope 2.0.0.
## Expected behavior
For concurrent calls on one shared agent:
- a tool's progress should always go to the event sink for the same `RuntimeContext` / call;
- progress from one session should never appear in another session;
- a streamed tool should not accidentally expose its final internal return value just because another call replaced a callback.
## Possible direction
Could the internal chunk callback be passed as call-scoped data to `ToolExecutor` / `ToolCallParam`, rather than stored as mutable state on a shared `Toolkit`? Capturing an immutable callback for the current `CallExecution` would also avoid this race.
I am happy to provide a small standalone test if that would help. Thanks!
Guía de contribución
Evaluación
Este issue todavía no se ha evaluado.