857 lines
35 KiB
Python
857 lines
35 KiB
Python
"""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)
|