agentscope-ai / agentscope-ai/agentscope
[Bug]: reply_stream() emits no text events for a final-only streaming ChatResponse
- Langage dominant
- Python
- Étoiles
- 31.6k
- Forks
- 3.5k
- Merge moyen
- 1 j 16 h
- PR mergées (30 j)
- 103
Description
### Background / Description
`Agent.reply_stream()` normally exposes model text through `TextBlockStartEvent`, `TextBlockDeltaEvent`, and `TextBlockEndEvent`.
`ChatResponse.is_last=True` is documented to mean that `content` is the complete response. A streaming `ChatModelBase` implementation may therefore legally produce a single final `ChatResponse` containing all text.
In that final-only case, the text is persisted into `agent.state.context`, but it is never streamed to the default `reply_stream()` caller.
### Actual Behavior
For a streaming model that yields only:
```python
ChatResponse(
content=[TextBlock(text="hello world")],
is_last=True,
)
```
Observed events include:
```text
ModelCallEndEvent
ReplyEndEvent
```
No text events are emitted:
```text
TextBlockStartEvent
TextBlockDeltaEvent
TextBlockEndEvent
```
Yet `agent.state.context` contains `"hello world"`.
### Expected Behavior
For a final-only response containing one `TextBlock("hello world")`, `reply_stream()` should expose equivalent text events so callers do not lose the model output.
### Steps to Reproduce
This uses a deterministic local fake model. It does not require an API key, network access, a model provider, or GPU.
```python
import asyncio
from typing import Any
from pydantic import BaseModel
from agentscope.agent import Agent
from agentscope.credential import CredentialBase
from agentscope.formatter import OpenAIChatFormatter
from agentscope.message import Msg, TextBlock, UserMsg
from agentscope.model import ChatModelBase, ChatResponse
from agentscope.tool import ToolChoice
class DummyCredential(CredentialBase):
@classmethod
def get_chat_model_class(cls):
return FakeModel
class FakeModel(ChatModelBase):
class Parameters(BaseModel):
pass
def __init__(self):
super().__init__(
credential=DummyCredential(),
model="fake",
parameters=self.Parameters(),
stream=True,
max_retries=0,
)
self.formatter = OpenAIChatFormatter()
async def _call_api(
self,
model_name: str,
messages: list[Msg],
tools: list[dict] | None = None,
tool_choice: ToolChoice | None = None,
**kwargs: Any,
):
async def gen():
yield ChatResponse(
content=[TextBlock(text="hello world", id="t1")],
is_last=True,
id="final",
)
return gen()
async def main():
agent = Agent(
name="assistant",
system_prompt="You are helpful.",
model=FakeModel(),
)
events = []
async for event in agent.reply_stream(UserMsg(name="user", content="hi")):
events.append(type(event).__name__)
print(events)
print([b.text for b in agent.state.context[-1].content if b.type == "text"])
asyncio.run(main())
```
Output on current `main`:
```text
['ReplyStartEvent', 'HintBlockEvent', 'ModelCallStartEvent', 'ModelCallEndEvent', 'ReplyEndEvent']
['hello world']
```
### Root Cause Analysis
`Agent._reasoning_impl()` stores an `is_last` `ChatResponse` as the completed response but does not pass that final chunk through `_convert_chat_response_to_event()`.
This assumes that equivalent content was already emitted by earlier partial chunks. That assumption is false for a valid final-only streaming response.
### Relevant Code Paths
- `src/agentscope/agent/_agent.py`
- `src/agentscope/model/_model_response.py`
### Environment
- AgentScope main: `142c341`
- Python version: `3.12.13`
- OS: WSL2 / Ubuntu 22.04.4 LTS
### Additional Context
The `ChatResponse.is_last` contract documents that its `content` is the complete response when true.
A fix must avoid duplicating text for providers that emit partial deltas and then return a final aggregate response.
I'd be happy to submit a focused fix with a regression test if this behavior is considered unintended.
Guide de contribution
Ouvrir le guide de contribution
Piste de recherche
Start with `src/agentscope/agent/_agent.py` where `Agent._reasoning_impl()` handles streaming chunks, then inspect `src/agentscope/model/_model_response.py` for `_convert_chat_response_to_event()`. Reproduce using the provided `FakeModel` snippet and confirm the current emitted event order. Add or update a regression test around final-only `ChatResponse(is_last=True)` streaming so `TextBlockStartEvent`, `TextBlockDeltaEvent`, and `TextBlockEndEvent` are emitted, and verify full response text is not duplicated when partial chunks are also produced.
Rédigé par le modèle d'indexation à partir du texte de l'issue.
Évaluation
- Stack technique
- python
- Domaine
- backend
- Type d'issue
- Bug
- Difficulté
- 2/5
- Temps estimé
- 1-3 heures
- Activité
- Active
- Clarté
- Clairement spécifiée
- Accessibilité débutants
- 75/100