Files
chatgpt-on-wechat/tests/test_evolution.py
zhayujie ba777ed706 feat(evolution): add self-evolution subsystem
Add a self-evolution subsystem that reviews idle conversations in an
isolated agent and durably learns from them — patching/creating skills,
finishing unfinished tasks, and backfilling missed memory.

- Trigger: background idle scan, fires when a session is idle >= N min AND
  (>= N turns OR context usage > 80%). In-memory cursor reviews only new
  messages so a session never re-learns old content.
- Isolated review agent: same model, restricted toolset, hard write-guard
  confining edits to the workspace (built-in skills are protected).
- Safety: file-level backup before edits + evolution_undo tool; notify the
  user ONLY when a workspace file actually changed (no-nag rule); capped
  concurrency.
- Records to memory/evolution/<date>.md, surfaced in the memory UI's
  renamed "Self-Evolution" tab (merged with dream diaries).
- Hide internal [SCHEDULED]/[EVOLUTION]/backup_id markers from chat history
  display (also fixes scheduler marker leakage) while keeping them in stored
  content for undo.
- Flat config: self_evolution_enabled (default off until release),
  self_evolution_idle_minutes (15), self_evolution_min_turns (6).
- Tests: tests/test_evolution.py (stub + real model modes, 7 scenarios).
2026-06-07 18:55:33 +08:00

661 lines
26 KiB
Python
Raw 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_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_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_memory_preference,
scenario_memory_correction,
scenario_skill_gap,
scenario_skill_error,
scenario_skill_wrong_config,
scenario_unfinished_task,
]
# ---------------------------------------------------------------------------
# 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")
# 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")
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(" (静默,未做任何改动)")
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 "--real" in sys.argv:
run_real()
else:
ok = run_stub()
sys.exit(0 if ok else 1)