Skip to content

修改streamEvents的输出顺序#1758

Open
alexsand1 wants to merge 2 commits into
agentscope-ai:mainfrom
alexsand1:main
Open

修改streamEvents的输出顺序#1758
alexsand1 wants to merge 2 commits into
agentscope-ai:mainfrom
alexsand1:main

Conversation

@alexsand1

Copy link
Copy Markdown

AgentScope-Java Version

[The version of AgentScope-Java you are working on, e.g. 1.0.12, check your pom.xml dependency version or run mvn dependency:tree | grep agentscope-parent:pom(only mac/linux)]

Description

[Please describe the background, purpose, changes made, and how to test this PR]

Checklist

Please check the following items before code is ready to be reviewed.

  • Code has been formatted with mvn spotless:apply
  • All tests are passing (mvn test)
  • Javadoc comments are complete and follow project conventions
  • Related documentation has been updated (e.g. links, examples, etc.)
  • Code is ready for review

@alexsand1 alexsand1 requested a review from a team June 14, 2026 11:58
@CLAassistant

CLAassistant commented Jun 14, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@AgentScopeJavaBot AgentScopeJavaBot added enhancement New feature or request area/core/agent Agent runtime, pipeline, hooks, plan labels Jun 15, 2026

@AgentScopeJavaBot AgentScopeJavaBot left a comment

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.

🤖 AI Review

This PR modifies emitBlockEvents() in ReActAgent to close the currently-open block type (thinking, text, or tool call) before opening a new one, producing a strictly sequential event stream where Start/End pairs never overlap. The approach for text↔thinking transitions is correct. However, the tool-call handling has a latent correctness bug: removing entries from startedToolCalls after emitting ToolCallEndEvent allows the same toolId to re-pass putIfAbsent if a model ever streams interleaved tool-call chunks, producing duplicate ToolCallStartEvents. Additionally, this is a non-trivial state-machine change with no test coverage and an empty PR description.

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.


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.

if (block instanceof TextBlock tb) {


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)

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.

@AgentScopeJavaBot AgentScopeJavaBot left a comment

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.

🤖 AI Review

This PR modifies emitBlockEvents() in ReActAgent to close the currently-open block type (thinking, text, or tool call) before opening a new one, producing a strictly sequential event stream where Start/End pairs never overlap. The approach for text↔thinking transitions is correct. However, the tool-call handling has a latent correctness bug: removing entries from startedToolCalls after emitting ToolCallEndEvent allows the same toolId to re-pass putIfAbsent if a model ever streams interleaved tool-call chunks, producing duplicate ToolCallStartEvents. Additionally, this is a non-trivial state-machine change with no test coverage and an empty PR description.

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.


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.

if (block instanceof TextBlock tb) {


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)

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core/agent Agent runtime, pipeline, hooks, plan enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants