调度健壮性:
- 技能进化总开关 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>
1193 lines
51 KiB
Python
1193 lines
51 KiB
Python
"""Skill curator — automatic lifecycle transitions and LLM consolidation pass.
|
|
|
|
Per-user since the multitenancy upgrade: every public function takes a
|
|
``user_id`` argument and the on-disk state lives under
|
|
``{base_dir}/users/{user_id}/.curator_state`` (alongside the per-user
|
|
memory.json and threads/ dirs).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import concurrent.futures
|
|
import contextvars
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import tempfile
|
|
import threading
|
|
from collections.abc import Coroutine
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any, TypeVar
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_CURATOR_STATE_FILENAME = ".curator_state"
|
|
|
|
# 周期调度一轮里同时执行的 curator 数量上限。到期用户多时(尤其首次部署所有用户一起
|
|
# 到期)用线程池排队消费,避免一次性起 N 个线程齐调 LLM 打爆 provider 速率限制。
|
|
_CURATOR_MAX_CONCURRENCY = 5
|
|
|
|
_T = TypeVar("_T")
|
|
|
|
|
|
def _run_async_blocking(coro: Coroutine[Any, Any, _T]) -> _T:
|
|
"""Run an async coroutine to completion from sync curator code.
|
|
|
|
Curator is invoked from a background thread, but ``agent.invoke()`` inside
|
|
``_run_llm_review`` (LangChain 1.x ``create_agent``) spins up its own
|
|
asyncio loop which can leave the calling thread in an "already has a
|
|
running loop" state. In that case raw ``asyncio.run(coro)`` raises
|
|
``RuntimeError`` *after* the coroutine object has been materialised but
|
|
before anyone awaits it — producing the ``coroutine was never awaited``
|
|
RuntimeWarning that pollutes our logs.
|
|
|
|
This helper detects a running loop in the caller's thread and offloads
|
|
to a dedicated worker thread; otherwise it falls back to ``asyncio.run``.
|
|
Either way it guarantees the coroutine is closed on the failure path,
|
|
so no RuntimeWarning ever escapes.
|
|
"""
|
|
try:
|
|
asyncio.get_running_loop()
|
|
in_loop = True
|
|
except RuntimeError:
|
|
in_loop = False
|
|
|
|
if in_loop:
|
|
try:
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
return pool.submit(asyncio.run, coro).result()
|
|
except BaseException:
|
|
# ``asyncio.run`` ran in the worker (it consumes the coroutine on
|
|
# success or surfaces the inner exception); but if the executor
|
|
# itself failed to submit, close to be safe.
|
|
try:
|
|
coro.close()
|
|
except Exception:
|
|
pass
|
|
raise
|
|
|
|
try:
|
|
return asyncio.run(coro)
|
|
except BaseException:
|
|
try:
|
|
coro.close()
|
|
except Exception:
|
|
pass
|
|
raise
|
|
|
|
|
|
def _get_skills_root() -> Path:
|
|
from deerflow.skills.storage import get_or_new_skill_storage
|
|
|
|
return Path(get_or_new_skill_storage().get_skills_root_path())
|
|
|
|
|
|
def _curator_state_path(user_id: str | None) -> Path:
|
|
"""Return the state file path. Per-user when user_id is known; falls back
|
|
to the global ``skills/.curator_state`` for legacy callers."""
|
|
if not user_id:
|
|
return _get_skills_root() / _CURATOR_STATE_FILENAME
|
|
from deerflow.config.paths import get_paths
|
|
|
|
return get_paths().user_dir(user_id) / _CURATOR_STATE_FILENAME
|
|
|
|
|
|
def _read_curator_state(user_id: str | None) -> dict:
|
|
path = _curator_state_path(user_id)
|
|
if not path.exists():
|
|
return {"run_count": 0, "paused": False}
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return {"run_count": 0, "paused": False}
|
|
|
|
|
|
def _write_curator_state(state: dict, user_id: str | None) -> None:
|
|
path = _curator_state_path(user_id)
|
|
try:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp_fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
|
|
json.dump(state, f, indent=2, default=str)
|
|
os.replace(tmp_path, path)
|
|
except Exception:
|
|
try:
|
|
os.unlink(tmp_path)
|
|
except Exception:
|
|
pass
|
|
raise
|
|
except Exception:
|
|
logger.debug("Failed to write curator state", exc_info=True)
|
|
|
|
|
|
def _parse_state_time(value: str | None) -> datetime | None:
|
|
"""解析 ``.curator_state`` 里的 ISO 时间字符串,返回 aware datetime(UTC)。
|
|
|
|
naive(无时区)字符串按 UTC 补全 —— 与 ``apply_automatic_transitions`` 的处理
|
|
一致,避免 naive 时间串与 ``datetime.now(UTC)`` 直接比较时抛异常。无法解析时
|
|
返回 None,由调用方决定降级行为。
|
|
"""
|
|
if not value:
|
|
return None
|
|
try:
|
|
dt = datetime.fromisoformat(value)
|
|
except Exception:
|
|
return None
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=UTC)
|
|
return dt
|
|
|
|
|
|
# Curator 运行锁:防止同一用户的 curator 并发执行(定时器 vs 手动触发、连点手动触发)。
|
|
# 锁状态写进 .curator_state 的 running/started_at 字段,跨 worker 可见(手动请求可能
|
|
# 落到非调度 worker)。started_at 超过下面的阈值视为进程残留,允许接管以免永久死锁。
|
|
_CURATOR_STALE_LOCK_HOURS = 6
|
|
# 上一轮"尝试"过但未成功(last_run_at 始终为空)时的重试间隔 —— 比正常 interval_hours
|
|
# 短得多,但也不至于每个调度 tick(1h)都重试、每次都烧一份 LLM 配额。
|
|
_CURATOR_RETRY_HOURS = 6
|
|
# 进程内串行化运行锁的"读-改-写",保证同进程内的抢锁是原子的(跨进程的极小竞态窗口
|
|
# 可接受 —— curator 触发频率极低)。
|
|
_run_lock_guard = threading.Lock()
|
|
|
|
|
|
def _run_lock_active(state: dict, now: datetime) -> bool:
|
|
"""判断 ``.curator_state`` 里的运行锁当前是否有效(running 且未过期)。
|
|
|
|
``started_at`` 缺失或不可解析、或距今已超过 ``_CURATOR_STALE_LOCK_HOURS`` 时,
|
|
视为进程残留留下的死锁,返回 False 以便后续触发能接管。
|
|
"""
|
|
if not state.get("running"):
|
|
return False
|
|
started_dt = _parse_state_time(state.get("started_at"))
|
|
if started_dt is None:
|
|
return False
|
|
return now < started_dt + timedelta(hours=_CURATOR_STALE_LOCK_HOURS)
|
|
|
|
|
|
def _try_acquire_run_lock(user_id: str, now: datetime) -> bool:
|
|
"""尝试为 ``user_id`` 抢占 curator 运行锁。
|
|
|
|
抢到锁(已把 running=True / started_at 写回)返回 True;已有未过期的运行时返回
|
|
False。读-改-写在 ``_run_lock_guard`` 下进行,保证同进程内原子。
|
|
"""
|
|
with _run_lock_guard:
|
|
state = _read_curator_state(user_id)
|
|
if _run_lock_active(state, now):
|
|
return False
|
|
state["running"] = True
|
|
state["started_at"] = now.isoformat()
|
|
_write_curator_state(state, user_id)
|
|
return True
|
|
|
|
|
|
def _release_run_lock(user_id: str) -> None:
|
|
"""释放 curator 运行锁并记录本次尝试时间。无论运行成功或异常都应调用。
|
|
|
|
除了把 running 置回 False,还写入 ``last_attempt_at`` —— 用于失败退避:首次运行
|
|
若一直崩溃,``last_run_at`` 永远为空,``should_run_now`` 改用
|
|
``last_attempt_at + _CURATOR_RETRY_HOURS`` 判定,避免每个 tick 都重试。
|
|
成功跑完后 ``last_run_at`` 已被写入,``should_run_now`` 不再看 ``last_attempt_at``。
|
|
"""
|
|
with _run_lock_guard:
|
|
state = _read_curator_state(user_id)
|
|
state["running"] = False
|
|
state["last_attempt_at"] = datetime.now(UTC).isoformat()
|
|
_write_curator_state(state, user_id)
|
|
|
|
|
|
def _get_curator_config(user_id: str | None = None):
|
|
"""Return the curator config object for ``user_id``.
|
|
|
|
Reads ``config.yaml.skill_evolution.curator`` as the baseline and merges
|
|
the per-user override file (``users/{user_id}/skill_evolution.json``) on
|
|
top. With ``user_id=None`` falls back to the global default.
|
|
"""
|
|
try:
|
|
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user
|
|
|
|
skill_evo = load_skill_evolution_config_for_user(user_id)
|
|
return getattr(skill_evo, "curator", None)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _skill_evolution_enabled(user_id: str | None = None) -> bool:
|
|
"""返回技能进化总开关 ``skill_evolution.enabled``(已合并 per-user 覆盖)。
|
|
|
|
这是整套技能进化功能的总闸:关闭时 curator 完全不动作。任何读取异常都按
|
|
"关闭"处理(fail-safe)—— 宁可不跑,也不在配置不可读时偷偷运行。
|
|
"""
|
|
try:
|
|
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user
|
|
|
|
skill_evo = load_skill_evolution_config_for_user(user_id)
|
|
return bool(getattr(skill_evo, "enabled", False))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _list_user_owned_skill_names(user_id: str) -> set[str] | None:
|
|
"""Return skill names owned by ``user_id`` (used to filter transitions).
|
|
|
|
Returns None if the SkillStore is unreachable — curator then degrades to
|
|
"process every on-disk agent-created skill" (legacy behaviour).
|
|
"""
|
|
try:
|
|
from deerflow.persistence.engine import get_session_factory
|
|
from deerflow.persistence.skills import make_skill_store
|
|
|
|
factory = get_session_factory()
|
|
if factory is None:
|
|
return None
|
|
store = make_skill_store(factory)
|
|
|
|
async def _run() -> list[dict]:
|
|
return await store.list_visible(user_id)
|
|
|
|
records = _run_async_blocking(_run())
|
|
return {r["name"] for r in records if r.get("owner_user_id") == user_id}
|
|
except Exception:
|
|
logger.debug("_list_user_owned_skill_names failed", exc_info=True)
|
|
return None
|
|
|
|
|
|
def _collect_referenced_skill_names() -> set[str]:
|
|
"""Scan every agent's config.yaml and return the union of their ``skills``
|
|
fields.
|
|
|
|
Used as the "do not touch" set during curator transitions and LLM
|
|
candidate selection — any skill currently bound to *any* agent
|
|
(including the owner's own) is exempt from automatic stale/archive
|
|
and from being suggested for merging.
|
|
|
|
Returns an empty set on filesystem errors (best-effort, non-fatal).
|
|
"""
|
|
import yaml
|
|
|
|
from deerflow.config.paths import get_paths
|
|
|
|
referenced: set[str] = set()
|
|
try:
|
|
agents_dir = get_paths().agents_dir
|
|
except Exception:
|
|
return referenced
|
|
if not agents_dir.exists():
|
|
return referenced
|
|
|
|
for entry in agents_dir.iterdir():
|
|
if not entry.is_dir():
|
|
continue
|
|
config_file = entry / "config.yaml"
|
|
if not config_file.exists():
|
|
continue
|
|
try:
|
|
with config_file.open("r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f) or {}
|
|
except Exception:
|
|
logger.debug("_collect_referenced_skill_names: failed to parse %s", config_file, exc_info=True)
|
|
continue
|
|
skills = data.get("skills")
|
|
if isinstance(skills, list):
|
|
for s in skills:
|
|
if isinstance(s, str):
|
|
referenced.add(s)
|
|
return referenced
|
|
|
|
|
|
def _is_skill_published(skill_name: str) -> bool:
|
|
"""Return True if ``skill_name`` is marked ``published=True`` in SkillStore.
|
|
|
|
Best-effort — returns False on any error so a transient DB hiccup never
|
|
causes a published skill to be wrongly archived (the caller treats
|
|
"published=True" as exempt, so False = "fail open to processing", which
|
|
only puts a skill *back* into curator scope when DB is unavailable; in
|
|
that degraded mode curator legacy behaviour applies).
|
|
"""
|
|
try:
|
|
from deerflow.persistence.engine import get_session_factory
|
|
from deerflow.persistence.skills import make_skill_store
|
|
|
|
factory = get_session_factory()
|
|
if factory is None:
|
|
return False
|
|
store = make_skill_store(factory)
|
|
|
|
async def _run() -> dict | None:
|
|
return await store.get_any(skill_name)
|
|
|
|
record = _run_async_blocking(_run())
|
|
return bool(record and record.get("published", False))
|
|
except Exception:
|
|
logger.debug("_is_skill_published(%s) failed", skill_name, exc_info=True)
|
|
return False
|
|
|
|
|
|
def _list_published_skill_names() -> set[str]:
|
|
"""一次性返回所有 ``published=True`` 的技能名集合。
|
|
|
|
取代"每个技能调一次 ``_is_skill_published``"的逐条查库 —— curator 一轮里会遍历
|
|
所有候选技能,逐条查会产生 ≈2N 次独立 event loop + DB 往返。出错时返回空集
|
|
(fail-open:与 ``_is_skill_published`` 出错返回 False 语义一致,即 DB 不可用时
|
|
所有技能都视为未发布,回退到 curator 的 legacy 行为)。
|
|
"""
|
|
try:
|
|
from deerflow.persistence.engine import get_session_factory
|
|
from deerflow.persistence.skills import make_skill_store
|
|
|
|
factory = get_session_factory()
|
|
if factory is None:
|
|
return set()
|
|
store = make_skill_store(factory)
|
|
|
|
async def _run() -> set[str]:
|
|
return await store.list_published_names()
|
|
|
|
return _run_async_blocking(_run())
|
|
except Exception:
|
|
logger.debug("_list_published_skill_names failed", exc_info=True)
|
|
return set()
|
|
|
|
|
|
def _eligible_for_curation(
|
|
name: str,
|
|
entry: dict,
|
|
*,
|
|
owned_set: set[str] | None,
|
|
referenced_set: set[str],
|
|
published_set: set[str],
|
|
) -> bool:
|
|
"""Three-condition gate for whether curator may touch a skill.
|
|
|
|
Curator only acts on skills that ALL of:
|
|
1. source == "agent" (caller is expected to have already filtered, but
|
|
we double-check here for safety)
|
|
2. NOT referenced by any agent's skills config
|
|
3. NOT published
|
|
|
|
``owned_set`` is the set of skill names owned by the running user. When
|
|
None, the legacy "process every agent-created skill" fallback applies
|
|
for ownership only (the other two conditions still hold).
|
|
|
|
``published_set`` is the set of published skill names, pre-fetched once by
|
|
the caller via ``_list_published_skill_names()`` — avoids a per-skill DB
|
|
round-trip.
|
|
"""
|
|
if entry.get("source") != "agent":
|
|
return False
|
|
if owned_set is not None and name not in owned_set:
|
|
return False
|
|
if name in referenced_set:
|
|
return False
|
|
if name in published_set:
|
|
return False
|
|
return True
|
|
|
|
|
|
def should_run_now(user_id: str) -> bool:
|
|
"""Return True if the curator interval has elapsed for ``user_id`` and it
|
|
is not paused."""
|
|
# 总开关:技能进化整体关闭时,curator 一律不跑。
|
|
if not _skill_evolution_enabled(user_id):
|
|
return False
|
|
curator_cfg = _get_curator_config(user_id)
|
|
if curator_cfg is None or not getattr(curator_cfg, "enabled", False):
|
|
return False
|
|
state = _read_curator_state(user_id)
|
|
if state.get("paused"):
|
|
return False
|
|
# 运行锁:已有 curator 在跑(且锁未过期)时不再触发新的一轮。
|
|
if _run_lock_active(state, datetime.now(UTC)):
|
|
return False
|
|
last_run = state.get("last_run_at")
|
|
if not last_run:
|
|
# 从未成功跑完过。若此前已尝试过(last_attempt_at 存在),按较短的重试间隔
|
|
# 退避 —— 避免首次运行持续失败时每个 tick(1h)都重试、每次烧一份 LLM 配额。
|
|
attempt_dt = _parse_state_time(state.get("last_attempt_at"))
|
|
if attempt_dt is None:
|
|
return True
|
|
return datetime.now(UTC) >= attempt_dt + timedelta(hours=_CURATOR_RETRY_HOURS)
|
|
interval_hours = getattr(curator_cfg, "interval_hours", 168)
|
|
last_dt = _parse_state_time(last_run)
|
|
if last_dt is None:
|
|
# 时间串损坏 —— 视同"从未跑过",允许重跑并重写正确时间戳,而不是像旧逻辑那样
|
|
# except 返回 False 导致该用户永久不再触发。
|
|
return True
|
|
return datetime.now(UTC) >= last_dt + timedelta(hours=interval_hours)
|
|
|
|
|
|
def manual_trigger_blocked_reason(user_id: str) -> str | None:
|
|
"""手动触发 curator 的预检。返回 None 表示可以跑;否则返回阻塞原因代码:
|
|
``evolution_disabled`` / ``curator_disabled`` / ``already_running``。
|
|
|
|
供 ``POST /api/curator/run`` 在派发后台线程**前**同步判定 —— 后台线程
|
|
(``_curator_thread_target``)本身也会再查一遍,这里只是为了让前端拿到准确反馈,
|
|
而不是无论开关状态如何都提示"已开始"。
|
|
"""
|
|
if not _skill_evolution_enabled(user_id):
|
|
return "evolution_disabled"
|
|
curator_cfg = _get_curator_config(user_id)
|
|
if curator_cfg is None or not getattr(curator_cfg, "enabled", False):
|
|
return "curator_disabled"
|
|
state = _read_curator_state(user_id)
|
|
if _run_lock_active(state, datetime.now(UTC)):
|
|
return "already_running"
|
|
return None
|
|
|
|
|
|
def apply_automatic_transitions(user_id: str, now: datetime | None = None) -> dict:
|
|
"""Pure age-based state transitions — no LLM involved.
|
|
|
|
Only processes agent-created skills that are ALL of:
|
|
- owned by ``user_id``
|
|
- not referenced by any agent's ``skills`` config
|
|
- not currently published
|
|
|
|
Skips pinned skills. Returns counters: {marked_stale, archived, checked,
|
|
skipped_referenced, skipped_published}.
|
|
"""
|
|
from deerflow.skills.usage import STATE_ACTIVE, STATE_ARCHIVED, STATE_STALE, agent_created_entries, latest_activity_at, set_state
|
|
|
|
if now is None:
|
|
now = datetime.now(UTC)
|
|
|
|
curator_cfg = _get_curator_config(user_id)
|
|
stale_days = getattr(curator_cfg, "stale_after_days", 30) if curator_cfg else 30
|
|
archive_days = getattr(curator_cfg, "archive_after_days", 90) if curator_cfg else 90
|
|
|
|
owned = _list_user_owned_skill_names(user_id)
|
|
referenced = _collect_referenced_skill_names()
|
|
published = _list_published_skill_names() # 一次性查出,避免逐技能查库
|
|
raw_entries = agent_created_entries()
|
|
|
|
skipped_referenced = 0
|
|
skipped_published = 0
|
|
eligible: dict[str, dict] = {}
|
|
for name, entry in raw_entries.items():
|
|
if owned is not None and name not in owned:
|
|
continue
|
|
if name in referenced:
|
|
skipped_referenced += 1
|
|
continue
|
|
if name in published:
|
|
skipped_published += 1
|
|
continue
|
|
eligible[name] = entry
|
|
|
|
counters = {
|
|
"marked_stale": 0,
|
|
"archived": 0,
|
|
"checked": len(eligible),
|
|
"skipped_referenced": skipped_referenced,
|
|
"skipped_published": skipped_published,
|
|
}
|
|
|
|
from deerflow.skills import curator_log
|
|
|
|
for name, entry in eligible.items():
|
|
if entry.get("pinned"):
|
|
continue
|
|
last_activity_str = latest_activity_at(name)
|
|
if not last_activity_str:
|
|
continue
|
|
try:
|
|
last_dt = datetime.fromisoformat(last_activity_str)
|
|
if last_dt.tzinfo is None:
|
|
last_dt = last_dt.replace(tzinfo=UTC)
|
|
except Exception:
|
|
continue
|
|
|
|
days_idle = (now - last_dt).days
|
|
current_state = entry.get("state", STATE_ACTIVE)
|
|
|
|
if days_idle >= archive_days and current_state != STATE_ARCHIVED:
|
|
set_state(name, STATE_ARCHIVED)
|
|
counters["archived"] += 1
|
|
curator_log.record(
|
|
"info",
|
|
f" 自动归档 [{name}]:已闲置 {days_idle} 天(阈值 {archive_days} 天)",
|
|
user_id=user_id,
|
|
)
|
|
elif days_idle >= stale_days and current_state == STATE_ACTIVE:
|
|
set_state(name, STATE_STALE)
|
|
counters["marked_stale"] += 1
|
|
curator_log.record(
|
|
"info",
|
|
f" 标记过时 [{name}]:已闲置 {days_idle} 天(阈值 {stale_days} 天)",
|
|
user_id=user_id,
|
|
)
|
|
|
|
return counters
|
|
|
|
|
|
def _render_candidate_list(user_id: str) -> str:
|
|
"""List of skill candidates handed to the LLM for merge/archive review.
|
|
|
|
Same three-condition gate as ``apply_automatic_transitions``: only
|
|
agent-created, not-referenced-by-any-agent, not-published skills owned
|
|
by ``user_id`` show up here. The LLM literally cannot see (and thus
|
|
cannot suggest action on) anything outside that set.
|
|
"""
|
|
from deerflow.skills.usage import STATE_ARCHIVED, all_entries
|
|
|
|
owned = _list_user_owned_skill_names(user_id)
|
|
referenced = _collect_referenced_skill_names()
|
|
published = _list_published_skill_names() # 一次性查出,避免逐技能查库
|
|
entries: dict[str, dict] = {}
|
|
for k, v in all_entries().items():
|
|
if not _eligible_for_curation(k, v, owned_set=owned, referenced_set=referenced, published_set=published):
|
|
continue
|
|
if v.get("state") == STATE_ARCHIVED:
|
|
continue
|
|
entries[k] = v
|
|
if not entries:
|
|
return "No agent-created active skills found."
|
|
lines = []
|
|
for name, entry in sorted(entries.items()):
|
|
state = entry.get("state", "active")
|
|
use = entry.get("use_count", 0)
|
|
view = entry.get("view_count", 0)
|
|
patch = entry.get("patch_count", 0)
|
|
last = entry.get("last_used_at") or entry.get("created_at") or "unknown"
|
|
lines.append(f"- {name} [state={state}, use={use}, view={view}, patches={patch}, last_active={last}]")
|
|
return "\n".join(lines)
|
|
|
|
|
|
_CURATOR_REVIEW_PROMPT = (
|
|
"You are running as DeerFlow's background skill CURATOR. This is an "
|
|
"UMBRELLA-BUILDING consolidation pass, not a passive audit and not a "
|
|
"duplicate-finder.\n\n"
|
|
"The goal of the skill collection is a LIBRARY OF CLASS-LEVEL "
|
|
"INSTRUCTIONS AND EXPERIENTIAL KNOWLEDGE. A collection of hundreds of "
|
|
"narrow skills where each one captures one session's specific bug is "
|
|
"a FAILURE of the library — not a feature. An agent searching skills "
|
|
"matches on descriptions, not on exact names; one broad umbrella "
|
|
"skill with labeled subsections beats five narrow siblings for "
|
|
"discoverability, not the other way around.\n\n"
|
|
"The right target shape is CLASS-LEVEL skills with rich SKILL.md "
|
|
"bodies + `references/`, `templates/`, and `scripts/` subfiles for "
|
|
"session-specific detail — not one-session-one-skill micro-entries.\n\n"
|
|
"Hard rules — do not violate:\n"
|
|
"1. DO NOT touch public (built-in) skills. The candidate list below "
|
|
"is already filtered to agent-created custom skills only.\n"
|
|
"2. DO NOT delete any skill. Archiving (a soft state flip via the "
|
|
"skill_archive tool — the file stays in custom/<name>/ and the user "
|
|
"can restore it from the Web UI) is the maximum action you may take. "
|
|
"Archives are recoverable; deletion is not. "
|
|
"When you call skill_archive(name, reason) you MUST pass a `reason` "
|
|
"string written **in Chinese**, in plain language a human operator "
|
|
"would read — NOT English jargon or terse debug notes. Examples of "
|
|
"acceptable reasons: '已合并到 code-formatter,该技能同时覆盖 Python 与 JS', "
|
|
"'与 foo-bar 内容重复,保留 foo-bar', '重构后过时,被 api-v2-search 替代', "
|
|
"'一次性 POC 实验,无复用价值'. Bad reasons (do NOT use): 'merged', "
|
|
"'duplicate', 'obsolete', 'consolidated'. The reason is shown in the "
|
|
"Web UI run-history for user audit, so write it as you would explain "
|
|
"it to the user themselves.\n"
|
|
"3. DO NOT touch skills shown as pinned=yes. Skip them entirely.\n"
|
|
"4. DO NOT use usage counters as a reason to skip consolidation. The "
|
|
"counters are new and often mostly zero. Judge overlap on CONTENT, "
|
|
"not on use_count. 'use=0' is not evidence a skill is valuable; it's "
|
|
"absence of evidence either way.\n"
|
|
"5. DO NOT reject consolidation on the grounds that 'each skill has "
|
|
"a distinct trigger'. Pairwise distinctness is the wrong bar. The "
|
|
"right bar is: 'would a human maintainer write this as N separate "
|
|
"skills, or as one skill with N labeled subsections?' When the "
|
|
"answer is the latter, merge.\n\n"
|
|
"How to work — not optional:\n"
|
|
"1. Scan the full candidate list. Identify PREFIX CLUSTERS (skills "
|
|
"sharing a first word or domain keyword). Examples you are likely "
|
|
"to find: search-*, code-*, data-*, api-*, file-*, analysis-*, "
|
|
"workflow-*, report-*, monitor-*, debug-*, python-*, security-*, etc. "
|
|
"Expect 10-25 clusters.\n"
|
|
"2. For each cluster with 2+ members, do NOT ask 'are these pairs "
|
|
"overlapping?' — ask 'what is the UMBRELLA CLASS these skills all "
|
|
"serve? Would a maintainer name that class and write one skill for "
|
|
"it?' If yes, pick (or create) the umbrella and absorb the siblings "
|
|
"into it.\n"
|
|
"3. Three ways to consolidate — use the right one per cluster:\n"
|
|
" a. MERGE INTO EXISTING UMBRELLA — one skill in the cluster is "
|
|
"already broad enough to be the umbrella. Patch it to add a labeled "
|
|
"section for each sibling's unique insight, then archive the "
|
|
"siblings.\n"
|
|
" b. CREATE A NEW UMBRELLA SKILL.md — no existing member is broad "
|
|
"enough. Create a new class-level skill whose SKILL.md covers the "
|
|
"shared workflow and has short labeled subsections. Archive the "
|
|
"now-absorbed narrow siblings.\n"
|
|
" c. DEMOTE TO REFERENCES/TEMPLATES/SCRIPTS — a sibling has "
|
|
"narrow-but-valuable session-specific content. Move it into the "
|
|
"umbrella's appropriate support directory:\n"
|
|
" • `references/<topic>.md` for session-specific detail OR "
|
|
"condensed knowledge banks (quoted research, API docs excerpts, "
|
|
"domain notes, provider quirks, reproduction recipes)\n"
|
|
" • `templates/<name>.<ext>` for starter files meant to be "
|
|
"copied and modified\n"
|
|
" • `scripts/<name>.<ext>` for statically re-runnable actions "
|
|
"(verification scripts, fixture generators, probes)\n"
|
|
" Then archive the old sibling.\n"
|
|
"4. Also flag skills whose NAME is too narrow (contains a specific "
|
|
"issue number, a feature codename, a specific error string, an "
|
|
"'audit' / 'diagnosis' / 'salvage' session artifact). These almost "
|
|
"always belong as a subsection or support file under a class-level "
|
|
"umbrella.\n"
|
|
"5. Iterate. After one consolidation round, scan the remaining set "
|
|
"and look for the NEXT umbrella opportunity. Don't stop after 3 "
|
|
"merges.\n\n"
|
|
"'keep' is a legitimate decision ONLY when the skill is already a "
|
|
"class-level umbrella and none of the proposed merges would improve "
|
|
"discoverability. 'This is narrow but distinct from its siblings' "
|
|
"is NOT a reason to keep — it's a reason to move it under an "
|
|
"umbrella as a subsection or support file.\n\n"
|
|
"Expected output: real umbrella-ification. Process every obvious "
|
|
"cluster. If you end the pass with fewer than 10 archives, you "
|
|
"stopped too early — go back and look at the clusters you left "
|
|
"alone.\n\n"
|
|
"When done, write a human summary AND a structured machine-readable "
|
|
"block so downstream tooling can distinguish consolidation from "
|
|
"pruning. Format EXACTLY:\n\n"
|
|
"## Structured summary (required)\n"
|
|
"```yaml\n"
|
|
"consolidations:\n"
|
|
" - from: <old-skill-name>\n"
|
|
" into: <umbrella-skill-name>\n"
|
|
" reason: <one short sentence — why merged, not just 'similar'>\n"
|
|
"prunings:\n"
|
|
" - name: <skill-name>\n"
|
|
" reason: <one short sentence — why archived with no merge target>\n"
|
|
"```\n\n"
|
|
"Every skill you archived via skill_archive MUST appear in exactly one "
|
|
"of the two lists. If you consolidated X into umbrella Y (patched Y, wrote "
|
|
"a references file to Y, or created Y with X's content absorbed), X "
|
|
"goes under `consolidations` with `into: Y`. If you archived X with "
|
|
"no absorption — truly stale, irrelevant, or obsolete — X goes under "
|
|
"`prunings`. Leave a list empty (`consolidations: []`) if none. Do "
|
|
"not omit the block. The block comes AFTER your human-readable "
|
|
"summary of clusters processed, patches made, and decisions left alone."
|
|
)
|
|
|
|
|
|
def _make_skill_archive_tool(user_id: str):
|
|
"""Curator-only tool: soft-archive a skill (sets state, files stay put)."""
|
|
from langchain.tools import tool as lc_tool
|
|
|
|
from deerflow.skills import curator_log
|
|
from deerflow.skills.storage import get_or_new_skill_storage
|
|
|
|
@lc_tool("skill_archive")
|
|
def _skill_archive(name: str, reason: str) -> str:
|
|
"""Soft-archive an agent-created skill (sets state=archived; files stay in custom/).
|
|
|
|
The user can recover the skill from the Web UI's archive drawer.
|
|
This is intentionally non-destructive — the on-disk SKILL.md and any
|
|
supporting files are preserved so nothing is lost.
|
|
|
|
Args:
|
|
name: The skill name to archive.
|
|
reason: 必填。用**中文**写一句话,清晰说明为什么归档这个技能 —
|
|
直接给读它的运维人员看,不要写英文术语 / 调试信息。例如:
|
|
- "已合并到 code-formatter,该技能同时覆盖 Python 与 JS"
|
|
- "重构后过时,被 api-v2-search 替代"
|
|
- "与现有 foo-bar 技能重复,内容完全一致"
|
|
- "一次性 POC 实验,无复用价值"
|
|
- "命名过窄(含具体 issue 号),已并入通用伞技能 X"
|
|
此原因会展示在策展日志和 Web UI 运行历史中,供用户审计。
|
|
"""
|
|
from deerflow.skills.usage import STATE_ARCHIVED, get_entry, set_state
|
|
|
|
# Defensive: tolerate empty/None reason but log a clear placeholder
|
|
# rather than crashing the curator run.
|
|
reason_text = (reason or "").strip() or "未提供原因"
|
|
|
|
storage = get_or_new_skill_storage()
|
|
if not storage.custom_skill_exists(name):
|
|
curator_log.record("warning", f"归档:未找到技能 [{name}],或它已被归档", user_id=user_id)
|
|
return f"Skill '{name}' not found or already archived."
|
|
entry = get_entry(name)
|
|
if entry.get("pinned"):
|
|
curator_log.record("info", f"归档:技能 [{name}] 已被钉选,跳过", user_id=user_id)
|
|
return f"Skill '{name}' is pinned — skipping."
|
|
if entry.get("state") == STATE_ARCHIVED:
|
|
curator_log.record("info", f"归档:技能 [{name}] 已是归档状态,跳过", user_id=user_id)
|
|
return f"Skill '{name}' is already archived."
|
|
|
|
# Safety net: even if the LLM hallucinated a name not in the
|
|
# candidate list, refuse to archive anything that is published or
|
|
# currently referenced by an agent.
|
|
if _is_skill_published(name):
|
|
curator_log.record(
|
|
"warning",
|
|
f"归档:已拒绝处理 [{name}],该技能已发布(策展不允许操作已发布的技能)",
|
|
user_id=user_id,
|
|
)
|
|
return f"Refused: skill '{name}' is published; manual unpublish is required first."
|
|
referenced = _collect_referenced_skill_names()
|
|
if name in referenced:
|
|
curator_log.record(
|
|
"warning",
|
|
f"归档:已拒绝处理 [{name}],该技能正被某个 Agent 引用",
|
|
user_id=user_id,
|
|
)
|
|
return f"Refused: skill '{name}' is still referenced by one or more agents."
|
|
|
|
# Soft archive: flip the lifecycle state. The skill directory stays in
|
|
# ``custom/<name>/`` untouched so the user can recover it from the
|
|
# Web UI's archive drawer (which lists skills by state, not by location).
|
|
try:
|
|
set_state(name, STATE_ARCHIVED)
|
|
except Exception as e:
|
|
curator_log.record("error", f"归档失败:技能 [{name}] — {e}", user_id=user_id)
|
|
return f"Archive failed: {e}"
|
|
curator_log.record(
|
|
"info",
|
|
f"已归档(软):[{name}] — 原因:{reason_text} — 文件保留在 custom/{name}/",
|
|
user_id=user_id,
|
|
)
|
|
return f"Archived '{name}' (reason: {reason_text}; files preserved in custom/{name}/)."
|
|
|
|
return _skill_archive
|
|
|
|
|
|
def _make_curator_skill_manage_tool():
|
|
"""curator 受限版 ``skill_manage``:在工具层屏蔽 delete 动作。
|
|
|
|
curator 做 umbrella 合并需要 create / patch / edit / write_file,但绝不能删整个
|
|
技能 —— prompt 硬规则也写明"归档(skill_archive)是允许的最大操作"。通用
|
|
skill_manage 的 delete 走 ``_assert_agent_may_delete``,**绕过了** skill_archive
|
|
对"已发布 / 被 agent 引用"技能的拒绝保护。只靠 prompt 文字约束不可靠,这里在
|
|
工具层直接拒掉 delete,curator 的 LLM 无论如何都删不掉技能。
|
|
"""
|
|
from langchain.tools import tool as lc_tool
|
|
|
|
from deerflow.tools.skill_manage_tool import _skill_manage_impl
|
|
|
|
@lc_tool("skill_manage")
|
|
def _curator_skill_manage(
|
|
action: str,
|
|
name: str,
|
|
content: str | None = None,
|
|
path: str | None = None,
|
|
find: str | None = None,
|
|
replace: str | None = None,
|
|
expected_count: int | None = None,
|
|
) -> str:
|
|
"""管理 skills/custom/ 下的自定义技能(curator 受限版)。
|
|
|
|
支持 action:create / patch / edit / write_file / remove_file / list / view。
|
|
**不支持 delete** —— curator 不得删除技能;如需下架请改用 skill_archive。
|
|
|
|
Args:
|
|
action: 操作类型(见上;delete 会被拒绝)。
|
|
name: 连字符命名的技能名。
|
|
content: create / edit / write_file 的新文件内容。
|
|
path: write_file / remove_file 的支持文件路径。
|
|
find: patch 要替换的原文本。
|
|
replace: patch 的替换文本。
|
|
expected_count: patch 的预期替换次数(可选)。
|
|
"""
|
|
if action == "delete":
|
|
return (
|
|
"Refused: curator 不允许删除技能。归档是允许的最大操作 —— "
|
|
"请改用 skill_archive(name, reason) 软下架(文件保留,用户可在 Web UI 恢复)。"
|
|
)
|
|
# curator 不绑定到具体会话线程,runtime 传 None(_get_thread_id 兼容 None)。
|
|
return _run_async_blocking(
|
|
_skill_manage_impl(
|
|
runtime=None,
|
|
action=action,
|
|
name=name,
|
|
content=content,
|
|
path=path,
|
|
find=find,
|
|
replace=replace,
|
|
expected_count=expected_count,
|
|
)
|
|
)
|
|
|
|
return _curator_skill_manage
|
|
|
|
|
|
def _run_llm_review(candidate_list: str, *, user_id: str) -> tuple[str, list[dict]]:
|
|
"""Run the curator consolidation pass as a React agent with skill tools bound.
|
|
|
|
返回 ``(llm_text, tool_actions)``:
|
|
- ``llm_text`` —— LLM 最终回复文字(它"自述"做了什么);
|
|
- ``tool_actions`` —— 从消息流提取的**真实**工具调用记录(它"实际"做了什么)。
|
|
"""
|
|
from deerflow.skills import curator_log
|
|
|
|
try:
|
|
from langchain.agents import create_agent
|
|
from langchain_core.messages import HumanMessage
|
|
|
|
from deerflow.agents.middlewares.tool_error_handling_middleware import ToolErrorHandlingMiddleware
|
|
from deerflow.models import create_chat_model
|
|
from deerflow.tools.builtins.skill_tools import skill_list_tool, skill_view_tool
|
|
|
|
curator_cfg = _get_curator_config(user_id)
|
|
# curator 评审用的模型:per-user 可配 curator.model_name(对应 config.yaml
|
|
# models[].name);为空则用主模型。
|
|
model_name = getattr(curator_cfg, "model_name", None) if curator_cfg else None
|
|
model = create_chat_model(name=model_name)
|
|
# 用 curator 受限版 skill_manage(屏蔽 delete),不用通用版 —— 见 _make_curator_skill_manage_tool。
|
|
tools = [skill_list_tool, skill_view_tool, _make_curator_skill_manage_tool(), _make_skill_archive_tool(user_id)]
|
|
# Reuse the lead-agent error middleware: when the LLM mis-calls a tool
|
|
# (wrong action / missing required args), the exception is converted
|
|
# into a ToolMessage(status="error") so the LLM can read the error and
|
|
# retry, instead of aborting the entire review pass.
|
|
# NOTE: ``create_react_agent`` (langgraph.prebuilt) does NOT accept a
|
|
# middleware kwarg — use LangChain 1.x's ``create_agent`` which does.
|
|
agent = create_agent(model=model, tools=tools, middleware=[ToolErrorHandlingMiddleware()])
|
|
|
|
timeout_seconds = getattr(curator_cfg, "review_timeout_seconds", 600) if curator_cfg else 600
|
|
curator_log.record(
|
|
"info",
|
|
f"LLM 评审:开始调用 React Agent(模型 {model_name or '主模型'},整体超时 {timeout_seconds}s)",
|
|
user_id=user_id,
|
|
)
|
|
prompt = _CURATOR_REVIEW_PROMPT + f"\n\nSkill candidates for this pass:\n{candidate_list}"
|
|
|
|
# 给整个 React Agent 评审套一个总超时:agent.invoke 内部可能多轮调 LLM,模型层的
|
|
# 单次 request_timeout 兜不住整体。超时后本 curator 线程能正常收尾、释放运行锁,
|
|
# 而不是无限期挂起。注意:Python 无法强杀线程 —— 超时后那个 agent.invoke 线程会
|
|
# 成为孤儿(最终被模型层 request_timeout 或自然返回所终结),但不再拖住 curator。
|
|
# copy_context():把 current_user 等 ContextVar 传进工作线程,否则工具调用会把
|
|
# user_id 误解析为 DEFAULT_USER_ID。
|
|
ctx = contextvars.copy_context()
|
|
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="curator-llm")
|
|
future = pool.submit(ctx.run, agent.invoke, {"messages": [HumanMessage(content=prompt)]})
|
|
try:
|
|
result = future.result(timeout=timeout_seconds)
|
|
except concurrent.futures.TimeoutError:
|
|
curator_log.record(
|
|
"warning",
|
|
f"LLM 评审超时(超过 {timeout_seconds}s),已中止本次评审",
|
|
user_id=user_id,
|
|
)
|
|
logger.warning("Curator[%s] LLM review timed out after %ss", user_id, timeout_seconds)
|
|
return "", []
|
|
finally:
|
|
# wait=False:超时时不阻塞等待挂死的 invoke 线程。
|
|
pool.shutdown(wait=False)
|
|
|
|
messages = result.get("messages", [])
|
|
tool_actions = _extract_tool_actions(messages)
|
|
for msg in reversed(messages):
|
|
if not getattr(msg, "tool_calls", None):
|
|
content = getattr(msg, "content", "")
|
|
if isinstance(content, str) and content.strip():
|
|
curator_log.record("info", "LLM 评审:Agent 已返回最终结论", user_id=user_id)
|
|
return content, tool_actions
|
|
if isinstance(content, list):
|
|
text = " ".join(
|
|
block.get("text", "") if isinstance(block, dict) else str(block)
|
|
for block in content
|
|
).strip()
|
|
curator_log.record("info", "LLM 评审:Agent 已返回最终结论", user_id=user_id)
|
|
return text, tool_actions
|
|
curator_log.record("warning", "LLM 评审:本次未产出最终回复", user_id=user_id)
|
|
return "", tool_actions
|
|
except Exception as e:
|
|
logger.warning("Curator LLM review failed: %s", e)
|
|
curator_log.record("warning", f"LLM 评审失败:{e}", user_id=user_id)
|
|
return "", []
|
|
|
|
|
|
def _parse_structured_summary(text: str) -> dict:
|
|
pattern = re.compile(r"```yaml\s*(.*?)```", re.DOTALL)
|
|
match = pattern.search(text)
|
|
if not match:
|
|
return {"consolidations": [], "prunings": []}
|
|
try:
|
|
import yaml
|
|
|
|
data = yaml.safe_load(match.group(1)) or {}
|
|
return {"consolidations": data.get("consolidations") or [], "prunings": data.get("prunings") or []}
|
|
except Exception:
|
|
return {"consolidations": [], "prunings": []}
|
|
|
|
|
|
def _extract_tool_actions(messages: list) -> list[dict]:
|
|
"""从 agent 消息流里提取**真实发生的**工具调用 + 结果。
|
|
|
|
与 _parse_structured_summary(解析 LLM 自述的 YAML)互补:这里记录的是 curator
|
|
实际调了哪些工具、入参是什么、成功还是失败 —— 用于审计"嘴上说的"与"实际做的"
|
|
是否一致。每条形如 ``{tool, args, status, result}``。
|
|
"""
|
|
# 先按 tool_call_id 收集每次调用的工具名与入参(来自 AIMessage.tool_calls)
|
|
calls: dict[str, dict] = {}
|
|
for msg in messages:
|
|
for tc in getattr(msg, "tool_calls", None) or []:
|
|
tc_id = tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None)
|
|
if not tc_id:
|
|
continue
|
|
calls[tc_id] = {
|
|
"tool": tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None),
|
|
"args": (tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", None)) or {},
|
|
}
|
|
# 再按 ToolMessage(带 tool_call_id)收集真实结果
|
|
actions: list[dict] = []
|
|
for msg in messages:
|
|
tc_id = getattr(msg, "tool_call_id", None)
|
|
if not tc_id:
|
|
continue
|
|
call = calls.get(tc_id, {})
|
|
raw = getattr(msg, "content", "")
|
|
result = raw if isinstance(raw, str) else str(raw)
|
|
actions.append(
|
|
{
|
|
"tool": call.get("tool") or getattr(msg, "name", None),
|
|
"args": call.get("args", {}),
|
|
"status": getattr(msg, "status", None) or "success",
|
|
"result": result[:500],
|
|
}
|
|
)
|
|
return actions
|
|
|
|
|
|
def run_curator_review(user_id: str) -> dict:
|
|
"""Main curator run for ``user_id``: transitions → LLM consolidation → state.
|
|
|
|
Only skills owned by ``user_id`` are touched.
|
|
"""
|
|
from deerflow.skills import curator_log
|
|
|
|
curator_log.start_new_run(user_id=user_id)
|
|
|
|
now = datetime.now(UTC)
|
|
logger.info("Curator[%s]: starting run at %s", user_id, now.isoformat())
|
|
curator_log.record("info", f"本次策展开始,启动时间 {now.isoformat()}", user_id=user_id)
|
|
|
|
transition_counters = apply_automatic_transitions(user_id, now)
|
|
logger.info("Curator[%s]: transitions=%s", user_id, transition_counters)
|
|
curator_log.record(
|
|
"info",
|
|
(
|
|
f"按年龄自动流转完成:已检查 {transition_counters['checked']} 个,"
|
|
f"标记陈旧 {transition_counters['marked_stale']} 个,"
|
|
f"自动归档 {transition_counters['archived']} 个"
|
|
),
|
|
user_id=user_id,
|
|
)
|
|
|
|
candidate_list = _render_candidate_list(user_id)
|
|
candidate_line_count = 0 if not candidate_list.strip() else len([line for line in candidate_list.splitlines() if line.startswith("- ")])
|
|
curator_log.record(
|
|
"info", f"进入 LLM 评审的候选技能:共 {candidate_line_count} 个", user_id=user_id
|
|
)
|
|
|
|
if candidate_line_count == 0:
|
|
# 没有候选技能 —— 跳过 LLM 评审,省掉无意义的模型调用。
|
|
# 年龄流转(上面的 apply_automatic_transitions)已完成,且不依赖 LLM。
|
|
curator_log.record("info", "无候选技能,跳过 LLM 评审", user_id=user_id)
|
|
suggestions = {"consolidations": [], "prunings": []}
|
|
tool_actions: list[dict] = []
|
|
else:
|
|
llm_text, tool_actions = _run_llm_review(candidate_list, user_id=user_id)
|
|
suggestions = _parse_structured_summary(llm_text)
|
|
curator_log.record(
|
|
"info",
|
|
(
|
|
f"LLM 建议已解析:建议合并 {len(suggestions['consolidations'])} 项,"
|
|
f"建议删减 {len(suggestions['prunings'])} 项"
|
|
),
|
|
user_id=user_id,
|
|
)
|
|
# Surface per-item reasoning so operators can audit *why* each action
|
|
# was taken, not just the count.
|
|
for item in suggestions.get("consolidations", []) or []:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
name = item.get("from", "?")
|
|
into = item.get("into", "?")
|
|
reason = (item.get("reason") or "无说明").strip()
|
|
curator_log.record(
|
|
"info",
|
|
f" 合并建议 [{name}] → [{into}]:{reason}",
|
|
user_id=user_id,
|
|
)
|
|
for item in suggestions.get("prunings", []) or []:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
name = item.get("name", "?")
|
|
reason = (item.get("reason") or "无说明").strip()
|
|
curator_log.record(
|
|
"info",
|
|
f" 删减建议 [{name}]:{reason}",
|
|
user_id=user_id,
|
|
)
|
|
|
|
# 上面是 LLM"自述"的建议;下面记录它"实际"调用了哪些工具,供对照审计。
|
|
curator_log.record("info", f"LLM 实际调用工具 {len(tool_actions)} 次", user_id=user_id)
|
|
for act in tool_actions:
|
|
target = act.get("args", {}).get("name", "")
|
|
curator_log.record(
|
|
"info",
|
|
f" 实际调用 [{act.get('tool')}]{f' [{target}]' if target else ''} → {act.get('status')}",
|
|
user_id=user_id,
|
|
)
|
|
|
|
state = _read_curator_state(user_id)
|
|
state["last_run_at"] = now.isoformat()
|
|
state["run_count"] = state.get("run_count", 0) + 1
|
|
state["last_run_summary"] = (
|
|
f"自动流转:标记陈旧 {transition_counters['marked_stale']} 个、"
|
|
f"归档 {transition_counters['archived']} 个;"
|
|
f"LLM:建议合并 {len(suggestions['consolidations'])} 项、实际工具调用 {len(tool_actions)} 次"
|
|
)
|
|
state["last_consolidation_suggestions"] = suggestions # LLM 自述
|
|
state["last_actual_actions"] = tool_actions # 真实工具调用记录
|
|
_write_curator_state(state, user_id)
|
|
|
|
logger.info("Curator[%s]: run complete — %s", user_id, state["last_run_summary"])
|
|
curator_log.record("info", f"本次策展已完成 — {state['last_run_summary']}", user_id=user_id)
|
|
return {"transitions": transition_counters, "suggestions": suggestions}
|
|
|
|
|
|
class _CuratorUser:
|
|
"""Minimal ``CurrentUser`` protocol implementation for background runs.
|
|
|
|
Curator threads are spawned via ``threading.Thread`` (not an asyncio task),
|
|
so the ``deerflow.runtime.user_context`` ContextVar starts unset. Without
|
|
a stand-in, any tool the LLM review invokes (e.g. ``skill_manage(create)``)
|
|
would call ``get_effective_user_id()`` and silently fall back to the
|
|
``DEFAULT_USER_ID`` sentinel — causing newly-created skills to be owned
|
|
by ``"default"`` instead of the user the curator is running for.
|
|
"""
|
|
|
|
__slots__ = ("id",)
|
|
|
|
def __init__(self, user_id: str) -> None:
|
|
self.id = user_id
|
|
|
|
|
|
def _curator_thread_target(user_id: str) -> None:
|
|
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
|
from deerflow.skills import curator_log
|
|
|
|
# 总开关 + curator 子开关:任一关闭则直接跳过。手动触发 /api/curator/run 也经由
|
|
# 本函数,因此手动触发同样受总开关与 curator.enabled 约束。
|
|
if not _skill_evolution_enabled(user_id):
|
|
logger.info("Curator[%s]: 技能进化总开关关闭,跳过", user_id)
|
|
return
|
|
curator_cfg = _get_curator_config(user_id)
|
|
if curator_cfg is None or not getattr(curator_cfg, "enabled", False):
|
|
logger.info("Curator[%s]: curator 未启用,跳过", user_id)
|
|
return
|
|
|
|
# 运行锁:防止同一用户的 curator 并发(定时器 vs 手动触发、连点手动触发)。抢不到
|
|
# 锁说明已有一轮在跑,直接跳过本次触发。
|
|
if not _try_acquire_run_lock(user_id, datetime.now(UTC)):
|
|
logger.info("Curator[%s]: 已有 curator 正在运行,跳过本次触发", user_id)
|
|
curator_log.record("info", "已有 curator 正在运行,跳过本次触发", user_id=user_id)
|
|
return
|
|
|
|
# Bind the per-thread user context for the entire curator run so every
|
|
# downstream tool / repository call resolves the correct user_id.
|
|
token = set_current_user(_CuratorUser(user_id))
|
|
try:
|
|
run_curator_review(user_id)
|
|
except Exception as exc:
|
|
logger.exception("Curator[%s] background run failed", user_id)
|
|
curator_log.record("error", f"策展后台任务失败:{exc}", user_id=user_id)
|
|
finally:
|
|
reset_current_user(token)
|
|
# 无论成功或异常,必定释放运行锁。
|
|
_release_run_lock(user_id)
|
|
|
|
|
|
async def maybe_run_curator() -> None:
|
|
"""周期调度入口 —— 给每个 interval 已到期的用户起一个后台策展线程。
|
|
|
|
由 app 的 curator 调度器周期性调用(也用于手动触发)。无候选技能的用户,其
|
|
LLM 评审会在 run_curator_review 内被跳过 —— 只做不耗费 LLM 的年龄流转。
|
|
任何失败都不会阻断调用方。
|
|
"""
|
|
try:
|
|
from sqlalchemy import select
|
|
|
|
from deerflow.persistence.engine import get_session_factory
|
|
from deerflow.persistence.user.model import UserRow
|
|
|
|
factory = get_session_factory()
|
|
if factory is None:
|
|
return
|
|
async with factory() as session:
|
|
result = await session.execute(select(UserRow.id))
|
|
user_ids = [str(row[0]) for row in result.all()]
|
|
except Exception:
|
|
logger.exception("Curator: failed to enumerate users at startup")
|
|
return
|
|
|
|
due_users: list[str] = []
|
|
for user_id in user_ids:
|
|
try:
|
|
if should_run_now(user_id):
|
|
due_users.append(user_id)
|
|
except Exception:
|
|
logger.exception("Curator: should_run_now check failed for user %s", user_id)
|
|
_dispatch_curator_runs(due_users)
|
|
|
|
|
|
def _dispatch_curator_runs(due_users: list[str]) -> None:
|
|
"""用线程池(并发上限 ``_CURATOR_MAX_CONCURRENCY``)派发到期用户的 curator 执行。
|
|
|
|
到期用户排队消费,而非一次性全部起线程齐调 LLM —— 防首次部署所有用户同时到期时
|
|
的惊群。``shutdown(wait=False)``:提交后立即返回,线程池在所有任务跑完后自行回收。
|
|
抽成独立函数便于对并发上限做单元测试。
|
|
"""
|
|
if not due_users:
|
|
return
|
|
|
|
logger.info(
|
|
"Curator: %d 个用户到期,以并发上限 %d 派发执行",
|
|
len(due_users),
|
|
_CURATOR_MAX_CONCURRENCY,
|
|
)
|
|
executor = concurrent.futures.ThreadPoolExecutor(
|
|
max_workers=_CURATOR_MAX_CONCURRENCY,
|
|
thread_name_prefix="skill-curator",
|
|
)
|
|
try:
|
|
for user_id in due_users:
|
|
executor.submit(_curator_thread_target, user_id)
|
|
except Exception:
|
|
logger.exception("Curator: failed to submit curator tasks")
|
|
finally:
|
|
executor.shutdown(wait=False)
|
|
|
|
|
|
def get_curator_status(user_id: str) -> dict:
|
|
"""Return curator state for ``user_id``."""
|
|
from deerflow.skills.usage import STATE_ACTIVE, STATE_ARCHIVED, STATE_STALE, agent_created_entries
|
|
|
|
state = _read_curator_state(user_id)
|
|
owned = _list_user_owned_skill_names(user_id)
|
|
entries = agent_created_entries()
|
|
if owned is not None:
|
|
entries = {name: entry for name, entry in entries.items() if name in owned}
|
|
counts: dict[str, int] = {STATE_ACTIVE: 0, STATE_STALE: 0, STATE_ARCHIVED: 0}
|
|
for entry in entries.values():
|
|
s = entry.get("state", STATE_ACTIVE)
|
|
counts[s] = counts.get(s, 0) + 1
|
|
|
|
cfg = _get_curator_config(user_id)
|
|
return {
|
|
"enabled": cfg is not None and getattr(cfg, "enabled", True),
|
|
"run_count": state.get("run_count", 0),
|
|
"last_run_at": state.get("last_run_at"),
|
|
"last_run_summary": state.get("last_run_summary"),
|
|
"paused": state.get("paused", False),
|
|
"agent_created_skills": {
|
|
"total": len(entries),
|
|
"active": counts[STATE_ACTIVE],
|
|
"stale": counts[STATE_STALE],
|
|
"archived": counts[STATE_ARCHIVED],
|
|
},
|
|
# 上次运行的两份记录:LLM 自述的建议 + 实际发生的工具调用,供前端对照展示。
|
|
"last_consolidation_suggestions": state.get("last_consolidation_suggestions")
|
|
or {"consolidations": [], "prunings": []},
|
|
"last_actual_actions": state.get("last_actual_actions") or [],
|
|
}
|