"""AI 写作功能型 agent 的「调用前自检 + 缺失自动补建」工具。 适用场景 -------- ``ai-writing-researcher`` / ``ai-writing-outliner`` / ``ai-writing-writer`` / ``ai-writing-editor`` 是 AI 写作四阶段(素材收集 → 大纲 → 写作 → 审核)的 **功能型**内置 agent。当 ``config.yaml`` 的 ``ai_writing.use_builtin_agents.*`` 开关打开时,对应的图节点会从 ``.deer-flow/agents//`` 加载该 agent 的 SOUL.md(人设+结构化输出契约)与 config.yaml(可配 per-阶段模型)。 仓库根 ``.gitignore`` 把整个 ``.deer-flow/`` 目录排除,新机器 ``git clone`` 之后这些目录**不会存在**。本模块在两个地方被调用: 1. **启动 lifespan**(``app/gateway/app.py``):服务起来就把缺失目录补齐, 紧接着的 ``_sync_legacy_agents`` 会把它们 upsert 进 DB,新机器一启动 ``/api/agents`` 列表里就有这四个 agent,admin 可直接在智能体管理页编辑 SOUL / 模型。 2. **AI 写作路由入口**(``ai_writing.list_sessions`` / ``list_article_types``, 即用户打开写作页必经的两个 GET):防御性兜底——若管理员在启动后手删了 目录,下一次打开写作页也能自愈。 与 ``_roundtable_seed.py`` 完全同构:资源文件 (``_ai_writing_seed_assets//``)是发布默认值的唯一可信源,seeder 只在 目录缺失时字节级复制,**绝不覆盖**已存在目录(尊重运维/admin 现场手改)。 线上调优 SOUL 后请把改动写回对应资源 .md,否则新机器补建出来的还是旧版。 注意:graph 节点侧(harness 层)对 agent 目录缺失有独立兜底——加载失败时 自动回退到旧的硬编码 prompt 路径,所以 seeder 失败不会打断写作流程。 """ from __future__ import annotations import logging from collections.abc import Iterable from pathlib import Path from deerflow.config.paths import get_paths logger = logging.getLogger(__name__) RESEARCHER_AGENT_ID = "ai-writing-researcher" OUTLINER_AGENT_ID = "ai-writing-outliner" WRITER_AGENT_ID = "ai-writing-writer" EDITOR_AGENT_ID = "ai-writing-editor" # 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。每个子目录名即 agent id。 _ASSETS_DIR = Path(__file__).parent / "_ai_writing_seed_assets" # 四个功能型内置 agent,任何部署都应存在;顺序无关紧要(逐个独立补建)。 _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = ( RESEARCHER_AGENT_ID, OUTLINER_AGENT_ID, WRITER_AGENT_ID, EDITOR_AGENT_ID, ) # seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。 _SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md") # 内置「知识库检索」技能:素材收集专家通用检索的默认技能。skills/ 目录被 # .gitignore 排除,所以与 agent 目录一样需要 seeder 在缺失时从资源补建。 KNOWLEDGE_SEARCH_SKILL_NAME = "knowledge-base-search" _SKILL_ASSETS_DIR = _ASSETS_DIR / "skills" def _ensure_one(agent_id: str) -> bool: """Copy the verified SOUL.md + config.yaml for one functional agent into ``.deer-flow/agents//`` if that directory is missing. Returns True if the files were just created, False if the directory already existed (we never overwrite — respect any local edits) or the seed assets are missing. """ agent_dir = get_paths().agent_dir(agent_id) if agent_dir.exists(): return False src_dir = _ASSETS_DIR / agent_id if not src_dir.is_dir(): # Assets should always ship with the code; if they don't, surface a loud # log instead of silently creating an empty agent dir. logger.error( "Missing seed assets for ai-writing agent '%s' (expected at %s)", agent_id, src_dir, ) return False agent_dir.mkdir(parents=True, exist_ok=True) for fname in _SEED_FILES: src = src_dir / fname if src.exists(): # 字节级复制,保留验证版文件原样(换行/编码),与资源文件 hash 对齐。 (agent_dir / fname).write_bytes(src.read_bytes()) return True def _skills_public_dir() -> Path: """skills/public 物理目录(config.yaml skills.path 解析);独立成函数便于测试替身。""" from deerflow.skills.storage import get_or_new_skill_storage return Path(get_or_new_skill_storage().get_skills_root_path()) / "public" def _ensure_default_search_skill() -> bool: """Seed the built-in knowledge-base-search skill into ``skills/public/`` when missing. Never overwrites an existing skill directory. Returns True if the skill was just created. """ src = _SKILL_ASSETS_DIR / KNOWLEDGE_SEARCH_SKILL_NAME / "SKILL.md" if not src.exists(): logger.error("Missing seed asset for skill '%s' (expected at %s)", KNOWLEDGE_SEARCH_SKILL_NAME, src) return False skill_dir = _skills_public_dir() / KNOWLEDGE_SEARCH_SKILL_NAME if skill_dir.exists(): return False skill_dir.mkdir(parents=True, exist_ok=True) (skill_dir / "SKILL.md").write_bytes(src.read_bytes()) return True def _ensure_researcher_default_skills() -> bool: """给**已存在**的 researcher agent config.yaml 补默认技能配置(就地升级)。 seeder 对已存在目录绝不整体覆盖,但旧版种子没有 ``skills`` 字段——通用 检索改为技能驱动后,老部署的智能体管理页会显示"未配置技能"(运行时虽有 knowledge-base-search 兜底,但用户看不见、也没法在它基础上增删)。只在 ``skills`` 键**完全缺失**时补默认值;用户显式改过(包括清空成 ``[]``) 的配置不动。Returns True if the file was patched. """ import yaml config_file = get_paths().agent_dir(RESEARCHER_AGENT_ID) / "config.yaml" if not config_file.exists(): return False try: data = yaml.safe_load(config_file.read_text(encoding="utf-8")) or {} except yaml.YAMLError: logger.warning("researcher config.yaml unparsable, skip default-skills patch") return False if not isinstance(data, dict) or "skills" in data: return False data["skills"] = [KNOWLEDGE_SEARCH_SKILL_NAME] config_file.write_text( yaml.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8", ) return True def ensure_ai_writing_functional_agents() -> list[str]: """Seed the four ai-writing functional agents if their on-disk directories are missing. Idempotent — fast no-op when all exist. Also seeds the built-in knowledge-base-search skill (the researcher's default retrieval skill) and patches a pre-existing researcher config.yaml that predates the ``skills`` field. Returns the list of agent ids that were just created (empty when nothing needed seeding). Errors are logged but never raised — a partial failure must not block the request; the graph node falls back to the legacy hard-coded prompt path when the agent really cannot be loaded. """ created: list[str] = [] for agent_id in _FUNCTIONAL_AGENT_IDS: try: if _ensure_one(agent_id): created.append(agent_id) except Exception: logger.exception("Failed to seed ai-writing functional agent '%s'", agent_id) if created: logger.info("Seeded missing ai-writing functional agents: %s", ", ".join(created)) try: if _ensure_default_search_skill(): logger.info("Seeded built-in skill '%s'", KNOWLEDGE_SEARCH_SKILL_NAME) except Exception: logger.exception("Failed to seed built-in skill '%s'", KNOWLEDGE_SEARCH_SKILL_NAME) try: if _ensure_researcher_default_skills(): logger.info("Patched researcher config.yaml with default skills") except Exception: logger.exception("Failed to patch researcher default skills") return created def required_agent_ids() -> Iterable[str]: """Stable ids of agents this seeder owns — exposed for tests/diagnostics.""" return tuple(_FUNCTIONAL_AGENT_IDS)