agentscope-ai / agentscope-ai/agentscope-java

Bug: ToolEmitter progress can be delivered to the wrong concurrent request

Abierto
#3,065 3 comentarios 0 reacciones 0 asignados Ver en GitHub
area/core/agent area/core/tool bug
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

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.