- 压缩崩根因(venv 2.0.2 代码级证据,4 次实证 'important_discoveries' required):reply 每轮推理前 compress_context(_agent.py:609)→ generate_structured_output 用 SummarySchema json-schema(五字段全 required+maxLength,_config.py:11-53)强制 M3 tool_choice 产摘要 → dict 路 jsonschema.validate (model/_base.py:566-567)对缺字段/超长抛 ValidationError → 非 retryable 直接 raise(_base.py:426-428) → _compress_context_impl 非 overflow 支 raise e from None(_agent.py:461)→ 穿透 reply;ChatService.run 吞异常不发终结事件(app/_service/_chat.py:166-176)→ driver 600s 慢失败 - ctx_compress_patch:fail-open 包 Agent.compress_context(压缩=旁路优化件,失败只跳过不杀主链;连续 2 次 断路防每轮白烧压缩计费调用;钉 2.0.2 不改 venv 本体,纪律同 m3_stream_patch);Service 与 CLI 两处 apply - collector on_reply 增崩溃支:先 flush 收口采集,再 yield 合成 ReplyEndEvent(消费侧先 publish 到 bus → driver 立刻收到回合终结按盘面快速失败),原异常原样重抛不掩盖;正常路零行为变化 - 测试 9 用例:成功透传/失败吞掉/2 次断路/成功清零/按实例隔离/apply 幂等;崩溃合成终结+异常上抛+崩溃路 flush/正常路不追加/合成兜底不遮原异常 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
140 lines
5.3 KiB
Python
140 lines
5.3 KiB
Python
"""test_service_synthetic_reply_end.py — Service 壳 run 崩合成终结事件单测(T5 慢失败 600s 出口封口)。
|
|
|
|
守的不变量:collector(最外层 on_reply)在内层 reply 生成器抛异常时——
|
|
· 先 yield 一枚合成 ReplyEndEvent(消费侧 _run_impl 的 async-for 会先 publish 它到 bus,driver 据此
|
|
立刻收到回合终结、快速失败),再把原异常原样上抛(不掩盖失败;框架 ChatService.run 照记日志);
|
|
· 崩溃路也 flush 收口采集 sidecar(部分成本/repairs 可回收);
|
|
· 正常路(REPLY_END 自然产生)行为不变:透传全部事件、REPLY_END 前 flush、不追加合成事件。
|
|
|
|
跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_service_synthetic_reply_end.py -v
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
|
|
import _bootstrap # noqa: E402,F401
|
|
import cheap_run # noqa: E402
|
|
import cheap_service_app as app # noqa: E402
|
|
|
|
|
|
class _FakeBreaker:
|
|
spent_rmb = 1.23
|
|
_rmb_gate_active = True
|
|
budget_soft_tripped = False
|
|
|
|
|
|
class _FakeRepair:
|
|
repairs = 2
|
|
|
|
|
|
class _FakeTracer:
|
|
def summary(self):
|
|
return {"traceId": "t", "steps": 0, "dropped": 0}
|
|
|
|
|
|
class _FakeState:
|
|
session_id = "sess-1"
|
|
reply_id = "reply-1"
|
|
|
|
|
|
class _FakeAgent:
|
|
state = _FakeState()
|
|
|
|
|
|
def _collector(game_id):
|
|
cls = app._get_collector_cls()
|
|
return cls(game_id=game_id, breaker=_FakeBreaker(), repair=_FakeRepair(), tracer=_FakeTracer())
|
|
|
|
|
|
def _consume(collector, handler):
|
|
"""驱动 on_reply,返回 (产出事件列表, 捕获的异常)。模拟框架 _run_impl 的 async-for 消费方。"""
|
|
async def _run():
|
|
out, err = [], None
|
|
try:
|
|
async for evt in collector.on_reply(_FakeAgent(), {}, handler):
|
|
out.append(evt)
|
|
except Exception as e: # noqa: BLE001
|
|
err = e
|
|
return out, err
|
|
return asyncio.run(_run())
|
|
|
|
|
|
def _sidecar(game_id):
|
|
return cheap_run.game_dir(game_id) / "evidence" / "service-run-summary.json"
|
|
|
|
|
|
def test_run_crash_yields_synthetic_reply_end(tmp_path, monkeypatch):
|
|
"""run 崩(如压缩崩/框架异常):消费方先收到合成 ReplyEndEvent,再收到原异常(快失败链)。"""
|
|
monkeypatch.setattr(cheap_run, "_GAMES_DIR", tmp_path)
|
|
|
|
async def crashing_handler(**_kwargs):
|
|
yield type("TextDeltaEvent", (), {})() # 崩前有若干普通事件
|
|
raise RuntimeError("'important_discoveries' is a required property") # 实证崩因形态
|
|
|
|
events, err = _consume(_collector("crash-t"), crashing_handler)
|
|
assert err is not None and "important_discoveries" in str(err), "原异常必须原样上抛(不掩盖失败)"
|
|
from agentscope.event import ReplyEndEvent
|
|
assert events and isinstance(events[-1], ReplyEndEvent), "崩溃前必须先产出合成 ReplyEndEvent(快终结)"
|
|
assert events[-1].session_id == "sess-1" and events[-1].reply_id == "reply-1"
|
|
# 崩溃路收口采集也已落盘(部分成本可回收)。
|
|
sc = _sidecar("crash-t")
|
|
assert sc.exists()
|
|
obj = json.loads(sc.read_text(encoding="utf-8"))
|
|
assert obj["costRmb"] == 1.23 and obj["repairs"] == 2
|
|
|
|
|
|
def test_normal_path_unchanged(tmp_path, monkeypatch):
|
|
"""正常路:真 REPLY_END 自然产生 → 全事件透传、不追加合成事件、REPLY_END 前已 flush。"""
|
|
monkeypatch.setattr(cheap_run, "_GAMES_DIR", tmp_path)
|
|
from agentscope.event import ReplyEndEvent
|
|
|
|
real_end = ReplyEndEvent(session_id="sess-1", reply_id="reply-1")
|
|
|
|
async def normal_handler(**_kwargs):
|
|
yield type("TextDeltaEvent", (), {})()
|
|
yield real_end
|
|
|
|
events, err = _consume(_collector("ok-t"), normal_handler)
|
|
assert err is None
|
|
ends = [e for e in events if isinstance(e, ReplyEndEvent)]
|
|
assert ends == [real_end], "正常路不得追加合成终结事件(只透传真 REPLY_END)"
|
|
assert _sidecar("ok-t").exists()
|
|
|
|
|
|
def test_synthetic_failure_still_reraises(tmp_path, monkeypatch):
|
|
"""合成兜底自身失败(极端):退回慢失败路,但原异常仍上抛、不被遮蔽。"""
|
|
monkeypatch.setattr(cheap_run, "_GAMES_DIR", tmp_path)
|
|
collector = _collector("synthfail-t")
|
|
|
|
async def crashing_handler(**_kwargs):
|
|
raise ValueError("original-crash")
|
|
yield # pragma: no cover —— 使其成为 async generator
|
|
|
|
# 让合成事件构造必败:agent.state 缺失 → getattr 兜空串仍能构造;改为直接破坏 import 不现实,
|
|
# 这里用「state 为 None」走 getattr(None,...) 兜底路——合成仍应成功(空串 id 合法)。
|
|
class _NoStateAgent:
|
|
state = None
|
|
|
|
async def _run():
|
|
out, err = [], None
|
|
try:
|
|
async for evt in collector.on_reply(_NoStateAgent(), {}, crashing_handler):
|
|
out.append(evt)
|
|
except Exception as e: # noqa: BLE001
|
|
err = e
|
|
return out, err
|
|
|
|
events, err = asyncio.run(_run())
|
|
assert err is not None and "original-crash" in str(err)
|
|
from agentscope.event import ReplyEndEvent
|
|
# state=None 时合成事件用空串 id 仍产出(快终结优先);关键断言=原异常不被吞。
|
|
assert any(isinstance(e, ReplyEndEvent) for e in events)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("请用 pytest 跑(用例依赖 tmp_path/monkeypatch fixture)。")
|
|
sys.exit(1)
|