Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -2107,24 +2107,59 @@ private void emitBlockEvents(
List<AgentEvent> events) {

if (block instanceof TextBlock tb) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Multiple consecutive blank lines (here lines 2110–2111, 2117–2118, 2160–2162) hurt readability. Run mvn spotless:apply to normalize formatting — the PR checklist item is unchecked.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Multiple consecutive blank lines (here lines 2110–2111, 2117–2118, 2160–2162) hurt readability. Run mvn spotless:apply to normalize formatting — the PR checklist item is unchecked.


if (textStarted.compareAndSet(false, true)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Missing test coverage: This PR changes the event-ordering state machine (a correctness-critical contract), but the existing StreamOrdering test suite in AgentEventStreamTest is @Disabled and no new tests are added. Please add at least the following test scenarios:

  1. Thinking → Text transition emits ThinkingBlockEnd before TextBlockStart
  2. Text → Thinking transition emits TextBlockEnd before ThinkingBlockStart
  3. Tool call B starts after tool call A: ToolCallEnd(A) is emitted before ToolCallStart(B)
  4. endEvents closure only emits end events for blocks still open (not already closed by transitions)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Missing test coverage: This PR changes the event-ordering state machine (a correctness-critical contract), but the existing StreamOrdering test suite in AgentEventStreamTest is @Disabled and no new tests are added. Please add at least the following test scenarios:

  1. Thinking → Text transition emits ThinkingBlockEnd before TextBlockStart
  2. Text → Thinking transition emits TextBlockEnd before ThinkingBlockStart
  3. Tool call B starts after tool call A: ToolCallEnd(A) is emitted before ToolCallStart(B)
  4. endEvents closure only emits end events for blocks still open (not already closed by transitions)

if (thinkingStarted.get()) {
events.add(new ThinkingBlockEndEvent(replyId, "thinking"));
thinkingStarted.set(false);
}


events.add(new TextBlockStartEvent(replyId, "text"));
}
if (tb.getText() != null && !tb.getText().isEmpty()) {
events.add(new TextBlockDeltaEvent(replyId, "text", tb.getText()));
}
} else if (block instanceof ThinkingBlock tb) {
if (thinkingStarted.compareAndSet(false, true)) {

if (textStarted.get()) {
events.add(new TextBlockEndEvent(replyId, "text"));
textStarted.set(false);
}

events.add(new ThinkingBlockStartEvent(replyId, "thinking"));
}
if (tb.getThinking() != null && !tb.getThinking().isEmpty()) {
events.add(new ThinkingBlockDeltaEvent(replyId, "thinking", tb.getThinking()));
}
} else if (block instanceof ToolUseBlock tub) {

String toolId = resolveToolCallId(tub, context);
String toolName = tub.getName();
Map<String, String> preStartToolCalls = new HashMap<>(startedToolCalls);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Minor inefficiency: new HashMap<>(startedToolCalls) is allocated on every ToolUseBlock chunk, even when toolId is null or the tool was already registered (duplicate). Move this snapshot inside the if (toolId != null && startedToolCalls.putIfAbsent(...) == null) block to avoid unnecessary allocations for the common delta-only path.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Minor inefficiency: new HashMap<>(startedToolCalls) is allocated on every ToolUseBlock chunk, even when toolId is null or the tool was already registered (duplicate). Move this snapshot inside the if (toolId != null && startedToolCalls.putIfAbsent(...) == null) block to avoid unnecessary allocations for the common delta-only path.

if (toolId != null && startedToolCalls.putIfAbsent(toolId, toolName) == null) {
if (toolName != null && !toolName.startsWith("__")) {

if (thinkingStarted.get()) {
events.add(new ThinkingBlockEndEvent(replyId, "thinking"));
thinkingStarted.set(false);
}

if (textStarted.get()) {
events.add(new TextBlockEndEvent(replyId, "text"));
textStarted.set(false);
}
for (Map.Entry<String, String> tc : preStartToolCalls.entrySet()) {
events.add(
new ToolCallEndEvent(
replyId, tc.getKey(), tc.getValue()));
startedToolCalls.remove(tc.getKey());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Bug: startedToolCalls.remove() allows re-entry of already-closed tool calls.

After emitting ToolCallEndEvent and removing the key from startedToolCalls, a later streaming chunk carrying the same toolId will pass putIfAbsent again, producing a duplicate ToolCallStartEvent for a tool call that was already started and ended.

This cannot happen with today's sequential tool-call streaming (OpenAI, Anthropic), but it is a latent correctness bug that will surface the moment any provider interleaves tool-call chunks.

Fix: Use a separate Set<String> closedToolCalls to track ended tool IDs instead of mutating startedToolCalls:

Set<String> closedToolCalls = new HashSet<>();
// ...
for (Map.Entry<String, String> tc : preStartToolCalls.entrySet()) {
    if (closedToolCalls.add(tc.getKey())) {
        events.add(new ToolCallEndEvent(replyId, tc.getKey(), tc.getValue()));
    }
}

Keep startedToolCalls intact so that putIfAbsent still correctly identifies duplicate tool IDs.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Bug: startedToolCalls.remove() allows re-entry of already-closed tool calls.

After emitting ToolCallEndEvent and removing the key from startedToolCalls, a later streaming chunk carrying the same toolId will pass putIfAbsent again, producing a duplicate ToolCallStartEvent for a tool call that was already started and ended.

This cannot happen with today's sequential tool-call streaming (OpenAI, Anthropic), but it is a latent correctness bug that will surface the moment any provider interleaves tool-call chunks.

Fix: Use a separate Set<String> closedToolCalls to track ended tool IDs instead of mutating startedToolCalls:

Set<String> closedToolCalls = new HashSet<>();
// ...
for (Map.Entry<String, String> tc : preStartToolCalls.entrySet()) {
    if (closedToolCalls.add(tc.getKey())) {
        events.add(new ToolCallEndEvent(replyId, tc.getKey(), tc.getValue()));
    }
}

Keep startedToolCalls intact so that putIfAbsent still correctly identifies duplicate tool IDs.

}



events.add(new ToolCallStartEvent(replyId, toolId, toolName));
}
}
Expand Down
Loading