agentscope-ai / agentscope-ai/agentscope-java

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

未關閉
#3,065 3 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視
area/core/agent area/core/tool bug
主要語言
Java
星號
5.6k
分支
1.3k
平均合併
4 天 12 小時
30 天內合併 PR
77

描述

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

貢獻指南

開啟貢獻指南

評估

這個 Issue 還沒有評估資料。

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。