Skip to content

REASONING_MESSAGE_END event emitted twice for the same messageId when DeepSeek model outputs thinking + tool_call #1690

@davidzhanghui

Description

@davidzhanghui

Bug Description

When using AguiAgentAdapter with enableReasoning(true) and a DeepSeek model (e.g. deepseek-v4-pro), the REASONING_MESSAGE_END event is emitted twice for the same messageId within a single model call turn.

Steps to Reproduce

  1. Create a ReActAgent with a DeepSeek model and tools
  2. Configure AguiAdapterConfig.builder().enableReasoning(true).build()
  3. Send a request that triggers a tool call (e.g. "calculate 5+3")
  4. Observe the AG-UI event stream

Expected Behavior

Each REASONING_MESSAGE_START should have exactly one matching REASONING_MESSAGE_END for the same messageId.

Actual Behavior

REASONING_MESSAGE_END is emitted twice for the same messageId. Example event sequence:

REASONING_MESSAGE_START  (messageId=A)
REASONING_MESSAGE_CONTENT × N
TEXT_MESSAGE_START → CONTENT × N → TEXT_MESSAGE_END
REASONING_MESSAGE_END    (messageId=A)   ← 1st END (from ThinkingBlock isLast=true, line 204-209)
TOOL_CALL_START → ARGS × N
REASONING_MESSAGE_END    (messageId=A)   ← 2nd END (duplicate!)
TOOL_CALL_END
TOOL_CALL_RESULT

Root Cause Analysis

In AguiAgentAdapter.convertEvent(), there are two code paths that can emit REASONING_MESSAGE_END:

  1. Line 204-209: When ThinkingBlock.isLast=true, it emits END and calls state.endReasoningMessage(messageId), which sets currentReasoningMessageId = null.

  2. Line 225-231: When encountering a ToolUseBlock, it checks state.hasActiveReasoningMessage() and emits END if active.

However, the duplicate is likely caused by DeepSeek emitting an additional ThinkingBlock (possibly empty or with isLast=true) after the tool call args stream ends. This triggers the first code path again, emitting a second REASONING_MESSAGE_END for the same messageId.

The endReasoningMessage() method adds to endedReasoningMessages set and clears currentReasoningMessageId, but neither code path checks whether the message has already been ended before emitting the event.

Suggested Fix

Add idempotency check before emitting REASONING_MESSAGE_END:

Line 204-209 — check if already ended:

if (!state.hasEndedReasoningMessage(messageId)) {
    events.add(new AguiEvent.ReasoningMessageEnd(state.threadId, state.runId, messageId));
    state.endReasoningMessage(messageId);
}

Line 225-231 — same check (though hasActiveReasoningMessage partially covers this, adding explicit check is safer):

if (state.hasActiveReasoningMessage() && !state.hasEndedReasoningMessage(activeReasoningMessageId)) {
    events.add(new AguiEvent.ReasoningMessageEnd(state.threadId, state.runId, activeReasoningMessageId));
    state.endReasoningMessage(activeReasoningMessageId);
}

Environment

  • agentscope-java version: latest (from main branch)
  • Model: deepseek-v4-pro via OpenAI-compatible API
  • Java: 21
  • Spring Boot: 3.2.5

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/ext/integrationExternal protocols & middleware integrationsbugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions