"""圆桌功能型 agent 的「调用前自检 + 缺失自动补建」工具。 适用场景 -------- ``roundtable-intent`` / ``roundtable-recommender`` / ``roundtable-coordinator`` 是圆桌 Step 1 + 推荐 + Step 2 总控链路的**功能型** 内置 agent,任何部署都必须 存在。但仓库根 ``.gitignore`` 把整个 ``.deer-flow/`` 目录排除,新机器 ``git clone`` 之后这些目录**不会存在**,直接调对应 endpoint 会因为 agent 解析失败抛 500。 本模块在两个地方被调用: 1. **启动 lifespan**(``app/gateway/app.py``):服务起来就把缺失的目录补齐, 紧接着的 ``_sync_legacy_agents`` 会把它们 upsert 进 DB,新机器一启动 ``/api/agents`` 列表里就有这三个 agent,**不必先去点圆桌页**。 2. **四个路由入口**(``intent.init``、``intent.stream``、``recommend.stream``、 ``multi_agent.init``):防御性兜底——若管理员在启动后手删了目录,下一次请求 也能自愈。 ``_sync_legacy_agents`` 只关心磁盘文件,本模块也只写磁盘文件,不直接动 DB。 为什么 SOUL/config 用资源文件而不是内嵌 Python 字符串 ---------------------------------------------------- 这三个 agent 的 SOUL 是**经过实测调优、验证过**的版本(intent 含硬规则/反例集、 recommender 含三条铁律/多领域示例),体量大且会持续迭代。早期把 SOUL 内嵌成 Python 三引号字符串,导致「磁盘上的验证版」与「代码里的内嵌版」很容易分叉 (还有 ``\\n`` 之类转义陷阱)。改为把验证版 SOUL.md + config.yaml 作为**源码树里 的资源文件**(``_roundtable_seed_assets//``,随 git 发布,不在 gitignore 的 ``.deer-flow/`` 下),seeder 直接字节级复制过去——调 SOUL 就是改 .md 文件, 单一可信源,走 git review。 > ⚠️ 维护提示:资源文件(``_roundtable_seed_assets/``)是**发布默认值**的唯一可信源。 > 线上调优后,请把改动写回对应的资源 .md,否则下次新机器补建出来的还是旧版。 > ``_ensure_one`` 通常只在目录缺失时复制,**不覆盖**已存在目录(尊重运维现场手改)。 > 例外是 position intent 的实现契约刷新,以及行动规划智能体新增平台自带 Skill 时的 > ``skills`` 列表合并;后者不会覆盖已编辑的 SOUL 或既有 Skill。 为什么 ``roundtable-coordinator`` 是单例而不是每次会话新建 ------------------------------------------------------------ 历史实现每次 Step 2 init 都用 ``roundtable-coordinator-`` 这种 带时间戳的临时 id 创建新 agent,把"本次可调度席位列表"写进动态拼接的 SOUL。 长期跑下来 ``.deer-flow/agents/`` 累积了几十上百个完全相同的 coordinator 壳,管理页非常杂乱。 改造后 coordinator 是 **单例**(id 固定为 ``roundtable-coordinator``): - SOUL 是**通用协调规则**,不含本次席位列表; - 本次圆桌可调度的席位清单由 ``multi_agent.init`` 在创建好 coordinator thread 后,以 human 消息的形式 append 到 thread 历史,让 leader run 自然 读到。这样 SOUL 长期稳定,席位列表随会话动态注入。 """ from __future__ import annotations import logging from collections.abc import Iterable from pathlib import Path import yaml from deerflow.config.paths import get_paths logger = logging.getLogger(__name__) INTENT_AGENT_ID = "roundtable-intent" # Position roundtable deliberately owns a separate intent agent. Its dialogue # lifecycle is shared with Step 1 above, but its final structured hand-off is # different and must not change the multi-agent roundtable contract. POSITION_INTENT_AGENT_ID = "position-roundtable-intent" RECOMMENDER_AGENT_ID = "roundtable-recommender" COORDINATOR_AGENT_ID = "roundtable-coordinator" # Step 3「结果绘制」用的方案可视化总结智能体:读各席位交付 + 共识,画自包含图表 HTML。 # 与上面三个一样是内置单例,种子化一次常驻磁盘+DB,绝不每次调用重建。 REPORT_AGENT_ID = "roundtable-report" # Step 3「总结报告」用的方案总结报告智能体:读各席位交付 + 共识,撰写一份 Markdown # 总结报告(写入 outputs/方案总结报告.md),并按业务链总控编排提示组织。与 report 并存 # 的另一内置单例(report 画 HTML 看板,summary 写 Markdown 报告 + 支持问答/增量改)。 SUMMARY_AGENT_ID = "roundtable-summary" # 岗位会商收口的第二个内置单例:读取已生效的方案总结和岗位交付,把它们拆成可执行 # 子任务,同时产出 Markdown 规划报告和 JSON 清单。 ACTION_PLAN_AGENT_ID = "position-action-planner" # Step 3「大屏多页」用的结构化数据智能体:读各席位交付 + 共识,逐席位分析并提炼成 # 一份大屏展示用的结构化数据(report-json),供前端按模板渲染成多页大屏。**不调任何工具、 # 不写文件、不输出 HTML**——只在对话里输出一个严格合法的 ```report-json 代码块。与 report # (画 HTML 看板)/summary(写 Markdown)并存的第三个 Step3 内置单例,把「大屏抽取」从 # 旧版「复用 report agent + 简单粗暴字符串抽取」改为专职智能体 + 健壮抽取(见 # roundtable_orchestrator.report_json)。 DASHBOARD_AGENT_ID = "roundtable-dashboard" # Step 3「结构化抽取」用的智能体:读「方案总结报告」/ 各席位交付,分析层级结构 → 产出 flow-json # (也负责按指令改图 / 校验修复)。供 /api/flow-extract 调用;以后可在它身上配置「入库技能」, # 让它分析出结构化结果后直接入库。与 report/summary 并存的另一内置单例。 STRUCTURE_AGENT_ID = "roundtable-structure" # 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。每个子目录名即 agent id, # 内含该 agent 的 config.yaml 与 SOUL.md,是 seeder 落盘时字节级复制的来源。 _ASSETS_DIR = Path(__file__).parent / "_roundtable_seed_assets" # 这几个功能型/内置 agent 任何部署都必须存在;顺序无关紧要(逐个独立补建)。 # 前三个支撑 Step 1 澄清 / 推荐 / Step 2 总控;roundtable-report 支撑 Step 3 结果绘制 # (HTML 看板);roundtable-summary 支撑 Step 3 总结报告(Markdown + 问答/增量改); # roundtable-dashboard 支撑 Step 3 大屏多页结构化数据(report-json + 问答/增量改)。 _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = ( INTENT_AGENT_ID, POSITION_INTENT_AGENT_ID, RECOMMENDER_AGENT_ID, COORDINATOR_AGENT_ID, REPORT_AGENT_ID, SUMMARY_AGENT_ID, ACTION_PLAN_AGENT_ID, DASHBOARD_AGENT_ID, STRUCTURE_AGENT_ID, ) # seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。 _SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md") _ACTION_PLAN_SKILL = "position-roundtable-action-create" def _ensure_action_plan_skill(agent_dir: Path) -> bool: """Merge the owned batch-create Skill into an existing action planner. The planner SOUL remains editable in the agent configuration drawer and is therefore never overwritten here. The skill binding, however, is a runtime integration contract: without it an already-deployed planner cannot read the new SKILL.md or invoke the batch API script after a code upgrade. """ config_path = agent_dir / "config.yaml" try: config = yaml.safe_load(config_path.read_text(encoding="utf-8")) if config_path.exists() else {} except (OSError, yaml.YAMLError): logger.warning("Unable to read action planner config for skill binding: %s", config_path) return False if not isinstance(config, dict): logger.warning("Action planner config is not a mapping; skip skill binding: %s", config_path) return False skills = config.get("skills") if skills is None: normalized: list[str] = [] elif isinstance(skills, list): normalized = [str(name).strip() for name in skills if str(name).strip()] else: logger.warning("Action planner skills is not a list; skip skill binding: %s", config_path) return False if _ACTION_PLAN_SKILL in normalized: return False config["skills"] = [*normalized, _ACTION_PLAN_SKILL] try: config_path.write_text( yaml.safe_dump(config, allow_unicode=True, sort_keys=False), encoding="utf-8", ) except OSError: logger.warning("Unable to write action planner skill binding: %s", config_path) return False return True 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. The position-roundtable intent agent is an implementation-owned compatibility contract: unlike user-managed agents, its schema must stay aligned with the position workspace. Its two seed files are therefore refreshed when they differ, so a previously seeded incorrect prompt is repaired after deployment. Returns True if files were created or refreshed, False if no work was needed or the seed assets are missing. """ 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 roundtable agent '%s' (expected at %s)", agent_id, src_dir, ) return False agent_dir = get_paths().agent_dir(agent_id) if agent_dir.exists(): if agent_id == ACTION_PLAN_AGENT_ID: return _ensure_action_plan_skill(agent_dir) if agent_id != POSITION_INTENT_AGENT_ID: # Other built-in agents may have been tuned on a deployed machine; # preserve the long-standing no-overwrite behavior for them. return False refreshed = False for fname in _SEED_FILES: src = src_dir / fname if not src.exists(): continue target = agent_dir / fname source_bytes = src.read_bytes() if not target.exists() or target.read_bytes() != source_bytes: target.write_bytes(source_bytes) refreshed = True return refreshed 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 ensure_roundtable_functional_agents() -> list[str]: """Seed the built-in roundtable functional agents when their on-disk directories are missing, and refresh the implementation-owned position intent schema when it has changed. Idempotent after synchronization. Called from the startup lifespan and from the entry of intent/init, intent/stream, recommend/stream, and multi_agent/init so the routes self-heal on fresh internal deployments where ``.deer-flow/`` is gitignored and the agent directories were never shipped. 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 downstream route will surface its own actionable error if the agent really cannot be resolved. """ 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 roundtable functional agent '%s'", agent_id) if created: logger.info("Seeded missing roundtable functional agents: %s", ", ".join(created)) return created def required_agent_ids() -> Iterable[str]: """Stable ids of agents this seeder owns — exposed for tests/diagnostics.""" return tuple(_FUNCTIONAL_AGENT_IDS)