diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 49ac2bc9..a993f732 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -503,6 +503,24 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: except Exception: logger.exception("Skill ownership migration failed (non-fatal)") + # 一次性把 .deer-flow/agents/{id}/ 目录里历史遗留的 agent 导入 agents 表。 + # 路由层原本每次请求都跑一遍这个 sync(扫文件系统 + N 个 agent 各一次 DB + # SELECT/UPDATE),把它挪到启动期 + 路由层只保留 TTL 兜底,显著降低 + # /api/agents 列表延迟。 + try: + from app.gateway.routers.agents import ( + _mark_legacy_sync_fresh, + _sync_legacy_agents, + ) + + agent_store = getattr(app.state, "agent_store", None) + if agent_store is not None: + await _sync_legacy_agents(agent_store) + _mark_legacy_sync_fresh() + logger.info("Legacy folder-backed agents synced at startup") + except Exception: + logger.exception("Legacy agent sync failed at startup (non-fatal)") + # 启动 skill curator 调度器:常驻后台任务,每小时检查一次需不需要进化 # (per-user interval_hours 仍是真正决定要不要跑进化的闸)。 try: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py index 63358dd7..3fddcd22 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py @@ -1,7 +1,9 @@ """Configuration and loaders for custom agents.""" import logging +import os import re +import threading from typing import Any import yaml @@ -13,6 +15,25 @@ logger = logging.getLogger(__name__) SOUL_FILENAME = "SOUL.md" AGENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") + +# Per-file mtime caches. These are read on every /api/agents list/detail request +# (one read per agent) — caching keyed by (path, mtime_ns, size) is correct +# because any edit to the file updates mtime, and an external tool re-writing +# the same bytes doesn't actually change the result we'd return. Cache size is +# unbounded but lookup is O(1) and entries are tiny (one parsed dict / string +# per agent file); for the order of N≤10⁴ agents the memory cost is negligible. +_config_cache: dict[str, tuple[tuple[int, int], "AgentConfig | None"]] = {} +_soul_cache: dict[str, tuple[tuple[int, int], str | None]] = {} +_cache_lock = threading.Lock() + + +def _file_stat_key(path) -> tuple[int, int] | None: + """Return (mtime_ns, size) for cache invalidation, or None when missing.""" + try: + st = os.stat(path) + except OSError: + return None + return (st.st_mtime_ns, st.st_size) # Backward-compatible alias. Historically this validated the display name; # after the DB-backed agent table, display names can be Chinese and duplicated. # The pattern now applies only to storage/runtime ids. @@ -94,6 +115,18 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None: ) return None + # mtime-based cache. The config file is rewritten every time the agent is + # edited via PUT /api/agents/{id}, which bumps mtime — so the next read + # picks up the change automatically. Missing config_file = key None below, + # bypassing the cache (cheap path: just return AgentConfig with id/name). + cache_path_key = str(config_file) + stat_key = _file_stat_key(config_file) if config_file.exists() else None + if stat_key is not None: + with _cache_lock: + cached = _config_cache.get(cache_path_key) + if cached is not None and cached[0] == stat_key: + return cached[1] + data: dict[str, Any] = {} if config_file.exists(): try: @@ -112,7 +145,11 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None: known_fields = set(AgentConfig.model_fields.keys()) data = {k: v for k, v in data.items() if k in known_fields} - return AgentConfig(**data) + result = AgentConfig(**data) + if stat_key is not None: + with _cache_lock: + _config_cache[cache_path_key] = (stat_key, result) + return result def load_agent_soul(agent_id: str | None) -> str | None: @@ -131,8 +168,21 @@ def load_agent_soul(agent_id: str | None) -> str | None: soul_path = agent_dir / SOUL_FILENAME if not soul_path.exists(): return None + # mtime-based cache, same rationale as load_agent_config: SOUL.md is + # rewritten on edit (PUT /api/agents/{id}), which bumps mtime. + cache_path_key = str(soul_path) + stat_key = _file_stat_key(soul_path) + if stat_key is not None: + with _cache_lock: + cached = _soul_cache.get(cache_path_key) + if cached is not None and cached[0] == stat_key: + return cached[1] content = soul_path.read_text(encoding="utf-8").strip() - return content or None + result = content or None + if stat_key is not None: + with _cache_lock: + _soul_cache[cache_path_key] = (stat_key, result) + return result def list_custom_agents() -> list[AgentConfig]: