feat(skills): 多租户阶段 3 — Curator + 配置 per-user

curator_log.py
- ring buffer 从单一 deque 改为 dict[user_id -> deque],每个用户
  自己的运行日志互不可见
- start_new_run / record / get_recent / clear 全部接 user_id
  关键字参数;未传时落入 __default__ 桶兼容旧调用
- 新增 clear_all() 用于跨用户清理(仅测试用)

curator.py
- 状态文件路径从 skills/.curator_state 改为
  .deer-flow/users/{user_id}/.curator_state(per-user 隔离)
- 所有公开 API 接受 user_id 参数:should_run_now /
  apply_automatic_transitions / _render_candidate_list /
  run_curator_review / _curator_thread_target /
  get_curator_status / _make_skill_archive_tool / _run_llm_review
- 新增 _list_user_owned_skill_names(user_id):通过 SkillStore
  查询当前用户拥有的 skill 名集合,用于过滤 transitions / LLM
  candidates,保证 user A 跑 curator 不会动 user B 的 skill;
  SkillStore 不可达时退化为"全部 agent-created skill"(兼容旧部署)
- _get_curator_config(user_id) 通过 load_skill_evolution_config_for_user
  叠加 per-user override,让阈值/间隔/moderation_model 也是 per-user
- maybe_run_curator 启动时枚举 UserRow,按用户检查 should_run_now
  并各起一个 daemon 线程跑

skill_evolution_runtime.py
- 全局 backend/skill_evolution.json 语义保留作为 fallback
- 新增 per-user API:resolve_user_path / load_user_overrides /
  save_user_overrides / get_user_overrides_mtime,文件落到
  .deer-flow/users/{user_id}/skill_evolution.json
- 新增 load_skill_evolution_config_for_user(user_id):读全局
  SkillEvolutionConfig 作为 baseline,浅合并 per-user JSON,
  返回 Pydantic 实例。验证失败时回落到 baseline

routers/skills.py
- /api/curator/status / run / config GET+PUT / logs 全部接
  _current_user_id 注入运行上下文
- update_curator_config 改为 per-user:保存到
  users/{user_id}/skill_evolution.json,不再 reset_app_config
- _build_curator_config_response 接受 SkillEvolutionConfig 实例
  (而不是 AppConfig)以适配 per-user 合并结果

旧 19 个测试继续通过;未引入新依赖。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xuhdandClaude Opus 4.7 committed 2026-05-14 17:09:05 +08:00
1 parent cf191ef874
commit 4ef96c7930
4 files changed
+358 -121

No files matched your search

@@ -780,23 +780,32 @@ async def pin_skill(skill_name: str, request: SkillPinRequest, config: AppConfig
@router.get("/curator/status", summary="Curator Status")
async def curator_status() -> dict:
"""Return the current curator state and per-lifecycle skill counts."""
async def curator_status(request: Request) -> dict:
"""Return the current curator state and per-lifecycle skill counts for the
authenticated user."""
try:
from deerflow.skills.curator import get_curator_status
return get_curator_status()
return get_curator_status(_current_user_id(request))
except Exception as e:
logger.error("Failed to get curator status: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to get curator status: {str(e)}")
@router.post("/curator/run", summary="Trigger Curator Run")
async def run_curator() -> dict:
"""Manually trigger an immediate curator review pass (bypasses interval check, runs in background thread)."""
async def run_curator(request: Request) -> dict:
"""Manually trigger a curator review pass for the authenticated user
(bypasses interval check, runs in a background thread)."""
try:
import threading
from deerflow.skills.curator import _curator_thread_target
thread = threading.Thread(target=_curator_thread_target, daemon=True, name="skill-curator-manual")
user_id = _current_user_id(request)
thread = threading.Thread(
target=_curator_thread_target,
args=(user_id,),
daemon=True,
name=f"skill-curator-manual-{user_id}",
)
thread.start()
return {"triggered": True, "message": "Curator run started in background"}
except Exception as e:
@@ -845,9 +854,12 @@ class CuratorLogsResponse(BaseModel):
last_run_at: str | None
def _build_curator_config_response(config: AppConfig) -> CuratorConfigResponse:
"""Pure helper so both the GET route and the post-PUT refresh share one shape."""
evo = config.skill_evolution
def _build_curator_config_response(evo) -> CuratorConfigResponse:
"""Pure helper so both the GET route and the post-PUT refresh share one shape.
``evo`` is a ``SkillEvolutionConfig`` instance (already merged with per-user
overrides if any).
"""
return CuratorConfigResponse(
enabled=evo.enabled,
moderation_model_name=evo.moderation_model_name,
@@ -861,38 +873,37 @@ def _build_curator_config_response(config: AppConfig) -> CuratorConfigResponse:
@router.get("/curator/config", response_model=CuratorConfigResponse, summary="Get Skill Evolution Config")
async def get_curator_config(config: AppConfig = Depends(get_config)) -> CuratorConfigResponse:
"""Return the current effective skill_evolution config (yaml defaults + skill_evolution.json overrides)."""
async def get_curator_config(request: Request) -> CuratorConfigResponse:
"""Return the current curator config for the authenticated user (yaml
defaults + that user's overrides)."""
try:
return _build_curator_config_response(config)
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user
evo = load_skill_evolution_config_for_user(_current_user_id(request))
return _build_curator_config_response(evo)
except Exception as e:
logger.error("Failed to read curator config: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to read curator config: {str(e)}")
@router.put("/curator/config", response_model=CuratorConfigResponse, summary="Update Skill Evolution Config")
async def update_curator_config(body: CuratorConfigRequest) -> CuratorConfigResponse:
"""Persist overrides to skill_evolution.json and force AppConfig to reload on next read.
async def update_curator_config(body: CuratorConfigRequest, request: Request) -> CuratorConfigResponse:
"""Persist per-user overrides to ``users/{user_id}/skill_evolution.json``
and return the effective merged config.
The payload is validated against the SkillEvolutionConfig shape; unknown
fields are rejected by the Pydantic model_validate call.
The payload is validated against the ``SkillEvolutionConfig`` shape; unknown
fields are rejected.
"""
try:
from deerflow.config.app_config import get_app_config, reset_app_config
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
from deerflow.config.skill_evolution_runtime import save_overrides
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user, save_user_overrides
user_id = _current_user_id(request)
payload = body.model_dump()
# Round-trip through SkillEvolutionConfig to ensure persisted shape stays valid.
SkillEvolutionConfig.model_validate(payload)
save_overrides(payload)
# Reset cache so the next get_app_config() picks up the new overrides
# (the mtime check would catch it too, but resetting makes the response
# below deterministically reflect the new state).
reset_app_config()
fresh_config = get_app_config()
return _build_curator_config_response(fresh_config)
save_user_overrides(user_id, payload)
return _build_curator_config_response(load_skill_evolution_config_for_user(user_id))
except HTTPException:
raise
except Exception as e:
@@ -901,8 +912,9 @@ async def update_curator_config(body: CuratorConfigRequest) -> CuratorConfigResp
@router.get("/curator/logs", response_model=CuratorLogsResponse, summary="Recent Curator Logs")
async def get_curator_logs() -> CuratorLogsResponse:
"""Return in-memory log records from the most recent curator run.
async def get_curator_logs(request: Request) -> CuratorLogsResponse:
"""Return in-memory log records from the most recent curator run for the
authenticated user.
The buffer is process-local and cleared when curator starts a new run or
when the backend restarts. Records are ordered oldest → newest.
@@ -911,8 +923,9 @@ async def get_curator_logs() -> CuratorLogsResponse:
from deerflow.skills import curator_log
from deerflow.skills.curator import get_curator_status
records = curator_log.get_recent()
status = get_curator_status()
user_id = _current_user_id(request)
records = curator_log.get_recent(user_id)
status = get_curator_status(user_id)
return CuratorLogsResponse(
records=[CuratorLogRecord(**r) for r in records],
run_count=int(status.get("run_count", 0)),
@@ -1,9 +1,17 @@
"""Runtime overrides for `skill_evolution` config, stored in skill_evolution.json.
The file format mirrors the ``skill_evolution`` YAML section — its content is
deep-merged onto the defaults loaded from ``config.yaml`` when ``AppConfig`` is
instantiated. The file is optional; absence (or a malformed payload) is treated
as "no overrides" so the system keeps the YAML-only behaviour.
Two scopes exist:
* **Global** (back-compat): ``backend/skill_evolution.json`` deep-merged onto
``config.yaml.skill_evolution`` at AppConfig load time. Used as a fallback
for users who never set a personal override.
* **Per-user**: ``.deer-flow/users/{user_id}/skill_evolution.json``. Read by
the curator on each run and by the ``GET/PUT /api/curator/config`` route
with the active user id. Curator merges this on top of the global config
via :func:`load_curator_config_for_user`.
The file is optional in both scopes; absence (or a malformed payload) is
treated as "no overrides" so the system keeps the YAML-only behaviour.
"""
from __future__ import annotations
@@ -113,3 +121,96 @@ def get_overrides_mtime() -> float | None:
return path.stat().st_mtime
except OSError:
return None
# ---------------------------------------------------------------------------
# Per-user overrides
# ---------------------------------------------------------------------------
def resolve_user_path(user_id: str) -> Path:
"""Return ``{base_dir}/users/{user_id}/skill_evolution.json``."""
from deerflow.config.paths import get_paths
return get_paths().user_dir(user_id) / _FILENAME
def load_user_overrides(user_id: str) -> dict[str, Any] | None:
"""Read the per-user overrides file for ``user_id`` (None if missing/invalid)."""
path = resolve_user_path(user_id)
if not path.exists():
return None
try:
raw = path.read_text(encoding="utf-8").strip()
if not raw:
return None
data = json.loads(raw)
except OSError as exc:
logger.warning("Failed to read %s: %s", path, exc)
return None
except json.JSONDecodeError as exc:
logger.warning("User skill_evolution overrides at %s are not valid JSON: %s", path, exc)
return None
if not isinstance(data, dict):
logger.warning("User skill_evolution overrides at %s must be a JSON object", path)
return None
return data
def save_user_overrides(user_id: str, payload: dict[str, Any]) -> Path:
"""Atomically persist ``payload`` to the per-user overrides file."""
if not isinstance(payload, dict):
raise TypeError("skill_evolution overrides payload must be a dict")
path = resolve_user_path(user_id)
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path_str = tempfile.mkstemp(prefix=".skill-evolution-", suffix=".tmp", dir=str(path.parent))
tmp_path = Path(tmp_path_str)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
os.replace(tmp_path, path)
except Exception:
tmp_path.unlink(missing_ok=True)
raise
logger.info("Wrote per-user skill_evolution overrides to %s", path)
return path
def get_user_overrides_mtime(user_id: str) -> float | None:
path = resolve_user_path(user_id)
try:
return path.stat().st_mtime
except OSError:
return None
def load_skill_evolution_config_for_user(user_id: str | None):
"""Return a ``SkillEvolutionConfig`` instance with per-user overrides merged
on top of the global AppConfig defaults.
Used by the curator + the per-user ``GET/PUT /api/curator/config`` route.
When ``user_id`` is None or the per-user file is absent, returns the global
config unchanged.
"""
from deerflow.config import get_app_config
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
base = get_app_config().skill_evolution
if not user_id:
return base
overrides = load_user_overrides(user_id)
if not overrides:
return base
merged = base.model_dump()
for key, value in overrides.items():
existing = merged.get(key)
if isinstance(existing, dict) and isinstance(value, dict):
merged[key] = {**existing, **value}
else:
merged[key] = value
try:
return SkillEvolutionConfig.model_validate(merged)
except Exception:
logger.warning("Per-user skill_evolution overrides for %s failed validation, ignoring", user_id, exc_info=True)
return base
@@ -1,7 +1,14 @@
"""Skill curator — automatic lifecycle transitions and LLM consolidation pass."""
"""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 json
import logging
import os
@@ -22,12 +29,18 @@ def _get_skills_root() -> Path:
return Path(get_or_new_skill_storage().get_skills_root_path())
def _curator_state_path() -> Path:
return _get_skills_root() / _CURATOR_STATE_FILENAME
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() -> dict:
path = _curator_state_path()
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:
@@ -36,8 +49,8 @@ def _read_curator_state() -> dict:
return {"run_count": 0, "paused": False}
def _write_curator_state(state: dict) -> None:
path = _curator_state_path()
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")
@@ -55,23 +68,54 @@ def _write_curator_state(state: dict) -> None:
logger.debug("Failed to write curator state", exc_info=True)
def _get_curator_config():
try:
from deerflow.config import get_app_config
def _get_curator_config(user_id: str | None = None):
"""Return the curator config object for ``user_id``.
config = get_app_config()
skill_evo = getattr(config, "skill_evolution", None)
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 should_run_now() -> bool:
"""Return True if the curator interval has elapsed and it is not paused."""
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 = asyncio.run(_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 should_run_now(user_id: str) -> bool:
"""Return True if the curator interval has elapsed for ``user_id`` and it
is not paused."""
curator_cfg = _get_curator_config()
if curator_cfg is None or not getattr(curator_cfg, "enabled", True):
return False
state = _read_curator_state()
state = _read_curator_state(user_id)
if state.get("paused"):
return False
last_run = state.get("last_run_at")
@@ -85,11 +129,11 @@ def should_run_now() -> bool:
return True
def apply_automatic_transitions(now: datetime | None = None) -> dict:
def apply_automatic_transitions(user_id: str, now: datetime | None = None) -> dict:
"""Pure age-based state transitions — no LLM involved.
Only processes skills with source=='agent'. Skips pinned skills.
Returns counters: {marked_stale, archived, checked}.
Only processes agent-created skills owned by ``user_id``. Skips pinned
skills. Returns counters: {marked_stale, archived, checked}.
"""
from deerflow.skills.usage import STATE_ACTIVE, STATE_ARCHIVED, STATE_STALE, agent_created_entries, latest_activity_at, set_state
@@ -100,7 +144,10 @@ def apply_automatic_transitions(now: datetime | None = None) -> dict:
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)
entries = agent_created_entries()
if owned is not None:
entries = {name: entry for name, entry in entries.items() if name in owned}
counters = {"marked_stale": 0, "archived": 0, "checked": len(entries)}
for name, entry in entries.items():
@@ -129,10 +176,17 @@ def apply_automatic_transitions(now: datetime | None = None) -> dict:
return counters
def _render_candidate_list() -> str:
def _render_candidate_list(user_id: str) -> str:
from deerflow.skills.usage import STATE_ARCHIVED, all_entries
entries = {k: v for k, v in all_entries().items() if v.get("source") == "agent" and v.get("state") != STATE_ARCHIVED}
owned = _list_user_owned_skill_names(user_id)
entries = {
k: v
for k, v in all_entries().items()
if v.get("source") == "agent" and v.get("state") != STATE_ARCHIVED
}
if owned is not None:
entries = {k: v for k, v in entries.items() if k in owned}
if not entries:
return "No agent-created active skills found."
lines = []
@@ -248,7 +302,7 @@ _CURATOR_REVIEW_PROMPT = (
)
def _make_skill_archive_tool():
def _make_skill_archive_tool(user_id: str):
"""Curator-only tool: move a skill directory to .archive/ (recoverable)."""
from langchain.tools import tool as lc_tool
@@ -266,10 +320,10 @@ def _make_skill_archive_tool():
storage = get_or_new_skill_storage()
if not storage.custom_skill_exists(name):
curator_log.record("warning", f"skill_archive: '{name}' not found or already archived")
curator_log.record("warning", f"skill_archive: '{name}' not found or already archived", user_id=user_id)
return f"Skill '{name}' not found or already archived."
if get_entry(name).get("pinned"):
curator_log.record("info", f"skill_archive: '{name}' is pinned — skipping")
curator_log.record("info", f"skill_archive: '{name}' is pinned — skipping", user_id=user_id)
return f"Skill '{name}' is pinned — skipping."
skill_dir = storage.get_custom_skill_dir(name)
archive_root = Path(storage.get_skills_root_path()) / "custom" / ".archive"
@@ -279,16 +333,16 @@ def _make_skill_archive_tool():
try:
skill_dir.rename(dest)
except Exception as e:
curator_log.record("error", f"skill_archive: archive failed for '{name}': {e}")
curator_log.record("error", f"skill_archive: archive failed for '{name}': {e}", user_id=user_id)
return f"Archive failed: {e}"
set_state(name, STATE_ARCHIVED)
curator_log.record("info", f"Archived '{name}' → custom/.archive/{dest.name}")
curator_log.record("info", f"Archived '{name}' → custom/.archive/{dest.name}", user_id=user_id)
return f"Archived '{name}' → custom/.archive/{dest.name}."
return _skill_archive
def _run_llm_review(candidate_list: str) -> str:
def _run_llm_review(candidate_list: str, *, user_id: str) -> str:
"""Run the curator consolidation pass as a React agent with skill tools bound."""
from deerflow.skills import curator_log
@@ -301,10 +355,10 @@ def _run_llm_review(candidate_list: str) -> str:
from deerflow.tools.skill_manage_tool import skill_manage_tool
model = create_chat_model()
tools = [skill_list_tool, skill_view_tool, skill_manage_tool, _make_skill_archive_tool()]
tools = [skill_list_tool, skill_view_tool, skill_manage_tool, _make_skill_archive_tool(user_id)]
agent = create_react_agent(model, tools)
curator_log.record("info", "LLM review: invoking React agent")
curator_log.record("info", "LLM review: invoking React agent", user_id=user_id)
prompt = _CURATOR_REVIEW_PROMPT + f"\n\nSkill candidates for this pass:\n{candidate_list}"
result = agent.invoke({"messages": [HumanMessage(content=prompt)]})
@@ -313,20 +367,20 @@ def _run_llm_review(candidate_list: str) -> str:
if not getattr(msg, "tool_calls", None):
content = getattr(msg, "content", "")
if isinstance(content, str) and content.strip():
curator_log.record("info", "LLM review: agent returned final answer")
curator_log.record("info", "LLM review: agent returned final answer", user_id=user_id)
return content
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 review: agent returned final answer")
curator_log.record("info", "LLM review: agent returned final answer", user_id=user_id)
return text
curator_log.record("warning", "LLM review: no final assistant message produced")
curator_log.record("warning", "LLM review: no final assistant message produced", user_id=user_id)
return ""
except Exception as e:
logger.warning("Curator LLM review failed: %s", e)
curator_log.record("warning", f"LLM review failed: {e}")
curator_log.record("warning", f"LLM review failed: {e}", user_id=user_id)
return ""
@@ -344,18 +398,21 @@ def _parse_structured_summary(text: str) -> dict:
return {"consolidations": [], "prunings": []}
def run_curator_review() -> dict:
"""Main curator run: automatic transitions → LLM consolidation → write state."""
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()
curator_log.start_new_run(user_id=user_id)
now = datetime.now(UTC)
logger.info("Curator: starting run at %s", now.isoformat())
curator_log.record("info", f"Curator starting run at {now.isoformat()}")
logger.info("Curator[%s]: starting run at %s", user_id, now.isoformat())
curator_log.record("info", f"Curator starting run at {now.isoformat()}", user_id=user_id)
transition_counters = apply_automatic_transitions(now)
logger.info("Curator: transitions=%s", transition_counters)
transition_counters = apply_automatic_transitions(user_id, now)
logger.info("Curator[%s]: transitions=%s", user_id, transition_counters)
curator_log.record(
"info",
(
@@ -363,13 +420,16 @@ def run_curator_review() -> dict:
f"marked_stale={transition_counters['marked_stale']}, "
f"archived={transition_counters['archived']}"
),
user_id=user_id,
)
candidate_list = _render_candidate_list()
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"Candidates for LLM review: {candidate_line_count} skill(s)")
curator_log.record(
"info", f"Candidates for LLM review: {candidate_line_count} skill(s)", user_id=user_id
)
llm_text = _run_llm_review(candidate_list)
llm_text = _run_llm_review(candidate_list, user_id=user_id)
suggestions = _parse_structured_summary(llm_text)
curator_log.record(
"info",
@@ -377,9 +437,10 @@ def run_curator_review() -> dict:
f"LLM suggestions parsed: consolidations={len(suggestions['consolidations'])}, "
f"prunings={len(suggestions['prunings'])}"
),
user_id=user_id,
)
state = _read_curator_state()
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"] = (
@@ -388,45 +449,78 @@ def run_curator_review() -> dict:
f"llm: {len(suggestions['consolidations'])} consolidations suggested"
)
state["last_consolidation_suggestions"] = suggestions
_write_curator_state(state)
_write_curator_state(state, user_id)
logger.info("Curator: run complete — %s", state["last_run_summary"])
curator_log.record("info", f"Curator run complete — {state['last_run_summary']}")
logger.info("Curator[%s]: run complete — %s", user_id, state["last_run_summary"])
curator_log.record("info", f"Curator run complete — {state['last_run_summary']}", user_id=user_id)
return {"transitions": transition_counters, "suggestions": suggestions}
def _curator_thread_target() -> None:
def _curator_thread_target(user_id: str) -> None:
from deerflow.skills import curator_log
try:
run_curator_review()
run_curator_review(user_id)
except Exception as exc:
logger.exception("Curator background run failed")
curator_log.record("error", f"Curator background run failed: {exc}")
logger.exception("Curator[%s] background run failed", user_id)
curator_log.record("error", f"Curator background run failed: {exc}", user_id=user_id)
async def maybe_run_curator() -> None:
"""Async entry called at startup — spawns a daemon thread if the interval has elapsed."""
if not should_run_now():
"""Async startup entry — kicks off a thread per user whose interval elapsed.
Skips users with no DB-tracked entries (a fresh deployment) and never
blocks lifespan startup on failure.
"""
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
logger.info("Curator: interval elapsed, spawning background thread")
thread = threading.Thread(target=_curator_thread_target, daemon=True, name="skill-curator")
thread.start()
for user_id in user_ids:
try:
if not should_run_now(user_id):
continue
logger.info("Curator[%s]: interval elapsed, spawning background thread", user_id)
thread = threading.Thread(
target=_curator_thread_target,
args=(user_id,),
daemon=True,
name=f"skill-curator-{user_id}",
)
thread.start()
except Exception:
logger.exception("Curator: failed to spawn for user %s", user_id)
def get_curator_status() -> dict:
"""Return curator state for the API status endpoint."""
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()
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()
return {
"enabled": _get_curator_config() is not None and getattr(_get_curator_config(), "enabled", True),
"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"),
@@ -1,11 +1,14 @@
"""In-memory ring buffer holding the most recent curator run's execution log.
The buffer is intentionally process-local and non-persistent — restarting the
server clears it. The Gateway exposes the buffer over ``GET /api/curator/logs``
so the frontend can show what the latest curator pass actually did.
Now sharded per-user — each ``user_id`` gets its own deque so users only see
their own curator activity from the Web UI.
All public functions swallow internal exceptions so a logging failure can never
break the curator run itself.
The buffer is intentionally process-local and non-persistent. The Gateway
exposes it over ``GET /api/curator/logs`` with the active user id resolved
from the request context.
All public functions swallow internal exceptions so a logging failure can
never break the curator run itself.
"""
from __future__ import annotations
@@ -19,36 +22,44 @@ from typing import Any
logger = logging.getLogger(__name__)
_MAX_RECORDS = 200
# Reserved bucket key for "no user context" callers (legacy code paths or
# tests). Real curator runs always supply a concrete user_id.
_DEFAULT_BUCKET = "__default__"
_lock = threading.Lock()
_buffer: deque[dict[str, Any]] = deque(maxlen=_MAX_RECORDS)
_buffers: dict[str, deque[dict[str, Any]]] = {}
def _bucket_key(user_id: str | None) -> str:
return user_id or _DEFAULT_BUCKET
def _get_or_create(user_id: str | None) -> deque[dict[str, Any]]:
key = _bucket_key(user_id)
buffer = _buffers.get(key)
if buffer is None:
buffer = deque(maxlen=_MAX_RECORDS)
_buffers[key] = buffer
return buffer
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def start_new_run() -> None:
"""Clear the buffer and emit a ``run_started`` record.
Called once at the top of each curator run so the frontend sees a fresh
log stream rather than the previous run's tail.
"""
def start_new_run(user_id: str | None = None) -> None:
"""Clear the user's buffer and emit a ``run_started`` record."""
try:
with _lock:
_buffer.clear()
_buffer.append({"ts": _now_iso(), "level": "info", "message": "run_started"})
buffer = _get_or_create(user_id)
buffer.clear()
buffer.append({"ts": _now_iso(), "level": "info", "message": "run_started"})
except Exception:
logger.debug("curator_log.start_new_run failed", exc_info=True)
def record(level: str, message: str, **fields: Any) -> None:
"""Append a single log record. Never raises.
``level`` is one of ``info``/``warning``/``error``; unknown values are
accepted and rendered verbatim by the frontend. Extra keyword arguments
are merged into the record so callers can attach structured context.
"""
def record(level: str, message: str, *, user_id: str | None = None, **fields: Any) -> None:
"""Append a single log record to the user's buffer. Never raises."""
try:
entry: dict[str, Any] = {
"ts": _now_iso(),
@@ -58,25 +69,43 @@ def record(level: str, message: str, **fields: Any) -> None:
if fields:
entry.update({k: v for k, v in fields.items() if k not in entry})
with _lock:
_buffer.append(entry)
buffer = _get_or_create(user_id)
buffer.append(entry)
except Exception:
logger.debug("curator_log.record failed", exc_info=True)
def get_recent() -> list[dict[str, Any]]:
"""Return a snapshot copy of the buffer contents. Never raises."""
def get_recent(user_id: str | None = None) -> list[dict[str, Any]]:
"""Return a snapshot of records for ``user_id``. Never raises."""
try:
with _lock:
return list(_buffer)
buffer = _buffers.get(_bucket_key(user_id))
if buffer is None:
return []
return list(buffer)
except Exception:
logger.debug("curator_log.get_recent failed", exc_info=True)
return []
def clear() -> None:
"""Drop all buffered records. Used by tests."""
def clear(user_id: str | None = None) -> None:
"""Drop a user's records. With ``user_id=None`` drops the default bucket.
Use ``clear_all()`` for cross-user cleanup (tests).
"""
try:
with _lock:
_buffer.clear()
buffer = _buffers.get(_bucket_key(user_id))
if buffer is not None:
buffer.clear()
except Exception:
logger.debug("curator_log.clear failed", exc_info=True)
def clear_all() -> None:
"""Drop every user's records. Used by tests."""
try:
with _lock:
_buffers.clear()
except Exception:
logger.debug("curator_log.clear_all failed", exc_info=True)