agentscope-ai / agentscope-ai/agentscope-java

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

Ouverte
#3,065 3 commentaires 0 réactions 0 personnes assignées Voir sur GitHub
area/core/agent area/core/tool bug
Langage dominant
Java
Étoiles
5.6k
Forks
1.3k
Merge moyen
4 j 12 h
PR mergées (30 j)
77

Description

# [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!

Guide de contribution

Ouvrir le guide de contribution

Évaluation

Cette issue n'a pas encore été évaluée.

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.