agentscope-ai / agentscope-ai/agentscope

Swallowing a ReplyEndEvent to force a redo permanently merges the discarded draft into the agent's persisted context

Abierto
#2,328 2 comentarios 0 reacciones 0 asignados Ver en GitHub
Lenguaje dominante
Python
Estrellas
31.5k
Forks
3.5k
Merge medio
1 d 23 h
PR fusionados (30 d)
95

Descripción

### Background / Description

When a middleware's `on_reply` hook swallows a `ReplyEndEvent` to force another
reasoning-acting round within the same reply (the pattern the middleware system's
own docs and tests use for self-critique middlewares), the discarded draft answer
from the swallowed round is not dropped. It ends up permanently glued into the
same assistant `Msg` as the real answer, as an extra `TextBlock` with no boundary
between the two, and that merged message becomes part of `agent.state.context`
that is fed to the model formatter on every later turn.

Concretely: middleware swallows round 1's `ReplyEndEvent`, agent redoes the
round and produces round 2's answer. The caller only ever sees round 2's answer
(`final_msg`), which looks correct. But `agent.state.context` ends up with round
1's text and round 2's text concatenated as two `TextBlock`s inside one
`assistant` message. I ran that merged context through the real
`OpenAIChatFormatter` and confirmed round 1's discarded text is present in the
formatted payload, i.e. it is not just an internal bookkeeping artifact, it would
be sent to the model as the agent's own prior utterance on every subsequent call.

I only traced the plain text-only-final-answer exit path (the one the
`SwallowOnceMiddleware` test already covers). I have not checked whether the
structured-output-completion exit path or the HITL-park exit path are affected
the same way, so I'm not claiming those here.

### Error Messages

None. Nothing raises or logs; `final_msg` looks correct to the caller, so this
is a silent context-correctness bug, not a crash.

### Steps to Reproduce

```python
import asyncio
from typing import AsyncGenerator, Callable

from agentscope.event import ReplyEndEvent
from agentscope.agent import Agent, InjectionConfig
from agentscope.middleware import MiddlewareBase
from agentscope.model import ChatResponse
from agentscope.message import TextBlock, UserMsg, Msg
from agentscope.tool import Toolkit
from agentscope.formatter import OpenAIChatFormatter

# MockModel is agentscope's own tests/utils.py::MockModel; substitute any
# ChatModelBase stub that returns the two responses below in order.
from tests.utils import MockModel

class SwallowOnceMiddleware(MiddlewareBase):
"""Copied from tests/middleware_test.py::
test_on_reply_middleware_swallow_reply_end."""

def __init__(self):
self.swallowed = False

async def on_reply(self, agent, input_kwargs, next_handler) -> AsyncGenerator:
async for item in next_handler():
if isinstance(item, ReplyEndEvent) and not self.swallowed:
self.swallowed = True
continue
yield item

async def main():
mock_model = MockModel()
mock_model.set_responses([
ChatResponse(content=[TextBlock(text="first answer")], is_last=True),
ChatResponse(content=[TextBlock(text="second answer")], is_last=True),
])

agent = Agent(
name="test_agent",
system_prompt="test prompt",
model=mock_model,
toolkit=Toolkit(),
middlewares=[SwallowOnceMiddleware()],
injection_config=InjectionConfig(inject_runtime_state=False),
)

final_msg = None
async for item in agent.reply_stream(UserMsg("user", "test message"), yield_final_msg=True):
if isinstance(item, Msg):
final_msg = item

print("final_msg:", final_msg.get_text_content()) # "second answer", looks fine

last = agent.state.context[-1]
print("persisted blocks:", [b.text for b in last.content if b.type == "text"])
# -> ['first answer', 'second answer'] <- the discarded draft is still there

formatted = await OpenAIChatFormatter().format(agent.state.context)
print("wire payload:", formatted[-1])
# -> the assistant message's content carries BOTH texts

asyncio.run(main())
```

Observed output (python:3.12-slim, this repo's own pinned deps, run twice to
confirm):

```
final_msg: second answer
persisted blocks: ['first answer', 'second answer']
wire payload: {'role': 'assistant', 'name': 'test_agent', 'content': [{'type': 'text', 'text': 'first answer'}, {'type': 'text', 'text': 'second answer'}]}
```

### Environment

- AgentScope Version: 2.0.6
- Python Version: 3.12.14
- OS: Debian (python:3.12-slim Docker image), reproduced against current `main` (`3a4e2ae`)

---
Found and reproduced with AI assistance (Claude Code), verified end to end in a
clean container against current `main` before filing; happy to dig into the
other two exit paths or send a fix if that would be useful.

Guía de contribución

Abrir la guía de contribución

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.