agentscope-ai / agentscope-ai/agentscope

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

未關閉
#2,328 2 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視
主要語言
Python
星號
31.6k
分支
3.5k
平均合併
1 天 16 小時
30 天內合併 PR
103

描述

### 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.

貢獻指南

開啟貢獻指南

研究方向

Start in `tests/middleware_test.py` at `test_on_reply_middleware_swallow_reply_end`, then follow the same `ReplyEndEvent` path through `Agent.reply_stream` where assistant context is committed to `agent.state.context`. Run the provided Python reproduction (or an added assertion there) to capture the current behavior: `final_msg` is fine but `agent.state.context[-1]` keeps both text blocks. Then verify success by ensuring only `second answer` is persisted and that `OpenAIChatFormatter().format(agent.state.context)` emits only the final text.

由索引模型根據 Issue 內容生成。

評估

技術堆疊
python
領域
backend
Issue 類型
缺陷
難度
3/5
預估耗時
半天
活躍度
活躍
描述清晰度
描述清楚
新手友好度
72/100

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。