263 lines
13 KiB
Python
263 lines
13 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"
|
|
# 8BF 独立会商页第一步同样拥有一份内置意图智能体:与 ``roundtable-intent``
|
|
# 功能/提示词同源(资源文件即字节级复制的验证版),但落成**独立目录**,供
|
|
# 8BF 部署现场单独调优 SOUL,不影响通用多智能体会商的契约。
|
|
EIGHTBF_INTENT_AGENT_ID = "eightbf-roundtable-intent"
|
|
# 报告协同第一阶段专用意图智能体。它复用真实 LangGraph agent thread / SSE
|
|
# 运行协议,但拥有独立 SOUL 和结构化交接契约,不能再退回一次性 ainvoke 包装。
|
|
REPORT_COLLABORATION_INTENT_AGENT_ID = "report-collaboration-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,
|
|
EIGHTBF_INTENT_AGENT_ID,
|
|
REPORT_COLLABORATION_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_contract(agent_dir: Path) -> bool:
|
|
"""Refresh the action-type contract and retain the batch-create skill binding.
|
|
|
|
``actionType`` changed from a task-system enum to a model-derived name. An
|
|
already seeded planner must receive this contract update; otherwise its
|
|
persisted SOUL would continue to require frontend ``dictValue`` input after
|
|
a deployment. Other optional skills on a deployed agent are preserved.
|
|
"""
|
|
changed = False
|
|
source_soul = _ASSETS_DIR / ACTION_PLAN_AGENT_ID / "SOUL.md"
|
|
target_soul = agent_dir / "SOUL.md"
|
|
try:
|
|
source_bytes = source_soul.read_bytes()
|
|
if not target_soul.exists() or target_soul.read_bytes() != source_bytes:
|
|
target_soul.write_bytes(source_bytes)
|
|
changed = True
|
|
except OSError:
|
|
logger.warning("Unable to refresh action planner contract: %s", target_soul)
|
|
return changed
|
|
|
|
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 changed
|
|
if not isinstance(config, dict):
|
|
logger.warning("Action planner config is not a mapping; skip skill binding: %s", config_path)
|
|
return changed
|
|
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 changed
|
|
if _ACTION_PLAN_SKILL in normalized:
|
|
return changed
|
|
|
|
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 changed
|
|
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 and action planner are implementation-
|
|
owned compatibility contracts: unlike user-managed agents, their required
|
|
prompts must stay aligned with the position workspace. Their affected 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_contract(agent_dir)
|
|
if agent_id not in {POSITION_INTENT_AGENT_ID, REPORT_COLLABORATION_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)
|