# -*- coding: utf-8 -*- """链式压缩块重构验证:原生历史重建 / 压缩节点 / 双次压缩块链 / payload 透传。 运行(Agent 目录下): python tests/verify_compression_chain.py 覆盖: T0 单元渲染/合并/token 估算 T1 _rebuild_turn_messages 原生重建顺序(并行合并/尾部去重/最终回复) T2 build_native_history_messages 存储路径 + 游标跳过 T3 compressed_context 读写往返 T4 maybe_compress_context 单次压缩(范围/统计/RemoveMessage/块链) T5 双次压缩(seq 递增、旧块保留、游标推进) T6 llm payload reasoning_content 透传 """ import json import os import shutil import sys import tempfile from pathlib import Path sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src")) import agent.utils.agent_utils as au from agent.history import tool_call_recorder as tcr from agent.utils.agent_utils import load_compressed_context, save_compressed_context from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage PASSED = [] def check(name: str, cond: bool, detail: str = "") -> None: if not cond: raise AssertionError(f"FAIL {name}: {detail}") PASSED.append(name) print(f" ok {name}") def section(t: str) -> None: print(f"\n== {t} ==") # ---------- 测试目录 ---------- tmp_root = tempfile.mkdtemp(prefix="verify_compress_") SHARE_SID = "20990101_000000" try: au.SESSIONS_ROOT = os.path.join(tmp_root, "sessions") os.makedirs(au.SESSIONS_ROOT, exist_ok=True) sess_dir = os.path.join(au.SESSIONS_ROOT, SHARE_SID) os.makedirs(sess_dir, exist_ok=True) # 测试图片/文件(供 _native_user_message 绝对路径解析) Path(os.path.join(au.SESSIONS_ROOT, "20990101_120001")).mkdir(exist_ok=True) (Path(au.SESSIONS_ROOT) / "20990101_120001" / "in.png").write_bytes(b"\x89PNG\r\n") (Path(au.SESSIONS_ROOT) / "20990101_120001" / "data.pdf").write_bytes(b"1 0 R") # ---------- T0 ---------- section("T0 单元渲染/合并/token 估算") from agent.core import nodes check("ascii tokens", nodes._estimate_text_tokens("abcd") == 1, str(nodes._estimate_text_tokens("abcd"))) check("cjk tokens", nodes._estimate_text_tokens("中文文本") == 4) merged = nodes._merge_units([ HumanMessage(content="m1"), HumanMessage(content="m2"), AIMessage(content="a1"), AIMessage(content="a2", additional_kwargs={"reasoning_content": "r"}, tool_calls=[{"id": "t1", "name": "t", "args": {}}]), ToolMessage(content="tool ok", tool_call_id="t1", name="t"), ]) check("合并单元数=3(human 合并 1 + ai 合并 1 + tool 1)", len(merged) == 3, str(len(merged))) check("human 合并含两条文本", "m1" in merged[0] and "m2" in merged[0]) check("ai 合并含思考与调用", "r" in merged[1] and "工具调用" in merged[1]) # ---------- T1 ---------- section("T1 _rebuild_turn_messages 原生重建顺序/id") TID1 = "20990101_120001" turn_msgs = [ {"role": "user", "user": {"content": [ {"type": "text", "text": "帮我处理图片"}, {"type": "image_url", "relpath": f"{TID1}/in.png", "name": "in.png"}, {"type": "file", "relpath": f"{TID1}/data.pdf", "name": "data.pdf"}, ]}}, {"role": "assistant", "assistant": {"text": "处理完成"}}, ] calls = [ {"id": "c1", "name": "read_image", "args": {"path": "in.png"}, "status": "done", "thinking": "思考A", "reflection": "反思A", "result_text": "图片内容是文本", "group_index": 0, "result_images": [], "result_files": []}, {"id": "c2", "name": "read_file", "args": {"path": "data.pdf"}, "status": "done", "thinking": "", "reflection": "", "result_text": "PDF 正文", "group_index": 0, "result_images": [], "result_files": ["out.txt"]}, {"id": "c3", "name": "call_subagent", "args": {"task": "x"}, "status": "done", "thinking": "", "reflection": "", "result_text": "子任务完成", "group_index": 1, "result_images": [], "result_files": []}, ] tail = [ {"thought": "思考A", "reflection": "反思A"}, # 与组内重复 → 去重 {"thought": "尾部思考", "reflection": ""}, # 未消费 → 注入 ] msgs = au._rebuild_turn_messages(TID1, turn_msgs, calls, tail) kinds = [type(m).__name__ for m in msgs] expect_kinds = ["HumanMessage", "AIMessage", "ToolMessage", "ToolMessage", "AIMessage", "ToolMessage", "AIMessage", "AIMessage"] check("顺序 user->AI(c1c2)->Tool2->AI(c3)->Tool1->tail->final", kinds == expect_kinds, str(kinds)) check("user 多模态还原(text+image_url+file)", len(msgs[0].content) == 3, str(msgs[0].content)) img_part = msgs[0].content[1] check("image part 绝对路径", img_part["type"] == "image_url" and str(img_part["image_url"]["url"]).endswith(os.path.join(TID1, "in.png")), str(img_part)) file_part = msgs[0].content[2] check("file part 绝对路径", file_part["type"] == "file" and str(file_part["path"]).endswith(os.path.join(TID1, "data.pdf")), str(file_part)) a0 = msgs[1] check("AIMessage0 合并两条并行调用", len(a0.tool_calls) == 2, str(a0.tool_calls)) check("AIMessage0 反思→content", a0.content == "反思A", repr(a0.content)) check("AIMessage0 思考→reasoning_content", (a0.additional_kwargs or {}).get("reasoning_content") == "思考A", str(a0.additional_kwargs)) check("AIMessage0 id", a0.id == f"hist_assist_{TID1}_0", str(a0.id)) check("Tool c1 匹配 id/name", msgs[2].tool_call_id == "c1" and msgs[2].name == "read_image") check("Tool c2 含工具产物提示", "out.txt" in msgs[3].content, msgs[3].content) check("AIMessage1 单调用", len(msgs[4].tool_calls) == 1 and msgs[4].id == f"hist_assist_{TID1}_1") check("Tail 去重+注入", msgs[6].id == f"hist_tail_{TID1}_0" and (msgs[6].additional_kwargs or {}).get("reasoning_content") == "尾部思考", str(msgs[6].id)) check("Final id/内容", msgs[7].id == f"hist_final_{TID1}" and msgs[7].content == "处理完成") check("全部 synthetic/tool_ctx 标记", all((m.additional_kwargs or {}).get("tool_ctx") for m in msgs)) # ---------- T2 ---------- section("T2 build_native_history_messages 存储路径 + 游标跳过") turn_path = Path(sess_dir) / f"turn_{TID1}.json" turn_path.write_text(json.dumps({"messages": turn_msgs}, ensure_ascii=False), encoding="utf-8") tool_path = Path(sess_dir) / f"tool_{TID1}.json" tool_path.write_text(json.dumps({ "turn_id": TID1, "tool_calls": calls, "reflections": tail, }, ensure_ascii=False), encoding="utf-8") hist0 = au.build_native_history_messages(SHARE_SID, 0) check("cursor=0 重建 8 条", len(hist0) == 8, str(len(hist0))) hist4 = au.build_native_history_messages(SHARE_SID, 4) check("cursor=4 跳过前 4 条", len(hist4) == 4 and hist4[0].id == f"hist_assist_{TID1}_1", str([m.id for m in hist4])) check("cursor=8 无剩余", len(au.build_native_history_messages(SHARE_SID, 8)) == 0) # ---------- T3 ---------- section("T3 compressed_context 读写往返(真实文件)") ctx1 = {"version": 2, "cursor_msg": 0, "blocks": [ {"seq": 1, "content": "块1摘要", "msg_from": 1, "msg_to": 8, "units": 8, "merged_units": 7, "turns": 1, "tokens_before": 900, "tokens_after": 60, "compressed_at": 1}, ]} save_compressed_context(SHARE_SID, ctx1) loaded = load_compressed_context(SHARE_SID) check("往返一致", loaded == ctx1, str(loaded)[:200]) check("新键结构", "blocks" in loaded and loaded["version"] == 2, str(loaded)[:120]) # ---------- T4 ---------- section("T4 maybe_compress_context 单次压缩(范围/统计/RemoveMessage/块链)") seen = {"live": [], "result": []} tcr.get_current_session = lambda: SHARE_SID tcr.is_sub_agent_context = lambda: False tcr.record_tool_call_live = lambda sid, name, args: seen["live"].append((sid, name, args)) tcr.record_tool_result_live = lambda sid, name, text: seen["result"].append((sid, name, text)) au.summarize_chat_text = lambda llm, text: "SUM:" + text[:80] state_msgs = [SystemMessage(content="sys")] state_msgs.append(SystemMessage(content="【历史对话摘要 #1】\n块1摘要", id="compressed_block_1")) state_msgs.extend(au.build_native_history_messages(SHARE_SID, 0)) state_msgs.append(HumanMessage(content="当前轮用户问题")) # 无 id → 不压缩 out1 = nodes.maybe_compress_context({"messages": state_msgs, "_need_compress": True}) upd = out1["messages"] rm = [u for u in upd if type(u).__name__ == "RemoveMessage"] new_sys = [u for u in upd if isinstance(u, SystemMessage)] check("RemoveMessage 移除全部 8 条 hist_*", len(rm) == 8, str(len(rm))) check("新增 1 个块 SystemMessage", len(new_sys) == 1) check("新块 id=compressed_block_2", new_sys[0].id == "compressed_block_2", str(new_sys[0].id)) check("新块内容带序号前缀", new_sys[0].content.startswith("【历史对话摘要 #2】\nSUM:"), str(new_sys[0].content)[:40]) check("_need_compress 复位", out1.get("_need_compress") is False) ctx2 = load_compressed_context(SHARE_SID) check("块链追加(2 块,新块 seq=2)", len(ctx2["blocks"]) == 2 and ctx2["blocks"][1]["seq"] == 2, str(len(ctx2["blocks"]))) b2 = ctx2["blocks"][1] check("统计 msg 区间 1-8", b2["msg_from"] == 1 and b2["msg_to"] == 8, str(b2)) check("统计 turns=1 / units=8 / merged=7", b2["turns"] == 1 and b2["units"] == 8 and b2["merged_units"] == 7, f"turns={b2['turns']} units={b2['units']} merged={b2['merged_units']}") check("统计 tokens_before>tokens_after>0", b2["tokens_before"] > b2["tokens_after"] > 0, f"{b2.get('tokens_before')}->{b2.get('tokens_after')}") check("游标推进到 8", ctx2["cursor_msg"] == 8, str(ctx2["cursor_msg"])) check("SSE 压缩开始事件", seen["live"] and seen["live"][-1][0] == SHARE_SID and seen["live"][-1][2]["action"] == "上下文压缩中", str(seen["live"])[:120]) check("SSE 压缩完成事件", seen["result"] and "压缩完成" in seen["result"][-1][2], str(seen["result"])[:120]) # ---------- T5 ---------- section("T5 双次压缩(seq 递增、旧块保留、游标推进)") TID2 = "21090102_090000" turn2_msgs = [ {"role": "user", "user": {"content": "第二轮问题"}}, {"role": "assistant", "assistant": {"text": "第二轮答复"}}, ] (Path(sess_dir) / f"turn_{TID2}.json").write_text( json.dumps({"messages": turn2_msgs}, ensure_ascii=False), encoding="utf-8") calls2 = [ {"id": "c4", "name": "search", "args": {"q": "xy"}, "status": "done", "thinking": "思考B", "reflection": "", "result_text": "结果B", "group_index": 0, "result_images": [], "result_files": []}, ] (Path(sess_dir) / f"tool_{TID2}.json").write_text( json.dumps({"turn_id": TID2, "tool_calls": calls2, "reflections": []}, ensure_ascii=False), encoding="utf-8") hist_t2 = au.build_native_history_messages(SHARE_SID, 8) check("游标后新内容 4 条(user+AI+tool+final)", len(hist_t2) == 4, str([m.id for m in hist_t2])) au.summarize_chat_text = lambda llm, text: "SUM2:" + text[:60] state2_msgs = [SystemMessage(content="sys")] for b in load_compressed_context(SHARE_SID)["blocks"]: state2_msgs.append(SystemMessage(content=f"#{b['seq']}:{b['content']}", id=f"compressed_block_{b['seq']}")) state2_msgs.extend(hist_t2) state2_msgs.append(HumanMessage(content="第二轮当前问题")) out2 = nodes.maybe_compress_context({"messages": state2_msgs, "_need_compress": True}) upd2 = out2["messages"] rm2 = [u for u in upd2 if type(u).__name__ == "RemoveMessage"] new_sys2 = [u for u in upd2 if isinstance(u, SystemMessage)] check("第二次 RemoveMessage 4 条", len(rm2) == 4, str(len(rm2))) check("第二次新块 seq=3", new_sys2[0].id == "compressed_block_3", str(new_sys2[0].id)) ctx3 = load_compressed_context(SHARE_SID) check("块链 3 块且旧块内容未变", len(ctx3["blocks"]) == 3 and ctx3["blocks"][0]["content"] == "块1摘要" and ctx3["blocks"][1]["content"].startswith("SUM:"), str(len(ctx3["blocks"]))) b3 = ctx3["blocks"][2] check("第 2 次统计区间 9-12", b3["msg_from"] == 9 and b3["msg_to"] == 12, str(b3)) check("游标推进到 12", ctx3["cursor_msg"] == 12, str(ctx3["cursor_msg"])) # ---------- T6 ---------- section("T6 llm payload reasoning_content 透传") from agent.core.llm import Multimodel_LLM llm = Multimodel_LLM(model="test-model", api_key="sk-test", base_url="http://localhost:9/v1") payload = llm._get_request_payload([ HumanMessage(content="用户问题"), AIMessage(content="反思A", additional_kwargs={"reasoning_content": "思考A"}, tool_calls=[{"id": "c1", "name": "t", "args": {}}]), ToolMessage(content="结果", tool_call_id="c1", name="t"), ]) pdicts = payload["messages"] check("payload 3 条消息", len(pdicts) == 3, str(len(pdicts))) asst = [p for p in pdicts if p.get("role") == "assistant"][0] check("reasoning_content 透传", asst.get("reasoning_content") == "思考A", str(asst)) check("tool_calls 保留", len(asst.get("tool_calls") or []) == 1) print(f"\n全部通过: {len(PASSED)} 项") finally: shutil.rmtree(tmp_root, ignore_errors=True)