Files
deerflow-code/offline-backend-20260512/backend/tests/test_skill_evolution_runtime.py
T
xuhdandClaude Opus 4.7 9b6b94d919 feat(skills): 自我进化后端 — curator 配置可编辑 + 运行日志可读
后端骨架:
- curator.py / usage.py / skill_tools.py:基于使用计数的生命周期管理
  + LLM 合并/归档建议(同事已有原型,集中提交)
- skill_evolution_config.py:CuratorConfig 嵌套模型
- app.py:启动时按间隔触发 curator 后台线程
- tools.py:开启自我进化时一并注册 skill_list / skill_view 工具
- skill_manage_tool.py:agent 创建技能时标记 source=agent

本次新增:
- skill_evolution_runtime.py:skill_evolution.json 读写,原子写入
- app_config.py:把 overrides 深合并到 AppConfig.skill_evolution,
  并把 overrides 的 mtime 纳入缓存失效条件,前端 PUT 后热生效
- curator_log.py:进程内 ring buffer (maxlen=200),所有 record/get
  操作内部兜异常,保证 logging 永远不会让 curator 跑挂
- curator.py 关键节点插桩:run_started / 自动转换计数 / 候选数 /
  LLM agent invoke / 解析结果 / archive 动作 / run complete
- routers/skills.py:GET/PUT /api/curator/config(写到
  skill_evolution.json + reset_app_config)+ GET /api/curator/logs

测试:
- tests/test_skill_evolution_runtime.py:overrides 往返、损坏 JSON
  容错、AppConfig 合并、ring buffer 线程安全、record 异常吞咽

杂项:
- .gitignore:忽略 .deer-flow/* 与 skills/* 等运行时目录
- uv.lock:跟随依赖锁更新
- 清理已废弃的 agent runtime 数据 SOUL.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:00:21 +08:00

119 lines
3.6 KiB
Python

from __future__ import annotations
import json
import threading
import pytest
from deerflow.config import skill_evolution_runtime
from deerflow.skills import curator_log
@pytest.fixture
def overrides_path(tmp_path, monkeypatch):
"""Redirect skill_evolution_runtime.resolve_path at a fresh temp file."""
target = tmp_path / "skill_evolution.json"
monkeypatch.setenv("DEER_FLOW_SKILL_EVOLUTION_CONFIG_PATH", str(target))
yield target
def test_load_overrides_returns_none_when_missing(overrides_path):
assert not overrides_path.exists()
assert skill_evolution_runtime.load_overrides() is None
def test_save_and_load_overrides_roundtrip(overrides_path):
payload = {
"enabled": True,
"moderation_model_name": "moderation-mini",
"curator": {
"enabled": True,
"interval_hours": 12,
"stale_after_days": 7,
"archive_after_days": 30,
},
}
written = skill_evolution_runtime.save_overrides(payload)
assert written == overrides_path
assert overrides_path.exists()
assert skill_evolution_runtime.load_overrides() == payload
def test_load_overrides_handles_invalid_json(overrides_path):
overrides_path.write_text("{not valid json", encoding="utf-8")
assert skill_evolution_runtime.load_overrides() is None
def test_load_overrides_rejects_non_object_root(overrides_path):
overrides_path.write_text(json.dumps([1, 2, 3]), encoding="utf-8")
assert skill_evolution_runtime.load_overrides() is None
def test_app_config_merges_overrides_on_from_file(overrides_path, monkeypatch, tmp_path):
yaml_cfg = tmp_path / "config.yaml"
yaml_cfg.write_text(
"""
sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider
skill_evolution:
enabled: false
curator:
interval_hours: 168
stale_after_days: 30
""".strip(),
encoding="utf-8",
)
# Overrides flip enabled and change interval_hours.
skill_evolution_runtime.save_overrides(
{
"enabled": True,
"curator": {"interval_hours": 24},
}
)
from deerflow.config.app_config import AppConfig
cfg = AppConfig.from_file(str(yaml_cfg))
assert cfg.skill_evolution.enabled is True
assert cfg.skill_evolution.curator.interval_hours == 24
# Untouched yaml values remain.
assert cfg.skill_evolution.curator.stale_after_days == 30
def test_curator_log_start_new_run_resets_and_seeds_record():
curator_log.clear()
curator_log.record("info", "old record")
assert len(curator_log.get_recent()) == 1
curator_log.start_new_run()
records = curator_log.get_recent()
assert len(records) == 1
assert records[0]["message"] == "run_started"
assert records[0]["level"] == "info"
def test_curator_log_record_is_thread_safe():
curator_log.clear()
def writer(idx: int) -> None:
for i in range(20):
curator_log.record("info", f"thread-{idx}-{i}")
threads = [threading.Thread(target=writer, args=(i,)) for i in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
records = curator_log.get_recent()
# 5 threads * 20 records each = 100, well under the 200 ring-buffer cap.
assert len(records) == 100
assert all("message" in r and "ts" in r for r in records)
def test_curator_log_record_ignores_internal_errors(monkeypatch):
# Even if the timestamp helper blows up, record() must not raise.
monkeypatch.setattr(curator_log, "_now_iso", lambda: (_ for _ in ()).throw(RuntimeError("boom")))
curator_log.record("info", "should not raise")