调度健壮性:
- 技能进化总开关 skill_evolution.enabled 真正生效;CuratorConfig
与 CuratorSubConfig 默认 enabled 改为 false(显式 opt-in)
- 新增 per-user 运行锁(.curator_state 的 running/started_at),
防定时器与手动触发并发重复运行;6h stale 检测防进程残留死锁
- maybe_run_curator 改用 ThreadPoolExecutor 限并发(上限 5),
防首次部署所有用户同时到期的惊群
- 首次运行持续失败时按 6h 退避重试,不再每个 tick 烧 LLM 配额
- should_run_now 时间解析容忍 naive 时间串;损坏的 last_run_at
触发重跑而非永久死锁
- SkillStore 新增 list_published_names 批量接口,curator 一轮
只查一次 published,取代逐技能查库
LLM 评审超时:
- _run_llm_review 给 agent.invoke 套整体超时
(curator.review_timeout_seconds,默认 600s),超时中止本次评审
并在 curator 日志输出,curator 正常收尾、释放运行锁
- copy_context 把 user 上下文传入工作线程
模型可配置:
- 新增 curator.model_name;curator 评审与技能安全审核共用该模型,
未配置则回退主模型
- 移除独立的 moderation_model_name 配置
- 前端进化面板新增模型下拉选择器
手动触发体验:
- /api/curator/run 派发后台线程前先预检,返回 {triggered, reason},
不再无论开关状态都提示"已开始"
- 前端:总开关/curator 开关未开时禁用"立即执行"按钮,
被预检拦下时按原因(开关未开 / 已有任务在跑)给出准确提示
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
133 lines
4.8 KiB
Python
133 lines
4.8 KiB
Python
"""In-memory ``SkillStore`` implementation for tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from deerflow.persistence.skills.base import SkillStore
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(UTC).isoformat()
|
|
|
|
|
|
def _copy(record: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
return copy.deepcopy(record) if record is not None else None
|
|
|
|
|
|
def _with_derived(record: dict[str, Any]) -> dict[str, Any]:
|
|
out = dict(record)
|
|
out["builtin"] = out.get("owner_user_id") is None
|
|
return out
|
|
|
|
|
|
class MemorySkillStore(SkillStore):
|
|
def __init__(self) -> None:
|
|
self._skills: dict[str, dict[str, Any]] = {}
|
|
|
|
@staticmethod
|
|
def _is_visible(record: dict[str, Any], user_id: str) -> bool:
|
|
owner = record.get("owner_user_id")
|
|
if owner is None:
|
|
return True
|
|
if owner == user_id:
|
|
return True
|
|
return bool(record.get("published"))
|
|
|
|
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
|
|
return [
|
|
_with_derived(_copy(record)) # type: ignore[arg-type]
|
|
for record in self._skills.values()
|
|
if self._is_visible(record, user_id)
|
|
]
|
|
|
|
async def get_visible(self, name: str, user_id: str) -> dict[str, Any] | None:
|
|
record = self._skills.get(name)
|
|
if record is None or not self._is_visible(record, user_id):
|
|
return None
|
|
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
|
|
|
async def get_any(self, name: str) -> dict[str, Any] | None:
|
|
record = self._skills.get(name)
|
|
if record is None:
|
|
return None
|
|
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
|
|
|
async def get_owned(self, name: str, user_id: str) -> dict[str, Any] | None:
|
|
record = self._skills.get(name)
|
|
if record is None or record.get("owner_user_id") != user_id:
|
|
return None
|
|
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
|
|
|
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
name = data["name"]
|
|
if name in self._skills:
|
|
raise ValueError(f"Skill '{name}' already exists")
|
|
record = {
|
|
"name": name,
|
|
"owner_user_id": data.get("owner_user_id"),
|
|
"published": bool(data.get("published", False)),
|
|
"created_at": _now(),
|
|
"updated_at": _now(),
|
|
}
|
|
self._skills[name] = record
|
|
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
|
|
|
async def ensure_legacy(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
name = data["name"]
|
|
record = self._skills.get(name)
|
|
if record is None:
|
|
record = {
|
|
"name": name,
|
|
"owner_user_id": data.get("owner_user_id"),
|
|
"published": bool(data.get("published", True)),
|
|
"created_at": _now(),
|
|
"updated_at": _now(),
|
|
}
|
|
self._skills[name] = record
|
|
elif record.get("owner_user_id") is None and data.get("owner_user_id") is not None:
|
|
record["owner_user_id"] = data["owner_user_id"]
|
|
record["updated_at"] = _now()
|
|
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
|
|
|
async def update(self, name: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
|
record = self._skills.get(name)
|
|
if record is None or record.get("owner_user_id") != user_id:
|
|
return None
|
|
if "published" in data:
|
|
record["published"] = bool(data["published"])
|
|
record["updated_at"] = _now()
|
|
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
|
|
|
async def update_admin(self, name: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
|
record = self._skills.get(name)
|
|
if record is None:
|
|
return None
|
|
if "published" in data:
|
|
record["published"] = bool(data["published"])
|
|
if "owner_user_id" in data:
|
|
record["owner_user_id"] = data["owner_user_id"]
|
|
record["updated_at"] = _now()
|
|
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
|
|
|
async def delete(self, name: str, user_id: str) -> bool:
|
|
record = self._skills.get(name)
|
|
if record is None or record.get("owner_user_id") != user_id:
|
|
return False
|
|
del self._skills[name]
|
|
return True
|
|
|
|
async def delete_admin(self, name: str) -> bool:
|
|
if name not in self._skills:
|
|
return False
|
|
del self._skills[name]
|
|
return True
|
|
|
|
async def count_owned(self, user_id: str) -> int:
|
|
return sum(1 for s in self._skills.values() if s.get("owner_user_id") == user_id)
|
|
|
|
async def list_published_names(self) -> set[str]:
|
|
return {name for name, record in self._skills.items() if record.get("published")}
|