agentscope-ai / agentscope-ai/agentscope

[Bug]: reply_stream() emits no text events for a final-only streaming ChatResponse

Ouverte Adaptée aux débutants
#2,430 1 commentaire 0 réactions 0 personnes assignées Voir sur GitHub
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

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.