Skip to content

fix(agent): preserve block order of non-streaming responses in events - #3238

Closed
BlueX888 wants to merge 1 commit into
agentscope-ai:mainfrom
BlueX888:fix/prep-nonstream-response-multi-textblock-merge-reorder
Closed

BlueX888 wants to merge 1 commit into
agentscope-ai:mainfrom
BlueX888:fix/prep-nonstream-response-multi-textblock-merge-reorder

Conversation

@BlueX888

Copy link
Copy Markdown
Contributor

Fixes #3236

PR Title Format

fix(agent): preserve block order of non-streaming responses in events — conventional commit, lowercase scope, lowercase description.

AgentScope Version

2.0.10dev (main)

Description

Summary

Agent._convert_chat_response_to_event flattened every TextBlock of a non-streaming response into one glued delta and emitted tool calls after all the text. The message rebuilt from reply_stream events therefore diverged from the assistant message the agent persists to the context and returns from reply(). Text and tool call blocks are now emitted in their original order, one text stream per block.

Why

A non-streaming ChatResponse reaches _convert_chat_response_to_event (src/agentscope/agent/_agent.py:3762) as a single chunk carrying the whole response, and the old code:

  • merged all TextBlocks of the chunk into one TextBlockDeltaEvent via "".join(...) with no separator, and
  • emitted ToolCallBlocks after all text, dropping their position in chunk.content.

At the same time _save_to_context (src/agentscope/agent/_agent.py:3428) persists completed_response.content verbatim, and reply() (src/agentscope/agent/_agent.py:1811) returns those blocks unchanged — so for the same response the event stream said [TextBlock('Helloworld')] while the context said [TextBlock('Hello'), TextBlock('world')], and [text, tool_call, text] lost both the split and the order.

This is reachable with real models: Gemini's non-streaming parser builds one TextBlock per part (src/agentscope/model/_gemini/_model.py), Anthropic's non-streaming parser preserves [text, tool_use, text] (src/agentscope/model/_anthropic/_model.py), and stream=False is a supported ChatModelBase config. The streaming branch of the same function keeps the block order naturally (a tool-call chunk closes the open text stream), so only the non-streaming branch diverged.

Changes

  • src/agentscope/agent/_agent.py: _convert_chat_response_to_event now walks chunk.content and emits TextBlock and ToolCallBlock events in original order. Only the first text block of a chunk continues the stream of the previous chunk; every further text block closes the current stream and opens a separate one. The close-the-stream-if-no-text condition is unchanged.
  • Streaming behavior is preserved: ChatResponse.append_text (src/agentscope/model/_model_response.py:68) merges same-id text within a chunk, so streaming chunks carry at most one text block per logical stream and take the "first text block continues" path unchanged.
  • tests/agent_basic_test.py: two regression tests — two adjacent text blocks stay separate blocks in the event stream, and the message rebuilt via Msg.append_event from the events of an interleaved [text, tool_call, text] response matches the message saved to the context.

No public API change, no breaking change.

Testing

New regression tests on the unpatched tree (both fail with the glued/reordered blocks):

PYTHONPATH=src python3 -m pytest tests/agent_basic_test.py -k "non_streaming_multi_text_blocks_stay_separate or non_streaming_interleaved_text_and_tool_call_keeps_order" -v
tests/agent_basic_test.py::AgentBasicTest::test_non_streaming_interleaved_text_and_tool_call_keeps_order FAILED [ 50%]
tests/agent_basic_test.py::AgentBasicTest::test_non_streaming_multi_text_blocks_stay_separate FAILED [100%]

with the interleaved case asserting the rebuilt signature against the saved one:

E       - [('text', 'Let me check.Done checking.'),
E       + [('text', 'Let me check.'),
E          ('tool_call', 'tool_call_1', 'mock_sequential_tool', '{"input": "test"}'),
E       +  ('text', 'Done checking.'),
E          ('tool_result', 'tool_call_1', ['Sequential result: test']),
E          ('text', 'All done.')]

With the fix, the same command:

tests/agent_basic_test.py::AgentBasicTest::test_non_streaming_interleaved_text_and_tool_call_keeps_order PASSED [ 50%]
tests/agent_basic_test.py::AgentBasicTest::test_non_streaming_multi_text_blocks_stay_separate PASSED [100%]
2 passed, 17 deselected

Existing agent module tests:

PYTHONPATH=src python3 -m pytest tests/agent_basic_test.py tests/agent_config_grouping_test.py tests/agent_injection_test.py tests/agent_interrupt_test.py tests/agent_structured_output_test.py -q
66 passed

Full suite comparison (PYTHONPATH=src python3 -m pytest tests -q -p no:xdist, with the four test files that need uninstalled extras ignored): 161 failed / 2457 passed before and after the change, with identical failure lists — the only difference is the two new tests, which fail before and pass after. The pre-existing failures are local environment issues (missing optional services/deps: SQL storage, Ollama, MongoDB, Qdrant, docx parsers) and CI runs coverage run -m pytest tests -v with the full dev extra installed.

Lint and type checks (same hooks CI runs):

pre-commit run --files src/agentscope/agent/_agent.py tests/agent_basic_test.py
mypy/black/flake8/pylint/... all Passed

Checklist

A non-streaming ChatResponse reaches _convert_chat_response_to_event
as a single chunk, where all text blocks were glued into one
TextBlockDeltaEvent and tool calls were emitted after the text. The
message rebuilt from the replyStream events therefore differed from the
message persisted to the context and returned by reply(): two text
parts became one "Helloworld" block, and [text, tool_call, text] kept
neither the split nor the order.

Emit the text and tool call blocks in their original order instead, and
let only the first text block of a chunk continue the stream of the
previous chunk so that further text blocks open separate ones. Streaming
chunks keep their behavior, as each of them carries at most one text
block per logical stream.
@github-actions github-actions Bot added the pr/awaiting-review Waiting for maintainers to review this PR label Oct 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for the pull request. One thing worth sorting out before it is reviewed:

If you think it belongs here anyway, say so and a maintainer will take a look.

@BlueX888

Copy link
Copy Markdown
Contributor Author

We filed this before noticing @ffffff55's claim on #3236 — apologies for stepping on it. Withdrawing this PR in favor of #3240 so the claim stays intact; the issue report stands, and we're happy to review #3240.

@BlueX888 BlueX888 closed this Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr/awaiting-review Waiting for maintainers to review this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: reply_stream merges all text blocks of a non-streaming response into one block and moves tool calls after the text

1 participant