241 lines
12 KiB
Python
241 lines
12 KiB
Python
"""圆桌功能型 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/<id>/``,随 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-<timestamp36>`` 这种
|
||
带时间戳的临时 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/<id>/`` 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)
|