From 10c10622cff89e91f0df2ebb7e8b42fbf1868b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=A2=E6=AF=85=E9=B9=8F?= <1103914483@qq.com> Date: Thu, 9 Apr 2026 00:42:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(seeder):=20=E7=BB=9F=E4=B8=80=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=88=9D=E5=A7=8B=E5=8C=96=E5=B9=82=E7=AD=89=E6=80=A7?= =?UTF-8?q?=EF=BC=8C=E4=BD=BF=E7=94=A8=20DB=20=E6=A0=87=E8=AE=B0=E6=9B=BF?= =?UTF-8?q?=E4=BB=A3=E6=96=87=E4=BB=B6=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: - agent_seeder 使用 .seeded 文件标记判断是否已初始化,换环境/连远程 DB 时标记丢失导致重复创建 - template/tool/skill seeder 每次启动都会重建被用户删除的数据 修复: - 4 个 seeder 统一使用 system_settings 表的 DB 标记(跟随数据库,跨环境有效) - 幂等三重检查:DB 标记 → DB 数据存在性 → 首次创建 - 用户删除数据后不再重建(标记存在即跳过) - seed_atlassian_rovo_config 同步加入 DB 标记保护 - push_default_skills_to_existing_agents 在技能未变化时跳过文件扫描 - 更新 ALEMBIC_GUIDELINES.md 补充数据初始化规范 涉及的 DB 标记 key: - builtin_tools_seeded - builtin_templates_seeded - builtin_skills_seeded - default_agents_seeded - atlassian_rovo_config_seeded --- backend/ALEMBIC_GUIDELINES.md | 191 +++++++++++++++++++++- backend/app/services/agent_seeder.py | 69 ++++++-- backend/app/services/skill_seeder.py | 150 +++++++++-------- backend/app/services/template_seeder.py | 99 ++++++------ backend/app/services/tool_seeder.py | 204 ++++++++++++------------ 5 files changed, 475 insertions(+), 238 deletions(-) diff --git a/backend/ALEMBIC_GUIDELINES.md b/backend/ALEMBIC_GUIDELINES.md index 82e6bf89a..577b8f301 100644 --- a/backend/ALEMBIC_GUIDELINES.md +++ b/backend/ALEMBIC_GUIDELINES.md @@ -130,6 +130,195 @@ - **模型文档**:为复杂的模型添加文档说明 - **迁移记录**:保持迁移历史的清晰记录,便于后续维护 -## 9. 附则 +## 9. 数据初始化(Seeder)规范 + +### 9.1 架构说明 + +系统启动时会执行数据初始化(Seeder),用于创建系统运行所需的基础数据。Seeder 分为两类: + +| 类型 | 职责 | 幂等方式 | 删除后是否重建 | +|------|------|---------|-------------| +| **配置同步 Seeder** | 每次部署同步最新的工具/模板/技能定义 | DB 查询(按 name/folder_name) | ✅ 是(系统基础设施) | +| **一次性初始化 Seeder** | 首次部署创建默认数据(如默认 Agent) | DB 标记(system_settings) | ❌ 否(尊重用户操作) | + +### 9.2 现有 Seeder 清单 + +| Seeder | 文件 | DB 标记 key | 删除后重建? | +|--------|------|------------|------------| +| 内置工具 | `tool_seeder.py` | `builtin_tools_seeded` | ❌ 不重建 | +| Atlassian 配置 | `tool_seeder.py` | `atlassian_rovo_config_seeded` | ❌ 不重建 | +| Agent 模板 | `template_seeder.py` | `builtin_templates_seeded` | ❌ 不重建 | +| 内置技能 | `skill_seeder.py` | `builtin_skills_seeded` | ❌ 不重建 | +| 默认 Agent | `agent_seeder.py` | `default_agents_seeded` | ❌ 不重建 | +| 技能推送 | `skill_seeder.py` | (依赖 `builtin_skills_seeded` 标记状态) | — | + +### 9.3 开发规范 + +#### 新增"配置同步"类数据(工具/模板/技能) + +直接在对应 seeder 的定义列表中添加,无需创建 Alembic migration: + +```python +# 例:在 tool_seeder.py 的 BUILTIN_TOOLS 列表中添加新工具 +{ + "name": "new_tool_name", + "display_name": "新工具", + "description": "工具描述", + "category": "类别", + ... +} +``` + +**要求:** +- 必须使用唯一标识字段(name / folder_name)作为幂等判断依据 +- 已存在时执行**更新**(同步最新定义),不存在时执行**插入** +- 禁止在 seeder 中使用 `INSERT` 不带 `ON CONFLICT` 或存在性检查 +- 用户手动删除后,下次启动**会重建**(这是预期行为——系统基础设施不应缺失) + +#### 新增"一次性初始化"类数据(默认用户/Agent/配置项) + +使用 `system_settings` 表的 DB 标记模式: + +```python +# 1. 检查 DB 标记 +marker = await db.execute( + select(SystemSetting).where(SystemSetting.key == "xxx_seeded") +) +if marker.scalar_one_or_none() is not None: + return # 已执行过,永远不再执行 + +# 2. 检查数据是否已存在(兼容旧版本 / 远程 DB) +existing = await db.execute(select(Model).where(...)) +if existing.scalars().first() is not None: + # 补写标记,跳过创建 + db.add(SystemSetting(key="xxx_seeded", value={...})) + await db.commit() + return + +# 3. 创建数据 + 写入标记 +... +db.add(SystemSetting(key="xxx_seeded", value={...})) +await db.commit() +``` + +**要求:** +- 必须使用 `system_settings` 表作为标记,禁止使用文件标记(文件不跟随数据库,换环境会失效) +- 标记写入必须在 `db.commit()` 同一个事务中(原子性) +- 标记存在时永远不再执行,即使用户手动删除了数据(尊重用户操作) +- 必须包含"兼容检查"——DB 中已有数据但无标记时,补写标记并跳过 + +#### 一次性数据变更(回填/迁移/修正) + +使用 Alembic data migration(参考本文档第 3 节),不要放在 startup seeder 中: + +```bash +alembic revision -m "backfill_xxx_column" +``` + +```python +def upgrade(): + conn = op.get_bind() + # 带幂等检查的数据操作 + conn.execute(sa.text("UPDATE ... WHERE ... AND new_column IS NULL")) + +def downgrade(): + conn.execute(sa.text("UPDATE ... SET new_column = NULL WHERE ...")) +``` + +### 9.4 禁止事项 + +| 禁止行为 | 原因 | 正确做法 | +|---------|------|---------| +| 使用文件标记(`.seeded`)判断是否已初始化 | 文件不跟随 DB,换环境/重建容器失效 | 使用 `system_settings` DB 标记 | +| Seeder 中不做存在性检查直接 INSERT | 重复启动会创建重复数据 | 先查询再插入,或使用 `ON CONFLICT` | +| 在 startup seeder 中做数据回填/修正 | 每次启动都执行,性能浪费且逻辑混乱 | 使用 Alembic data migration | +| Seeder 依赖执行顺序但不显式声明 | 换顺序后默默失败 | 在 `main.py` 中用注释标明依赖关系 | + +### 9.5 Agent 模板目录规范(`agent_template/`) + +`agent_template/` 是每个新建 Agent 的**文件系统初始模板**。创建 Agent 时,`agent_manager.py` 会将整个目录 `copytree` 到 Agent 的独立工作区(`{AGENT_DATA_DIR}/{agent_id}/`),然后替换模板变量。 + +#### 目录结构 + +``` +agent_template/ +├── soul.md ← Agent 人格定义模板(含 {{agent_name}} 等变量) +├── souls/ ← 角色专用人格模板(engineer/hr/sales),创建时按角色选用 +│ ├── engineer.md +│ ├── hr.md +│ └── sales.md +├── memory/ +│ ├── memory.md ← 长期记忆模板(带分类引导结构) +│ ├── MEMORY_INDEX.md ← 记忆索引 +│ └── curiosity_journal.md ← 自主探索日志 +├── skills/ ← 预装技能定义(复制到 Agent 后可被 Skills 索引发现) +│ ├── FOLLOW_UP_TASK.md +│ ├── MEETING_MANAGEMENT.md +│ ├── RESEARCH_AND_REPORT.md +│ └── MCP_INSTALLER.md +├── HEARTBEAT.md ← 心跳唤醒指令 +├── state.json ← Agent 状态初始模板 +├── todo.json ← 任务跟踪初始模板 +├── enterprise_info/ ← 共享企业信息目录 +├── daily_reports/ ← 日报存储目录 +└── workspace/ ← Agent 工作文件目录 +``` + +#### 模板变量 + +`soul.md` 和 `souls/*.md` 中支持以下变量,创建 Agent 时由 `agent_manager.py` 替换: + +| 变量 | 替换为 | 来源 | +|------|--------|------| +| `{{agent_name}}` | Agent 名称 | `Agent.name` | +| `{{role_description}}` | 角色描述 | `Agent.role_description`,默认"通用助手" | +| `{{creator_name}}` | 创建者姓名 | `User.display_name` | +| `{{created_at}}` | 创建日期 | 当前 UTC 日期(YYYY-MM-DD) | + +#### 修改规范 + +| 操作 | 影响范围 | 注意事项 | +|------|---------|---------| +| 修改 `soul.md` | **仅影响新建的 Agent** | 已有 Agent 的 soul.md 不会自动更新 | +| 修改 `skills/*.md` | **仅影响新建的 Agent** | 已有 Agent 需通过 `push_default_skills_to_existing_agents()` 同步 | +| 修改 `HEARTBEAT.md` | **仅影响新建的 Agent** | 已有 Agent 需手动更新或通过迁移脚本批量更新 | +| 修改 `memory/memory.md` | **仅影响新建的 Agent** | 已有 Agent 的记忆由系统自动管理(memory_extractor) | +| 新增 `souls/*.md` | 无直接影响 | 需同步修改 `agent_manager.py` 或 API 以支持角色选择 | + +**关键点:模板修改不会影响已创建的 Agent。** 如需批量更新已有 Agent 的模板文件,应编写专用的迁移脚本或在 startup 代码中添加同步逻辑(参考 `push_default_skills_to_existing_agents()` 的模式)。 + +#### 与 Seeder 的关系 + +``` +agent_template/ ← 文件系统模板(soul.md, skills/, memory/) + ↓ copytree +{AGENT_DATA_DIR}/{id}/ ← 每个 Agent 的独立工作区 + +skill_seeder.py ← DB 中的技能定义(Skill 表) + ↓ push_default_skills +{AGENT_DATA_DIR}/{id}/skills/ ← Agent 工作区中的技能文件 + +二者是独立的分发渠道: +- 新建 Agent → 从 agent_template/ 复制 +- 已有 Agent 补装新技能 → 从 skill_seeder 推送 +``` + +### 9.6 启动顺序与依赖 + +``` +main.py 启动序列: +│ +├─ 1. seed_builtin_tools() ← 无依赖 +├─ 2. seed_agent_templates() ← 无依赖 +├─ 3. seed_skills() ← 无依赖 +│ └─ push_default_skills_to_existing_agents() +├─ 4. seed_default_agents() ← 依赖:admin 用户、工具、技能必须已存在 +│ +└─ 注意:1-3 顺序可调,4 必须在 1-3 之后 +``` + +修改启动顺序时,必须确认依赖关系不被打破。 + +## 10. 附则 本规范适用于所有使用 Alembic 进行数据库迁移管理的开发人员,应严格遵守。如有特殊情况需要偏离本规范,应提前与团队沟通并获得批准。 diff --git a/backend/app/services/agent_seeder.py b/backend/app/services/agent_seeder.py index ed03491b7..27850b6f0 100644 --- a/backend/app/services/agent_seeder.py +++ b/backend/app/services/agent_seeder.py @@ -93,19 +93,41 @@ async def seed_default_agents(): - """Create Morty & Meeseeks if they don't already exist. + """创建默认 Agent(Morty & Meeseeks),如果尚未创建过。 - Idempotency is guarded by a '.seeded' marker file in AGENT_DATA_DIR rather - than by agent name, so the seeder does NOT re-run if the user renames or - deletes the default agents. Delete the marker manually to re-seed. + 幂等性保护(双重检查): + 1. DB 标记:system_settings 表中 key="default_agents_seeded" → 跟随数据库,跨环境有效 + 2. DB 查询:检查 Agent 表中是否已有同名 agent → 兼容旧版本已 seed 但无标记的情况 + + 设计原则: + - 标记存在 → 永远不重建(即使用户删了默认 agent,也尊重用户意图) + - 标记不存在但 DB 有数据 → 补写标记,不重建(兼容远程 DB 已有数据的场景) + - 标记不存在且 DB 无数据 → 首次创建,写入标记 """ - # --- Idempotency guard: file-based marker (survives agent renames/deletes) --- seed_marker = Path(settings.AGENT_DATA_DIR) / ".seeded" - if seed_marker.exists(): - logger.info("[AgentSeeder] Seed marker found, skipping default agent creation") - return async with async_session() as db: + # ── 检查 1:DB 标记(主判断,跟随数据库走)── + from app.models.system_settings import SystemSetting + marker_result = await db.execute( + select(SystemSetting).where(SystemSetting.key == "default_agents_seeded") + ) + if marker_result.scalar_one_or_none() is not None: + logger.info("[AgentSeeder] DB 标记已存在,跳过默认 Agent 创建") + return + + # ── 检查 2:DB 中是否已有默认 Agent(兼容旧版本 / 远程 DB 已 seed)── + existing_result = await db.execute( + select(Agent).where(Agent.name.in_(["Morty", "Meeseeks"])) + ) + if existing_result.scalars().first() is not None: + logger.info("[AgentSeeder] DB 中已存在默认 Agent,补写标记并跳过") + db.add(SystemSetting( + key="default_agents_seeded", + value={"seeded_at": str(__import__("datetime").datetime.utcnow()), "source": "existing_data"} + )) + await db.commit() + return # Get platform admin as creator admin_result = await db.execute( @@ -260,13 +282,26 @@ async def seed_default_agents(): encoding="utf-8", ) + # 写入 DB 标记(主标记,跟随数据库) + db.add(SystemSetting( + key="default_agents_seeded", + value={ + "seeded_at": str(__import__("datetime").datetime.utcnow()), + "morty_id": str(morty.id), + "meeseeks_id": str(meeseeks.id), + "source": "initial_seed", + } + )) + await db.commit() - logger.info(f"[AgentSeeder] Created default agents: Morty ({morty.id}), Meeseeks ({meeseeks.id})") - - # Write seed marker AFTER a successful commit so a failed seed can be retried - seed_marker.parent.mkdir(parents=True, exist_ok=True) - seed_marker.write_text( - f"seeded\nmorty={morty.id}\nmeeseeks={meeseeks.id}\n", - encoding="utf-8", - ) - logger.info(f"[AgentSeeder] Wrote seed marker to {seed_marker}") + logger.info(f"[AgentSeeder] 默认 Agent 创建完成: Morty ({morty.id}), Meeseeks ({meeseeks.id})") + + # 同时写文件标记(向后兼容旧版本) + try: + seed_marker.parent.mkdir(parents=True, exist_ok=True) + seed_marker.write_text( + f"seeded\nmorty={morty.id}\nmeeseeks={meeseeks.id}\n", + encoding="utf-8", + ) + except Exception: + pass # 文件标记写入失败不影响功能,DB 标记已写入 diff --git a/backend/app/services/skill_seeder.py b/backend/app/services/skill_seeder.py index ce6af2d12..d8a091900 100644 --- a/backend/app/services/skill_seeder.py +++ b/backend/app/services/skill_seeder.py @@ -563,84 +563,88 @@ async def seed_skills(): - """Insert builtin skills if they don't exist.""" + """创建内置技能,如果尚未创建过。 + + 幂等性保护(与 agent_seeder 统一模式): + 1. DB 标记:system_settings 表中 key="builtin_skills_seeded" + 2. DB 查询:检查是否已有 is_builtin=True 的技能 + 已执行过则跳过,用户删除后不重建。 + """ from app.services.skill_creator_content import get_skill_creator_files from pathlib import Path as _Path - _files_dir = _Path(__file__).parent / "skill_creator_files" - _template_skills_dir = _Path(__file__).parent.parent.parent / "agent_template" / "skills" - - # Populate skill-creator files at runtime - for s in BUILTIN_SKILLS: - if s["folder_name"] == "skill-creator" and not s["files"]: - s["files"] = get_skill_creator_files() - elif s["folder_name"] == "content-research-writer" and not s["files"]: - # Load from downloaded file - crw_file = _files_dir / "content_research_writer__SKILL.md" - if crw_file.exists(): - s["files"] = [{"path": "SKILL.md", "content": crw_file.read_text(encoding="utf-8")}] - elif s["folder_name"] == "mcp-installer" and not s["files"]: - mcp_file = _template_skills_dir / "MCP_INSTALLER.md" - if mcp_file.exists(): - s["files"] = [{"path": "SKILL.md", "content": mcp_file.read_text(encoding="utf-8")}] - else: - logger.warning("[SkillSeeder] MCP_INSTALLER.md not found in agent_template/skills/") - async with async_session() as db: + # ── 检查 1:DB 标记 ── + from app.models.system_settings import SystemSetting + marker = await db.execute( + select(SystemSetting).where(SystemSetting.key == "builtin_skills_seeded") + ) + if marker.scalar_one_or_none() is not None: + logger.info("[SkillSeeder] DB 标记已存在,跳过") + return + + # ── 检查 2:DB 中是否已有内置技能(兼容旧版本) ── + existing_result = await db.execute( + select(Skill).where(Skill.is_builtin == True) + ) + if existing_result.scalars().first() is not None: + logger.info("[SkillSeeder] DB 中已存在内置技能,补写标记并跳过") + db.add(SystemSetting( + key="builtin_skills_seeded", + value={"seeded_at": str(__import__("datetime").datetime.utcnow()), "source": "existing_data"} + )) + await db.commit() + return + + # ── 首次创建 ── + _files_dir = _Path(__file__).parent / "skill_creator_files" + _template_skills_dir = _Path(__file__).parent.parent.parent / "agent_template" / "skills" + + # 填充运行时文件内容 + for s in BUILTIN_SKILLS: + if s["folder_name"] == "skill-creator" and not s["files"]: + s["files"] = get_skill_creator_files() + elif s["folder_name"] == "content-research-writer" and not s["files"]: + crw_file = _files_dir / "content_research_writer__SKILL.md" + if crw_file.exists(): + s["files"] = [{"path": "SKILL.md", "content": crw_file.read_text(encoding="utf-8")}] + elif s["folder_name"] == "mcp-installer" and not s["files"]: + mcp_file = _template_skills_dir / "MCP_INSTALLER.md" + if mcp_file.exists(): + s["files"] = [{"path": "SKILL.md", "content": mcp_file.read_text(encoding="utf-8")}] + else: + logger.warning("[SkillSeeder] MCP_INSTALLER.md not found in agent_template/skills/") + for skill_data in BUILTIN_SKILLS: - result = await db.execute( - select(Skill).where(Skill.folder_name == skill_data["folder_name"]) - ) - existing = result.scalar_one_or_none() is_default = skill_data.get("is_default", False) - if existing: - # Update metadata - existing.name = skill_data["name"] - existing.description = skill_data["description"] - existing.category = skill_data["category"] - existing.icon = skill_data["icon"] - existing.is_default = is_default - # Sync files — add missing ones - from sqlalchemy.orm import selectinload - res2 = await db.execute( - select(Skill).where(Skill.id == existing.id).options(selectinload(Skill.files)) - ) - sk = res2.scalar_one() - existing_paths = {f.path: f for f in sk.files} - for f in skill_data["files"]: - if f["path"] in existing_paths: - # Update content if changed - existing_file = existing_paths[f["path"]] - if existing_file.content != f["content"]: - existing_file.content = f["content"] - logger.info(f"[SkillSeeder] Updated {f['path']} in {skill_data['name']}") - else: - db.add(SkillFile(skill_id=existing.id, path=f["path"], content=f["content"])) - logger.info(f"[SkillSeeder] Added file {f['path']} to {skill_data['name']}") - else: - skill = Skill( - name=skill_data["name"], - description=skill_data["description"], - category=skill_data["category"], - icon=skill_data["icon"], - folder_name=skill_data["folder_name"], - is_builtin=True, - is_default=is_default, - ) - db.add(skill) - await db.flush() - for f in skill_data["files"]: - db.add(SkillFile(skill_id=skill.id, path=f["path"], content=f["content"])) - logger.info(f"[SkillSeeder] Created skill: {skill_data['name']}") + skill = Skill( + name=skill_data["name"], + description=skill_data["description"], + category=skill_data["category"], + icon=skill_data["icon"], + folder_name=skill_data["folder_name"], + is_builtin=True, + is_default=is_default, + ) + db.add(skill) + await db.flush() + for f in skill_data["files"]: + db.add(SkillFile(skill_id=skill.id, path=f["path"], content=f["content"])) + logger.info(f"[SkillSeeder] 创建技能: {skill_data['name']}") + + db.add(SystemSetting( + key="builtin_skills_seeded", + value={"seeded_at": str(__import__("datetime").datetime.utcnow()), "source": "initial_seed"} + )) await db.commit() - logger.info("[SkillSeeder] Skills seeded") + logger.info("[SkillSeeder] 内置技能创建完成") async def push_default_skills_to_existing_agents(): - """Deploy all is_default skills into the workspace of every existing agent that is missing them. - - Called at startup after seed_skills() so existing agents automatically receive new default skills - like MCP_INSTALLER without requiring manual re-creation. + """将 is_default 技能部署到所有已有 Agent 的工作区。 + + 仅在 seed_skills() 首次创建技能时有意义。 + 如果 builtin_skills_seeded 标记已存在且不是本次刚写入的,说明技能没有变化,跳过扫描。 """ from pathlib import Path from app.models.agent import Agent @@ -649,6 +653,16 @@ async def push_default_skills_to_existing_agents(): from app.services.agent_manager import agent_manager async with async_session() as db: + # 检查标记来源:如果是 existing_data(旧数据补写),说明技能没变化,跳过推送 + from app.models.system_settings import SystemSetting + marker_r = await db.execute( + select(SystemSetting).where(SystemSetting.key == "builtin_skills_seeded") + ) + marker = marker_r.scalar_one_or_none() + if marker and marker.value and marker.value.get("source") == "existing_data": + logger.info("[SkillSeeder] 技能未变化(标记来源: existing_data),跳过推送") + return + # Load all is_default skills with their files default_skills_r = await db.execute( select(Skill).where(Skill.is_default == True).options(selectinload(Skill.files)) diff --git a/backend/app/services/template_seeder.py b/backend/app/services/template_seeder.py index 254148e95..ce4042166 100644 --- a/backend/app/services/template_seeder.py +++ b/backend/app/services/template_seeder.py @@ -158,59 +158,54 @@ async def seed_agent_templates(): - """Insert default agent templates if they don't exist. Update stale ones.""" + """创建内置 Agent 模板,如果尚未创建过。 + + 幂等性保护(与 agent_seeder 统一模式): + 1. DB 标记:system_settings 表中 key="builtin_templates_seeded" + 2. DB 查询:检查是否已有 is_builtin=True 的模板 + 已执行过则跳过,用户删除后不重建。 + """ async with async_session() as db: + # ── 检查 1:DB 标记 ── + from app.models.system_settings import SystemSetting + marker = await db.execute( + select(SystemSetting).where(SystemSetting.key == "builtin_templates_seeded") + ) + if marker.scalar_one_or_none() is not None: + logger.info("[TemplateSeeder] DB 标记已存在,跳过") + return + + # ── 检查 2:DB 中是否已有内置模板(兼容旧版本) ── + existing_result = await db.execute( + select(AgentTemplate).where(AgentTemplate.is_builtin == True) + ) + if existing_result.scalars().first() is not None: + logger.info("[TemplateSeeder] DB 中已存在内置模板,补写标记并跳过") + db.add(SystemSetting( + key="builtin_templates_seeded", + value={"seeded_at": str(__import__("datetime").datetime.utcnow()), "source": "existing_data"} + )) + await db.commit() + return + + # ── 首次创建 ── with db.no_autoflush: - # Remove old builtin templates that are no longer in our list - # BUT skip templates that are still referenced by agents - from app.models.agent import Agent - from sqlalchemy import func - - current_names = {t["name"] for t in DEFAULT_TEMPLATES} - result = await db.execute( - select(AgentTemplate).where(AgentTemplate.is_builtin == True) - ) - existing_builtins = result.scalars().all() - for old in existing_builtins: - if old.name not in current_names: - # Check if any agents still reference this template - ref_count = await db.execute( - select(func.count(Agent.id)).where(Agent.template_id == old.id) - ) - if ref_count.scalar() == 0: - await db.delete(old) - logger.info(f"[TemplateSeeder] Removed old template: {old.name}") - else: - logger.info(f"[TemplateSeeder] Skipping delete of '{old.name}' (still referenced by agents)") - - # Upsert new templates for tmpl in DEFAULT_TEMPLATES: - result = await db.execute( - select(AgentTemplate).where( - AgentTemplate.name == tmpl["name"], - AgentTemplate.is_builtin == True, - ) - ) - existing = result.scalar_one_or_none() - if existing: - # Update existing template - existing.description = tmpl["description"] - existing.icon = tmpl["icon"] - existing.category = tmpl["category"] - existing.soul_template = tmpl["soul_template"] - existing.default_skills = tmpl["default_skills"] - existing.default_autonomy_policy = tmpl["default_autonomy_policy"] - else: - db.add(AgentTemplate( - name=tmpl["name"], - description=tmpl["description"], - icon=tmpl["icon"], - category=tmpl["category"], - is_builtin=True, - soul_template=tmpl["soul_template"], - default_skills=tmpl["default_skills"], - default_autonomy_policy=tmpl["default_autonomy_policy"], - )) - logger.info(f"[TemplateSeeder] Created template: {tmpl['name']}") + db.add(AgentTemplate( + name=tmpl["name"], + description=tmpl["description"], + icon=tmpl["icon"], + category=tmpl["category"], + is_builtin=True, + soul_template=tmpl["soul_template"], + default_skills=tmpl["default_skills"], + default_autonomy_policy=tmpl["default_autonomy_policy"], + )) + logger.info(f"[TemplateSeeder] 创建模板: {tmpl['name']}") + + db.add(SystemSetting( + key="builtin_templates_seeded", + value={"seeded_at": str(__import__("datetime").datetime.utcnow()), "source": "initial_seed"} + )) await db.commit() - logger.info("[TemplateSeeder] Agent templates seeded") + logger.info("[TemplateSeeder] 内置模板创建完成") diff --git a/backend/app/services/tool_seeder.py b/backend/app/services/tool_seeder.py index 6e4697d99..13da6149d 100644 --- a/backend/app/services/tool_seeder.py +++ b/backend/app/services/tool_seeder.py @@ -1883,71 +1883,66 @@ ] async def seed_builtin_tools(): - """Insert or update builtin tools in the database.""" + """创建内置工具,如果尚未创建过。 + + 幂等性保护(与 agent_seeder 统一模式): + 1. DB 标记:system_settings 表中 key="builtin_tools_seeded" + 2. DB 查询:检查是否已有 source="builtin" 的工具 + 已执行过则跳过,用户删除后不重建。 + """ from app.models.tool import AgentTool from app.models.agent import Agent async with async_session() as db: + # ── 检查 1:DB 标记 ── + from app.models.system_settings import SystemSetting + marker = await db.execute( + select(SystemSetting).where(SystemSetting.key == "builtin_tools_seeded") + ) + if marker.scalar_one_or_none() is not None: + logger.info("[ToolSeeder] DB 标记已存在,跳过") + return + + # ── 检查 2:DB 中是否已有内置工具(兼容旧版本) ── + existing_result = await db.execute( + select(Tool).where(Tool.source == "builtin") + ) + if existing_result.scalars().first() is not None: + logger.info("[ToolSeeder] DB 中已存在内置工具,补写标记并跳过") + db.add(SystemSetting( + key="builtin_tools_seeded", + value={"seeded_at": str(__import__("datetime").datetime.utcnow()), "source": "existing_data"} + )) + await db.commit() + return + + # ── 首次创建 ── new_tool_ids = [] for t in BUILTIN_TOOLS: - result = await db.execute(select(Tool).where(Tool.name == t["name"])) - existing = result.scalar_one_or_none() - if not existing: - tool = Tool( - name=t["name"], - display_name=t["display_name"], - description=t["description"], - type="builtin", - category=t["category"], - icon=t["icon"], - is_default=t["is_default"], - config=t.get("config", {}), - config_schema=t.get("config_schema", {}), - source="builtin", - ) - db.add(tool) - await db.flush() # get tool.id - if t["is_default"]: - new_tool_ids.append(tool.id) - logger.info(f"[ToolSeeder] Created builtin tool: {t['name']}") - else: - # Sync fields that may evolve - updated_fields = [] - if existing.category != t["category"]: - existing.category = t["category"] - updated_fields.append("category") - if existing.description != t["description"]: - existing.description = t["description"] - updated_fields.append("description") - if existing.display_name != t["display_name"]: - existing.display_name = t["display_name"] - updated_fields.append("display_name") - if existing.icon != t["icon"]: - existing.icon = t["icon"] - updated_fields.append("icon") - if t.get("config_schema") and existing.config_schema != t["config_schema"]: - existing.config_schema = t["config_schema"] - updated_fields.append("config_schema") - # Merge new config defaults when config_schema changes - if t.get("config"): - existing.config = {**t["config"], **(existing.config or {})} - updated_fields.append("config") - if not existing.config and t.get("config"): - existing.config = t["config"] - updated_fields.append("config") - if existing.parameters_schema != t["parameters_schema"]: - existing.parameters_schema = t["parameters_schema"] - updated_fields.append("parameters_schema") - if updated_fields: - logger.info(f"[ToolSeeder] Updated {', '.join(updated_fields)}: {t['name']}") + tool = Tool( + name=t["name"], + display_name=t["display_name"], + description=t["description"], + type="builtin", + category=t["category"], + icon=t["icon"], + is_default=t["is_default"], + config=t.get("config", {}), + config_schema=t.get("config_schema", {}), + source="builtin", + ) + db.add(tool) + await db.flush() + if t["is_default"]: + new_tool_ids.append(tool.id) + logger.info(f"[ToolSeeder] 创建工具: {t['name']}") - # Auto-assign new default tools to all existing agents + # 将默认工具分配给所有已有 Agent if new_tool_ids: agents_result = await db.execute(select(Agent.id)) agent_ids = [row[0] for row in agents_result.fetchall()] for agent_id in agent_ids: for tool_id in new_tool_ids: - # Check if already assigned check = await db.execute( select(AgentTool).where( AgentTool.agent_id == agent_id, @@ -1956,15 +1951,14 @@ async def seed_builtin_tools(): ) if not check.scalar_one_or_none(): db.add(AgentTool(agent_id=agent_id, tool_id=tool_id, enabled=True)) - logger.info(f"[ToolSeeder] Auto-assigned {len(new_tool_ids)} new tools to {len(agent_ids)} agents") + logger.info(f"[ToolSeeder] 为 {len(agent_ids)} 个 Agent 分配了 {len(new_tool_ids)} 个默认工具") - OBSOLETE_TOOLS = ["bing_search", "read_webpage", "manage_tasks"] - for obsolete_name in OBSOLETE_TOOLS: - result = await db.execute(select(Tool).where(Tool.name == obsolete_name)) - obsolete = result.scalar_one_or_none() - if obsolete: - await db.delete(obsolete) - logger.info(f"[ToolSeeder] Removed obsolete tool: {obsolete_name}") + db.add(SystemSetting( + key="builtin_tools_seeded", + value={"seeded_at": str(__import__("datetime").datetime.utcnow()), "source": "initial_seed"} + )) + await db.commit() + logger.info("[ToolSeeder] 内置工具创建完成") await db.commit() logger.info("[ToolSeeder] Builtin tools seeded") @@ -2037,55 +2031,65 @@ async def clean_orphaned_mcp_tools(): async def seed_atlassian_rovo_config(): - """Ensure the Atlassian Rovo platform config tool exists in the database. + """创建 Atlassian Rovo 平台配置工具,如果尚未创建过。 - If the env var ATLASSIAN_API_KEY is set, it will be written into the tool config - so the platform is immediately ready without manual UI setup. + 幂等性保护(与其他 seeder 统一模式): + 1. DB 标记:system_settings 表中 key="atlassian_rovo_config_seeded" + 2. DB 查询:检查是否已有该工具 + 已执行过则跳过,用户删除后不重建。 """ import os env_key = os.environ.get("ATLASSIAN_API_KEY", "").strip() async with async_session() as db: + # ── 检查 1:DB 标记 ── + from app.models.system_settings import SystemSetting + marker = await db.execute( + select(SystemSetting).where(SystemSetting.key == "atlassian_rovo_config_seeded") + ) + if marker.scalar_one_or_none() is not None: + logger.info("[ToolSeeder] Atlassian Rovo 配置 DB 标记已存在,跳过") + return + + # ── 检查 2:DB 中是否已有该工具(兼容旧版本) ── t = ATLASSIAN_ROVO_CONFIG_TOOL result = await db.execute(select(Tool).where(Tool.name == t["name"])) existing = result.scalar_one_or_none() - if not existing: - initial_config = dict(t["config"]) - if env_key: - initial_config["api_key"] = env_key - tool = Tool( - name=t["name"], - display_name=t["display_name"], - description=t["description"], - type="mcp_config", - category=t["category"], - icon=t["icon"], - is_default=t["is_default"], - parameters_schema=t["parameters_schema"], - config=initial_config, - config_schema=t["config_schema"], - mcp_server_url=ATLASSIAN_ROVO_MCP_URL, - mcp_server_name="Atlassian Rovo", - source="admin", - ) - db.add(tool) + if existing: + logger.info("[ToolSeeder] Atlassian Rovo 配置已存在,补写标记并跳过") + db.add(SystemSetting( + key="atlassian_rovo_config_seeded", + value={"seeded_at": str(__import__("datetime").datetime.utcnow()), "source": "existing_data"} + )) await db.commit() - logger.info("[ToolSeeder] Created Atlassian Rovo config tool") - else: - updated = False - if existing.config_schema != t["config_schema"]: - existing.config_schema = t["config_schema"] - updated = True - if existing.mcp_server_url != ATLASSIAN_ROVO_MCP_URL: - existing.mcp_server_url = ATLASSIAN_ROVO_MCP_URL - updated = True - # Write env key into DB if not already stored - if env_key and (not existing.config or not existing.config.get("api_key")): - existing.config = {**(existing.config or {}), "api_key": env_key} - updated = True - if updated: - await db.commit() - logger.info("[ToolSeeder] Updated Atlassian Rovo config tool") + return + + # ── 首次创建 ── + initial_config = dict(t["config"]) + if env_key: + initial_config["api_key"] = env_key + tool = Tool( + name=t["name"], + display_name=t["display_name"], + description=t["description"], + type="mcp_config", + category=t["category"], + icon=t["icon"], + is_default=t["is_default"], + parameters_schema=t["parameters_schema"], + config=initial_config, + config_schema=t["config_schema"], + mcp_server_url=ATLASSIAN_ROVO_MCP_URL, + mcp_server_name="Atlassian Rovo", + source="admin", + ) + db.add(tool) + db.add(SystemSetting( + key="atlassian_rovo_config_seeded", + value={"seeded_at": str(__import__("datetime").datetime.utcnow()), "source": "initial_seed"} + )) + await db.commit() + logger.info("[ToolSeeder] Atlassian Rovo 配置工具创建完成") async def get_atlassian_api_key() -> str: