Files
2026-07-13 12:30:04 +08:00

857 lines
35 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Self-evolution test harness.
Simulates multiple realistic conversations and checks the evolution pass behaves
correctly: stays silent when it should, evolves (memory/skill) when it should,
backs up before editing, notifies the user, and supports undo.
Two modes:
- stub (default): the review agent's reasoning is replaced by a scripted
output per scenario. Fast, deterministic, validates the WIRING (backup,
record, inject, notify, undo, protection). No model calls.
- real: the review agent runs the configured model for real. Validates the
QUALITY of the judgement (does it correctly decide to act / stay silent).
Run:
python tests/test_evolution.py # stub mode
python tests/test_evolution.py --real # real model mode
"""
import os
import sys
import shutil
import tempfile
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class FakeChannel:
"""Captures channel.send calls instead of sending."""
def __init__(self):
self.sent = []
def send(self, reply, context):
self.sent.append({"content": getattr(reply, "content", str(reply)), "receiver": context.get("receiver")})
class FakeModel:
pass
class FakeAgent:
"""Minimal stand-in for a chat Agent."""
def __init__(self, messages, tools=None):
import threading
self.messages = messages
self.messages_lock = threading.Lock()
self.tools = tools or []
self.model = FakeModel()
self.skill_manager = None
self.memory_manager = None
class FakeReviewAgent:
"""Review agent whose run_stream returns a scripted result (stub mode)."""
def __init__(self, scripted_output, workspace, on_edit=None):
self._out = scripted_output
self._workspace = workspace
self._on_edit = on_edit
self.model = None
def run_stream(self, user_message, clear_history=False, **kwargs):
# Simulate the side effects a real review agent would perform.
if self._on_edit:
self._on_edit(self._workspace)
return self._out
class FakeAgentBridge:
"""Stand-in for AgentBridge wiring used by the executor."""
def __init__(self, agent, scripted_output, on_edit=None):
self.agents = {"session_test": agent}
self.default_agent = agent
self._scripted = scripted_output
self._on_edit = on_edit
self.injected = []
def create_agent(self, **kwargs):
from agent.memory.config import get_default_memory_config
ws = get_default_memory_config().get_workspace()
return FakeReviewAgent(self._scripted, ws, on_edit=self._on_edit)
def remember_scheduled_output(self, session_id, content, channel_type="", task_description=""):
self.injected.append(content)
# ---------------------------------------------------------------------------
# Test scaffolding
# ---------------------------------------------------------------------------
def _setup_workspace():
"""Create a realistic temp workspace: seeded memory + real editable skills.
Mirrors a real CowAgent workspace closely enough that the model has genuine
content to read, reason about, and edit during a real evolution pass.
"""
ws = Path(tempfile.mkdtemp(prefix="evo_test_"))
(ws / "MEMORY.md").write_text(
"# Long-term Memory\n\n"
"## User\n"
"- Name: 大锤 (David)\n"
"- Lives in Shenzhen, works as a backend engineer\n"
"- Company: a fintech startup, team of 8\n\n"
"## Preferences\n"
"- Likes detailed technical explanations\n",
encoding="utf-8",
)
(ws / "memory").mkdir()
(ws / "output").mkdir()
skills = ws / "skills"
# Editable skill 1: weekly report generator (has a structural gap: no risk).
(skills / "weekly-report").mkdir(parents=True)
(skills / "weekly-report" / "SKILL.md").write_text(
"# Weekly Report\n\n"
"Generate a weekly work report from the user's notes.\n\n"
"## Steps\n"
"1. Collect this week's completed items.\n"
"2. Summarize key progress in 3-5 bullets.\n"
"3. List next week's plan.\n\n"
"## Output format\n"
"Markdown with sections: 本周进展 / 下周计划\n",
encoding="utf-8",
)
# Editable skill 2: expense tracker (has a wrong currency-format step).
(skills / "expense-tracker").mkdir(parents=True)
(skills / "expense-tracker" / "SKILL.md").write_text(
"# Expense Tracker\n\n"
"Record an expense into output/expenses.md.\n\n"
"## Steps\n"
"1. Parse amount and category from the user message.\n"
"2. Append a row to output/expenses.md.\n"
"3. Format the amount with a `$` prefix.\n",
encoding="utf-8",
)
# Editable skill 3: an API caller whose SKILL.md hardcodes a WRONG endpoint
# host. The conversation discovers the correct host at runtime; the right
# fix is to edit this file's source, not just log the corrected fact.
(skills / "data-fetch").mkdir(parents=True)
(skills / "data-fetch" / "SKILL.md").write_text(
"# Data Fetch\n\n"
"Fetch records from the data service.\n\n"
"## Steps\n"
"1. Build the request payload from the user's query.\n"
"2. POST it to `https://api.example-wrong.com/v1/fetch`.\n"
"3. Parse and return the `data` field.\n",
encoding="utf-8",
)
# Protected built-in skill: must never be edited by evolution.
(skills / "image-generation").mkdir(parents=True)
(skills / "image-generation" / "SKILL.md").write_text(
"# Image Generation (built-in)\nDo not modify.\n", encoding="utf-8"
)
return ws
def _point_config_at(ws):
"""Force the global memory config to use the temp workspace."""
from agent.memory.config import MemoryConfig, set_global_memory_config
set_global_memory_config(MemoryConfig(workspace_root=str(ws)))
def _make_messages(turns):
msgs = []
for u, a in turns:
msgs.append({"role": "user", "content": u})
msgs.append({"role": "assistant", "content": a})
return msgs
# ---------------------------------------------------------------------------
# Scenarios
# ---------------------------------------------------------------------------
def scenario_silent():
"""Pure small talk -> should stay SILENT (no change, no notify)."""
return {
"name": "闲聊 (should stay SILENT)",
"goal": "none",
"turns": [
("在吗", "在的,有什么可以帮你?"),
("今天周五了,终于要放假了", "是呀,周末好好休息一下。"),
("哈哈是的,那没事了", "好的,随时找我。"),
],
"scripted": "[SILENT]",
"on_edit": None,
"expect_evolved": False,
}
def scenario_silent_qa():
"""A normal knowledge Q&A -> nothing durable, should stay SILENT."""
return {
"name": "普通问答 (should stay SILENT)",
"goal": "none",
"turns": [
("Python 里 list 和 tuple 有什么区别?",
"主要区别:list 可变、用 []tuple 不可变、用 ()。tuple 更省内存、可作字典键。"),
("那什么时候该用 tuple", "当数据不应被修改、或要做字典键/集合元素时用 tuple。"),
("懂了,谢谢", "不客气。"),
],
"scripted": "[SILENT]",
"on_edit": None,
"expect_evolved": False,
}
def scenario_silent_transient():
"""User shares transient, non-durable info -> should stay SILENT."""
return {
"name": "临时信息 (should stay SILENT)",
"goal": "none",
"turns": [
("帮我看下今天天气适合跑步吗,深圳", "深圳今天多云 26°C,傍晚湿度高,清晨或晚上跑步比较合适。"),
("那我晚上去吧", "好的,记得补水。"),
("行", "👍"),
],
"scripted": "[SILENT]",
"on_edit": None,
"expect_evolved": False,
}
def scenario_silent_advice():
"""User asks for one-off advice, no reusable workflow -> should stay SILENT."""
return {
"name": "一次性建议 (should stay SILENT)",
"goal": "none",
"turns": [
("给我起三个适合咖啡馆的名字", "可以考虑:① 拾光咖啡 ② 角落 Corner ③ 慢半拍。"),
("第二个不错", "嗯,「角落 Corner」简洁好记。"),
("就用这个了", "好的,祝开业顺利。"),
],
"scripted": "[SILENT]",
"on_edit": None,
"expect_evolved": False,
}
def scenario_memory_preference():
"""User states a durable working preference -> update MEMORY.md."""
def edit(ws):
from datetime import datetime
p = ws / "memory" / (datetime.now().strftime("%Y-%m-%d") + ".md")
p.write_text((p.read_text() if p.exists() else "") + "- Prefers concise replies; avoid long explanations\n", encoding="utf-8")
return {
"name": "记忆-偏好 (should note in daily memory)",
"goal": "memory",
"turns": [
("你能帮我解释下什么是数据库事务吗", "当然可以。数据库事务(Transaction)是一组要么全部成功、要么全部失败的操作单元,它有四个核心特性 ACID:原子性、一致性、隔离性、持久性……(接着是 5 段详细展开)"),
("打住打住,太长了", "抱歉,我说得太细了。"),
("对,你以后回答我都简短一点,抓重点就行,别长篇大论", "好的,明白了。"),
("嗯这样挺好", "👌"),
],
"scripted": (
"我刚做了一次自我学习。\n"
"- 学到:你希望回答简短、抓重点\n"
"- 调整:更新了长期记忆 MEMORY.md\n"
"如果不对,回复「撤销上次学习」即可。"
),
"on_edit": edit,
"expect_evolved": True,
"expect_memory_contains": "concise",
}
def scenario_memory_correction():
"""User corrects a wrong fact the agent stated -> fix MEMORY.md."""
def edit(ws):
from datetime import datetime
p = ws / "memory" / (datetime.now().strftime("%Y-%m-%d") + ".md")
p.write_text((p.read_text() if p.exists() else "") + "- Correction: team size is now 15 (was recorded as 8)\n", encoding="utf-8")
return {
"name": "记忆-纠正事实 (should note correction in daily)",
"goal": "memory",
"turns": [
("我们团队最近又招了几个人", "记得你们是 8 人的小团队,扩张了挺好的。"),
("不对,我们现在是 15 个人了,上次你就记错了", "抱歉记错了,你们现在是 15 人团队。"),
("嗯对,记住啊", "好的,记住了。"),
],
"scripted": (
"我刚做了一次自我学习。\n"
"- 学到:团队规模已是 15 人(之前记成 8 人)\n"
"- 调整:修正了长期记忆 MEMORY.md\n"
"如果不对,回复「撤销上次学习」即可。"
),
"on_edit": edit,
"expect_evolved": True,
"expect_memory_contains": "15",
}
def scenario_skill_gap():
"""Using weekly-report reveals a missing section -> patch the skill."""
def edit(ws):
p = ws / "skills" / "weekly-report" / "SKILL.md"
p.write_text(p.read_text() + "4. List risk items / blockers.\n", encoding="utf-8")
return {
"name": "技能-缺陷 (should patch weekly-report skill)",
"goal": "skill",
"turns": [
("用周报技能帮我生成这周的周报,素材:完成了支付网关重构、修复了3个线上bug、对接了风控接口",
"好的,我用 weekly-report 技能生成:\n\n## 本周进展\n- 完成支付网关重构\n- 修复 3 个线上 bug\n- 对接风控接口\n\n## 下周计划\n- 联调风控接口"),
("不错,但是每次周报都漏了风险项,我们 leader 每次都要问这个", "确实,风险项很重要。"),
("对,你能不能让这个周报技能以后都带上风险项这一节", "可以的,我把它固化进技能。"),
],
"scripted": (
"我刚做了一次自我学习。\n"
"- 学到:周报需要固定包含「风险项」一节\n"
"- 调整:给 weekly-report 技能补充了风险项步骤\n"
"如果不对,回复「撤销上次学习」即可。"
),
"on_edit": edit,
"expect_evolved": True,
"expect_skill_contains": ("weekly-report", "risk"),
}
def scenario_skill_error():
"""A skill step is wrong (currency format) -> correct the skill."""
def edit(ws):
p = ws / "skills" / "expense-tracker" / "SKILL.md"
p.write_text(p.read_text().replace("`$` prefix", "`¥` prefix (RMB)"), encoding="utf-8")
return {
"name": "技能-报错纠正 (should fix expense-tracker skill)",
"goal": "skill",
"turns": [
("用记账技能记一笔:午饭 35", "好的,已记录:午饭 $35(已写入 output/expenses.md"),
("怎么是美元?我们都是人民币啊", "抱歉,格式用错了,应该是 ¥35。"),
("对,以后这个记账技能都用人民币符号", "明白,我修正技能里的货币格式。"),
],
"scripted": (
"我刚做了一次自我学习。\n"
"- 学到:记账应使用人民币符号 ¥ 而非 $\n"
"- 调整:修正了 expense-tracker 技能的货币格式步骤\n"
"如果不对,回复「撤销上次学习」即可。"
),
"on_edit": edit,
"expect_evolved": True,
"expect_skill_contains": ("expense-tracker", "¥"),
}
def scenario_skill_wrong_config():
"""A skill's SKILL.md hardcodes a wrong endpoint; the chat works around it
at runtime. Correct evolution = FIX the skill source, not log a memory note.
"""
def edit(ws):
p = ws / "skills" / "data-fetch" / "SKILL.md"
p.write_text(
p.read_text().replace("api.example-wrong.com", "api.example-correct.com"),
encoding="utf-8",
)
return {
"name": "技能-配置错误 (should fix skill source, not log memory)",
"goal": "skill",
"turns": [
("用 data-fetch 技能拉一下最新数据",
"好的,我按技能里的步骤 POST 到 https://api.example-wrong.com/v1/fetch …… 报错了,连接失败。"),
("哦那个地址不对,正确的是 api.example-correct.com,你用这个调",
"好的,换成 https://api.example-correct.com/v1/fetch ,成功拿到数据了 ✅"),
("嗯对,就是这个地址", "明白了,这个才是正确的服务地址。"),
],
# The model must decide on its own to edit the skill; we still provide a
# scripted output for stub mode wiring.
"scripted": (
"我刚做了一次自我学习。\n"
"- 学到:data-fetch 的正确服务地址是 api.example-correct.com\n"
"- 调整:修正了 data-fetch 技能里写错的接口地址\n"
"如果不对,回复「撤销上次学习」即可。"
),
"on_edit": edit,
"expect_evolved": True,
"expect_skill_contains": ("data-fetch", "api.example-correct.com"),
}
def scenario_skill_create():
"""A reusable, repeatable workflow emerges that no skill covers, and the
user explicitly asks to make it permanent -> CREATE a new skill.
"""
return {
"name": "技能-新增 (should create a new skill)",
"goal": "skill",
"turns": [
("每周一帮我把客户反馈整理成晨会简报:先按「严重/一般/建议」给反馈分类,再统计每类数量,最后写一段 3 句话的总结,发我",
"好的,本周客户反馈晨会简报:\n\n## 严重 (2)\n- 支付失败\n- 数据丢失\n## 一般 (3)\n- 加载慢…\n## 建议 (1)\n- 增加暗色模式\n\n总结:本周以稳定性问题为主,建议优先排查支付与数据链路,其余可排期跟进。"),
("很好,就是这个格式。上周我也是这么让你弄的", "嗯,分类+计数+三句总结这套流程挺顺手。"),
("对,这个我每周一都要用,你能不能把这套流程固化下来,以后我直接说『生成客户反馈晨会简报』你就照这个来",
"可以,我把这套「客户反馈晨会简报」流程固化成一个可复用的技能。"),
],
"scripted": (
"我刚做了一次自我学习。\n"
"- 学到:你每周一都要按「分类+计数+三句总结」生成客户反馈晨会简报\n"
"- 调整:新建了「客户反馈晨会简报」技能,固化这套流程\n"
"如果不对,回复「撤销上次学习」即可。"
),
"on_edit": _create_briefing_skill,
"expect_evolved": True,
"expect_new_skill": True,
}
def scenario_skill_create_implicit():
"""A complex, clearly-structured multi-step workflow is executed once. The
user NEVER says "I do this weekly" or "make this a skill" — the agent must
判断 on its own that this is a reusable procedure worth capturing.
This is the realistic, harder case: no explicit instruction to create a
skill, only an obviously repeatable workflow.
"""
return {
"name": "技能-隐式新增 (complex workflow, user never asks to save)",
"goal": "skill",
"turns": [
("帮我做一份竞品调研:对比一下 Notion、飞书文档、语雀这三个产品",
"好的,我按一套固定方法来做竞品调研:\n1. 先确定对比维度:定价、协作能力、模板生态、API 开放度、本地化;\n2. 逐个产品按维度收集信息;\n3. 做成对比表;\n4. 给出结论和选型建议。\n\n(随后产出了完整的五维度对比表 + 结论)"),
("不错。再帮我用同样的方法调研一下 Slack、企业微信、钉钉",
"好的,沿用刚才那套方法(定价/协作/模板/API/本地化 五维度 → 收集 → 对比表 → 结论):\n\n(产出了第二份五维度对比表 + 选型建议)"),
("可以,结论挺清楚的", "嗯,这套五维度对比的方法做下来结构很清楚。"),
],
# In real mode the model decides on its own. The scripted side effect
# only wires stub mode; it emulates capturing the procedure as a skill.
"scripted": (
"我刚做了一次自我学习。\n"
"- 学到:你做竞品调研有一套固定方法(五维度对比 → 收集 → 对比表 → 结论)\n"
"- 调整:把这套竞品调研流程固化成了一个可复用技能\n"
"如果不对,回复「撤销上次学习」即可。"
),
"on_edit": _create_competitor_skill,
"expect_evolved": True,
"expect_new_skill": True,
}
def _create_competitor_skill(ws):
"""Stub side effect: emulate capturing the competitor-research procedure."""
d = ws / "skills" / "competitor-research"
d.mkdir(parents=True, exist_ok=True)
(d / "SKILL.md").write_text(
"# Competitor Research\n\n"
"Compare a set of products with a fixed methodology.\n\n"
"## Steps\n"
"1. Fix the comparison dimensions (pricing, collaboration, templates, API, localization).\n"
"2. Collect info per product across each dimension.\n"
"3. Build a comparison table.\n"
"4. Give a conclusion and recommendation.\n",
encoding="utf-8",
)
def scenario_skill_no_create():
"""A one-off, novel task with no sign of recurrence -> must NOT create a
skill (and ideally stay silent). Guards against over-eager skill creation.
"""
return {
"name": "技能-不应新增 (one-off task, must NOT create skill)",
"goal": "none",
"turns": [
("帮我把这段话翻译成英文:今晚的庆功宴改到 8 点", "翻译:The celebration dinner tonight is moved to 8 PM."),
("谢谢", "不客气。"),
("嗯没事了", "好的,随时找我。"),
],
"scripted": "[SILENT]",
"on_edit": None,
"expect_evolved": False,
"expect_no_new_skill": True,
}
def _create_briefing_skill(ws):
"""Stub side effect: emulate creating a new skill under workspace skills/."""
d = ws / "skills" / "customer-feedback-briefing"
d.mkdir(parents=True, exist_ok=True)
(d / "SKILL.md").write_text(
"# Customer Feedback Briefing\n\n"
"Turn raw customer feedback into a standup briefing.\n\n"
"## Steps\n"
"1. Classify each item as 严重/一般/建议.\n"
"2. Count items per category.\n"
"3. Write a 3-sentence summary.\n",
encoding="utf-8",
)
def scenario_unfinished_task():
"""A promised deliverable was not produced -> finish it now via tools."""
def edit(ws):
p = ws / "output" / "team-roster.md"
p.write_text("# Team Roster (backend)\n- 张伟\n- 李娜\n- 王强\n- 大锤\n", encoding="utf-8")
return {
"name": "未完成任务 (should finish & write output file)",
"goal": "task",
"turns": [
("帮我把后端团队花名册整理成一个文件保存下,成员有:张伟、李娜、王强,还有我自己(大锤)",
"好的,后端 4 个人:张伟、李娜、王强、大锤。我整理成文件保存到 output/team-roster.md。"),
("好的麻烦了,我先去开个会", "没问题,我现在就处理。"),
("(用户离开,会话中断,文件尚未写入)", "(助手未及写入文件,对话中断)"),
],
"scripted": (
"我刚做了一次自我学习。\n"
"- 发现:之前答应整理团队花名册但没完成\n"
"- 已完成:把后端成员名单写入 output/team-roster.md\n"
"如果不需要,回复「撤销上次学习」即可。"
),
"on_edit": edit,
"expect_evolved": True,
"expect_output_file": "team-roster.md",
}
SCENARIOS = [
scenario_silent,
scenario_silent_qa,
scenario_silent_transient,
scenario_silent_advice,
scenario_memory_preference,
scenario_memory_correction,
scenario_skill_gap,
scenario_skill_error,
scenario_skill_wrong_config,
scenario_skill_create,
scenario_skill_create_implicit,
scenario_skill_no_create,
scenario_unfinished_task,
]
# Skill directories present in a fresh workspace; anything beyond these that
# appears after a pass is a newly-created skill.
_SEED_SKILLS = {"weekly-report", "expense-tracker", "data-fetch", "image-generation"}
def _new_skill_dirs(ws: Path) -> set:
"""Skill directories created beyond the seeded set."""
skills_dir = ws / "skills"
if not skills_dir.exists():
return set()
return {p.name for p in skills_dir.iterdir() if p.is_dir()} - _SEED_SKILLS
# ---------------------------------------------------------------------------
# Runner (stub mode)
# ---------------------------------------------------------------------------
def run_stub():
from agent.evolution.executor import run_evolution_for_session
from agent.evolution import backup as backup_mod
from config import conf
# Evolution is disabled by default now; enable for the test.
conf()["self_evolution_enabled"] = True
passed, failed = 0, 0
for make in SCENARIOS:
sc = make()
ws = _setup_workspace()
try:
_point_config_at(ws)
# Patch channel push to capture instead of send.
channel = FakeChannel()
import agent.evolution.executor as ex
orig_notify = ex._notify_user
ex._notify_user = lambda ct, rcv, summary: channel.send(
type("R", (), {"content": summary})(),
{"receiver": rcv},
)
agent = FakeAgent(_make_messages(sc["turns"]))
bridge = FakeAgentBridge(agent, sc["scripted"], on_edit=sc["on_edit"])
evolved = run_evolution_for_session(
bridge, "session_test", channel_type="telegram", receiver="user_42"
)
ok = True
errs = []
if evolved != sc["expect_evolved"]:
ok = False
errs.append(f"evolved={evolved}, expected {sc['expect_evolved']}")
if sc["expect_evolved"]:
# memory / skill content checks
if "expect_memory_contains" in sc:
# Evolution now writes to the dated daily file, not MEMORY.md.
from datetime import datetime
daily = ws / "memory" / (datetime.now().strftime("%Y-%m-%d") + ".md")
mem = daily.read_text() if daily.exists() else ""
if sc["expect_memory_contains"] not in mem:
ok = False
errs.append("daily memory missing expected content")
if "expect_skill_contains" in sc:
sk, txt = sc["expect_skill_contains"]
content = (ws / "skills" / sk / "SKILL.md").read_text()
if txt not in content:
ok = False
errs.append("skill missing expected content")
if sc.get("expect_new_skill") and not _new_skill_dirs(ws):
ok = False
errs.append("expected a new skill to be created")
# notify happened
if not channel.sent:
ok = False
errs.append("no notification sent")
# injection happened (undo support)
if not bridge.injected or "[EVOLUTION]" not in bridge.injected[0]:
ok = False
errs.append("no [EVOLUTION] record injected")
# protected skill untouched
prot = (ws / "skills" / "image-generation" / "SKILL.md").read_text()
if prot != "# Image Generation (built-in)\nDo not modify.\n":
ok = False
errs.append("PROTECTED skill was modified!")
# backup exists (undo possible)
backups = list((ws / "memory" / ".evolution_backups").glob("*"))
if not backups:
ok = False
errs.append("no backup created")
else:
# SILENT: nothing should have changed / been sent
if channel.sent:
ok = False
errs.append("notification sent on SILENT")
if bridge.injected:
ok = False
errs.append("injected record on SILENT")
if sc.get("expect_no_new_skill") and _new_skill_dirs(ws):
ok = False
errs.append(f"unexpected new skill created: {_new_skill_dirs(ws)}")
ex._notify_user = orig_notify
if ok:
passed += 1
print(f" PASS {sc['name']}")
else:
failed += 1
print(f" FAIL {sc['name']}: {'; '.join(errs)}")
finally:
shutil.rmtree(ws, ignore_errors=True)
# Undo verification (uses the memory scenario's backup path).
print("\n-- undo tool --")
_verify_undo()
print(f"\nStub results: {passed} passed, {failed} failed")
return failed == 0
def _verify_undo():
from agent.evolution.backup import create_backup, restore_backup
ws = _setup_workspace()
try:
_point_config_at(ws)
mem = ws / "MEMORY.md"
bid = create_backup(ws, [mem])
mem.write_text("CORRUPTED", encoding="utf-8")
from agent.tools.evolution_undo import EvolutionUndoTool
r = EvolutionUndoTool().execute({"backup_id": bid})
restored = mem.read_text()
if r.status == "success" and "大锤" in restored:
print(" PASS undo restores pre-evolution state")
else:
print(f" FAIL undo: status={r.status}, content={restored[:40]}")
finally:
shutil.rmtree(ws, ignore_errors=True)
# ---------------------------------------------------------------------------
# Runner (real mode) — minimal: just prints the model's decision per scenario.
# ---------------------------------------------------------------------------
def _snapshot_ws(ws: Path) -> dict:
"""Map every text file under the workspace -> content (skip backups dir)."""
snap = {}
for p in ws.rglob("*"):
if not p.is_file():
continue
rel = str(p.relative_to(ws))
if rel.startswith("memory/.evolution_backups"):
continue
try:
snap[rel] = p.read_text(encoding="utf-8")
except Exception:
pass
return snap
def _print_diff(before: dict, after: dict) -> bool:
"""Print added/changed files. Returns True if anything changed."""
changed = False
keys = sorted(set(before) | set(after))
for rel in keys:
old = before.get(rel)
new = after.get(rel)
if old == new:
continue
changed = True
tag = "NEW FILE" if old is None else "CHANGED"
print(f" ~ {rel} [{tag}]")
old_lines = set((old or "").splitlines())
for line in (new or "").splitlines():
if line not in old_lines:
print(f" + {line}")
return changed
def run_real():
"""Run real model evolution on each scenario and print the actual output.
Uses config.json's configured model via a real AgentBridge, so you see
exactly what the model decides and writes for each conversation.
"""
from bridge.bridge import Bridge
from agent.memory.config import (
MemoryConfig,
set_global_memory_config,
get_default_memory_config,
)
from config import conf, load_config
# Load config.json so real API keys are available to the bots.
load_config()
# Default the test to deepseek-v4-flash (fast, low cost) unless overridden.
override_model = os.environ.get("EVO_TEST_MODEL", "deepseek-v4-flash")
conf()["model"] = override_model
conf()["bot_type"] = os.environ.get("EVO_TEST_BOT_TYPE", "deepseek")
# Force-enable evolution for the test regardless of config.json default.
conf()["self_evolution_enabled"] = True
print(f"[test] model: {override_model} (bot_type={conf().get('bot_type')}, "
f"key={'set' if conf().get('deepseek_api_key') else 'MISSING'})")
from agent.memory.manager import MemoryManager
import agent.evolution.executor as ex
bridge = Bridge()
agent_bridge = bridge.get_agent_bridge()
# Capture the user-facing reply instead of pushing it to a channel.
captured = {"reply": None}
orig_notify = ex._notify_user
ex._notify_user = lambda ct, rcv, summary: captured.__setitem__("reply", summary)
results = [] # (name, goal, evolved, changed, reply_ok)
only = os.environ.get("EVO_TEST_ONLY") # substring filter on goal/name
try:
for make in SCENARIOS:
sc = make()
if only and only not in sc["goal"] and only not in sc["name"]:
continue
ws = _setup_workspace()
captured["reply"] = None
try:
mem_cfg = MemoryConfig(workspace_root=str(ws))
set_global_memory_config(mem_cfg)
sid = "session_evo_real"
# Fully isolated agent: tool cwd + memory_manager -> temp ws.
iso_mem = MemoryManager(mem_cfg)
agent = agent_bridge.create_agent(
system_prompt="You are a helpful assistant.",
tools=None,
workspace_dir=str(ws),
memory_manager=iso_mem,
enable_skills=False,
)
# Notify path needs a channel+receiver to fire; give dummies.
agent_bridge.agents[sid] = agent
with agent.messages_lock:
agent.messages.clear()
agent.messages.extend(_make_messages(sc["turns"]))
before = _snapshot_ws(ws)
print("\n" + "=" * 72)
print(f"场景: {sc['name']} [目标: {sc['goal']}]")
print("-" * 72)
print("【会话输入】")
for u, a in sc["turns"]:
print(f" 用户: {u}")
print(f" 助手: {a}")
from agent.evolution.executor import run_evolution_for_session
evolved = run_evolution_for_session(
agent_bridge, sid, channel_type="telegram", receiver="tester"
)
after = _snapshot_ws(ws)
print("\n【进化结果】 evolved =", evolved)
changed = False
if evolved:
changed = _print_diff(before, after)
if not changed:
print(" (无文件变更)")
else:
print(" (静默,未做任何改动)")
new_skills = _new_skill_dirs(ws)
if new_skills:
print(f" 新建技能: {', '.join(sorted(new_skills))}")
# Surface mismatches against the scenario's skill expectation.
if sc.get("expect_new_skill") and not new_skills:
print(" ⚠ 预期新建技能,但未创建")
if sc.get("expect_no_new_skill") and new_skills:
print(" ⚠ 不应新建技能,但创建了")
print("\n【给用户的回复】")
if captured["reply"]:
for line in captured["reply"].splitlines():
print(f" {line}")
else:
print(" (无推送)")
reply_ok = bool(captured["reply"]) == bool(evolved)
results.append((sc["name"], sc["goal"], evolved, changed, reply_ok))
agent_bridge.agents.pop(sid, None)
finally:
shutil.rmtree(ws, ignore_errors=True)
finally:
ex._notify_user = orig_notify
# Summary table.
print("\n" + "=" * 72)
print("汇总 (deepseek-v4-flash 真实运行)")
print("-" * 72)
for name, goal, evolved, changed, reply_ok in results:
exp = "静默" if goal == "none" else "应进化"
got = "进化" if evolved else "静默"
mark = "✓" if (goal == "none") != evolved else "✗"
print(f" {mark} {name:42s} 预期={exp} 实际={got}")
if __name__ == "__main__":
if "--debug" in sys.argv:
import logging
from common.log import logger as _cow_logger
_cow_logger.setLevel(logging.DEBUG)
for _h in _cow_logger.handlers:
_h.setLevel(logging.DEBUG)
if "--real" in sys.argv:
run_real()
else:
ok = run_stub()
sys.exit(0 if ok else 1)