feat: 会商链条启停与深度研究报告结构,并完善问答展示与 8BF 返回。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-19 20:37:34 +08:00
1 parent be4851555d
commit 0ce060693b
42 files changed
+5320 -3247

No files matched your search

@@ -482,6 +482,16 @@ Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊
### Position-collaboration foundation
### Roundtable business-chain enable/disable
`roundtable_chains.enabled` (BOOLEAN NOT NULL DEFAULT 1, migration `20260819_02`)
is the per-chain 启停 switch. New and existing chains default to **enabled**.
Owner or admin can flip it via `PUT /api/roundtable-chains/{id}` `{enabled}`.
Disabled chains stay visible in the management list (with an 启用/停用 filter)
and still show `updated_at`; the frontend hides them from the picker / public
entry rail so they cannot be selected into a roundtable. Tests:
`tests/test_roundtable_chain_stages.py` (`test_create_defaults_enabled_then_toggle`).
### Roundtable business-chain human validation
The existing `/api/roundtable-chains` `seats` JSON also accepts the optional
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -64,15 +64,20 @@ class AgentResponse(BaseModel):
description="入库模板名 (external knowledge-ingest templateName). When set, markdown artifacts produced in this agent's chats can be published to the external knowledge-ingest API.",
)
seat_skill_directive: bool | None = Field(
seat_skill_directive: bool | None = Field(
default=None,
description="会商席位「技能识别强化」开关 (config.yaml). True = 本智能体作为会商席位时强化技能识别 (与业务链条同名开关取 OR). None/False = 关 (默认).",
)
llmwiki_knowledge_base_ids: list[str] | None = Field(
default=None,
description="DeerFlow LLMWiki mapping ids this agent may offer for conversation retrieval.",
)
)
job_responsibilities: str | None = Field(
default=None,
description="岗位职责 (config.yaml). Shown above 常用问题 in the agent configuration panel. None/empty = not set.",
)
llmwiki_knowledge_base_ids: list[str] | None = Field(
default=None,
description="DeerFlow LLMWiki mapping ids this agent may offer for conversation retrieval.",
)
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
@@ -125,12 +130,13 @@ class AgentCreateRequest(BaseModel):
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
common_questions: list[CommonQuestion] | None = Field(default=None, description="Recommended questions (常用问题) persisted to config.yaml. Each item is {title, prompt}; legacy strings are coerced.")
template_name: str | None = Field(default=None, description="入库模板名 persisted to config.yaml. Enables publishing markdown artifacts to the external knowledge-ingest API.")
seat_skill_directive: bool | None = Field(default=None, description="会商席位「技能识别强化」开关 persisted to config.yaml. None/False = off (默认).")
llmwiki_knowledge_base_ids: list[str] | None = Field(
default=None,
max_length=100,
description="Allowed DeerFlow LLMWiki mapping ids persisted to config.yaml.",
)
seat_skill_directive: bool | None = Field(default=None, description="会商席位「技能识别强化」开关 persisted to config.yaml. None/False = off (默认).")
job_responsibilities: str | None = Field(default=None, description="岗位职责 persisted to config.yaml. Empty/None = not set.")
llmwiki_knowledge_base_ids: list[str] | None = Field(
default=None,
max_length=100,
description="Allowed DeerFlow LLMWiki mapping ids persisted to config.yaml.",
)
class AgentUpdateRequest(BaseModel):
@@ -147,12 +153,13 @@ class AgentUpdateRequest(BaseModel):
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
common_questions: list[CommonQuestion] | None = Field(default=None, description="Updated recommended questions (常用问题, config.yaml). Each item is {title, prompt}; legacy strings are coerced.")
template_name: str | None = Field(default=None, description="Updated 入库模板名 (config.yaml). Empty string clears it.")
seat_skill_directive: bool | None = Field(default=None, description="Updated 会商席位「技能识别强化」开关 (config.yaml).")
llmwiki_knowledge_base_ids: list[str] | None = Field(
default=None,
max_length=100,
description="Updated allowed DeerFlow LLMWiki mapping ids (config.yaml).",
)
seat_skill_directive: bool | None = Field(default=None, description="Updated 会商席位「技能识别强化」开关 (config.yaml).")
job_responsibilities: str | None = Field(default=None, description="Updated 岗位职责 (config.yaml). Empty string clears it.")
llmwiki_knowledge_base_ids: list[str] | None = Field(
default=None,
max_length=100,
description="Updated allowed DeerFlow LLMWiki mapping ids (config.yaml).",
)
class AgentSelectorResponse(BaseModel):
@@ -193,38 +200,38 @@ def _current_user_id(request: Request) -> str:
return get_effective_user_id()
def _is_admin_user(request: Request) -> bool:
def _is_admin_user(request: Request) -> bool:
"""Return True when the request is authenticated as a system admin."""
user = getattr(request.state, "user", None)
return getattr(user, "system_role", None) == "admin"
async def _validate_llmwiki_bindings(
request: Request,
mapping_ids: list[str] | None,
) -> list[str] | None:
"""Normalize bindings and require read access for the editing actor."""
if mapping_ids is None:
return None
normalized = list(dict.fromkeys(item.strip() for item in mapping_ids if item.strip()))
if not normalized:
return []
store = getattr(request.app.state, "llmwiki_store", None)
if store is None:
raise HTTPException(status_code=503, detail="LLMWiki metadata store is unavailable")
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
for mapping_id in normalized:
row = await store.get_authorized(
mapping_id,
user_id,
write=False,
is_admin=is_admin,
)
if row is None:
raise HTTPException(status_code=422, detail=f"LLMWiki knowledge base '{mapping_id}' is unavailable")
return normalized
return getattr(user, "system_role", None) == "admin"
async def _validate_llmwiki_bindings(
request: Request,
mapping_ids: list[str] | None,
) -> list[str] | None:
"""Normalize bindings and require read access for the editing actor."""
if mapping_ids is None:
return None
normalized = list(dict.fromkeys(item.strip() for item in mapping_ids if item.strip()))
if not normalized:
return []
store = getattr(request.app.state, "llmwiki_store", None)
if store is None:
raise HTTPException(status_code=503, detail="LLMWiki metadata store is unavailable")
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
for mapping_id in normalized:
row = await store.get_authorized(
mapping_id,
user_id,
write=False,
is_admin=is_admin,
)
if row is None:
raise HTTPException(status_code=422, detail=f"LLMWiki knowledge base '{mapping_id}' is unavailable")
return normalized
def _email_local_part(email: str | None) -> str | None:
@@ -276,7 +283,7 @@ def _validate_storage_id(agent_id: str) -> str:
raise HTTPException(status_code=422, detail=str(exc)) from exc
def _write_agent_config(
def _write_agent_config(
agent_id: str,
*,
name: str,
@@ -286,9 +293,10 @@ def _write_agent_config(
tool_groups: list[str] | None = None,
common_questions: list[CommonQuestion] | None = None,
template_name: str | None = None,
seat_skill_directive: bool | None = None,
llmwiki_knowledge_base_ids: list[str] | None = None,
) -> None:
seat_skill_directive: bool | None = None,
job_responsibilities: str | None = None,
llmwiki_knowledge_base_ids: list[str] | None = None,
) -> None:
agent_dir = get_paths().agent_dir(agent_id)
agent_dir.mkdir(parents=True, exist_ok=True)
config_file = agent_dir / "config.yaml"
@@ -317,19 +325,30 @@ def _write_agent_config(
if template_name is not None and template_name.strip():
config_data["template_name"] = template_name.strip()
# 会商席位技能识别强化开关:仅当显式设置时写入(None = 不写, 由 AgentConfig 默认为关)。
if seat_skill_directive is not None:
config_data["seat_skill_directive"] = bool(seat_skill_directive)
if llmwiki_knowledge_base_ids is not None:
config_data["llmwiki_knowledge_base_ids"] = list(
dict.fromkeys(item.strip() for item in llmwiki_knowledge_base_ids if item.strip())
)
if seat_skill_directive is not None:
config_data["seat_skill_directive"] = bool(seat_skill_directive)
if job_responsibilities is not None and job_responsibilities.strip():
config_data["job_responsibilities"] = job_responsibilities.strip()
if llmwiki_knowledge_base_ids is not None:
config_data["llmwiki_knowledge_base_ids"] = list(
dict.fromkeys(item.strip() for item in llmwiki_knowledge_base_ids if item.strip())
)
with open(config_file, "w", encoding="utf-8") as f:
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
def _load_config_extras(agent_id: str) -> dict[str, Any]:
"""Read skills/model/tool_groups/common_questions from the agent's config.yaml, tolerant of missing files."""
empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None, "llmwiki_knowledge_base_ids": None}
empty = {
"skills": None,
"model": None,
"tool_groups": None,
"common_questions": None,
"template_name": None,
"seat_skill_directive": None,
"job_responsibilities": None,
"llmwiki_knowledge_base_ids": None,
}
try:
cfg = load_agent_config(agent_id)
except (FileNotFoundError, ValueError):
@@ -342,8 +361,9 @@ def _load_config_extras(agent_id: str) -> dict[str, Any]:
"tool_groups": cfg.tool_groups,
"common_questions": cfg.common_questions,
"template_name": cfg.template_name,
"seat_skill_directive": cfg.seat_skill_directive,
"llmwiki_knowledge_base_ids": cfg.llmwiki_knowledge_base_ids,
"seat_skill_directive": cfg.seat_skill_directive,
"job_responsibilities": cfg.job_responsibilities,
"llmwiki_knowledge_base_ids": cfg.llmwiki_knowledge_base_ids,
}
@@ -504,7 +524,16 @@ def _record_to_response(
can happen lazily when the agent is actually opened.
"""
soul: str | None = None
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None, "llmwiki_knowledge_base_ids": None}
extras: dict[str, Any] = {
"skills": None,
"model": None,
"tool_groups": None,
"common_questions": None,
"template_name": None,
"seat_skill_directive": None,
"job_responsibilities": None,
"llmwiki_knowledge_base_ids": None,
}
if include_extras:
_ensure_agent_dir_from_record(record)
extras = _load_config_extras(record["id"])
@@ -528,8 +557,9 @@ def _record_to_response(
skills=extras["skills"],
common_questions=extras.get("common_questions"),
template_name=extras.get("template_name"),
seat_skill_directive=extras.get("seat_skill_directive"),
llmwiki_knowledge_base_ids=extras.get("llmwiki_knowledge_base_ids"),
seat_skill_directive=extras.get("seat_skill_directive"),
job_responsibilities=extras.get("job_responsibilities"),
llmwiki_knowledge_base_ids=extras.get("llmwiki_knowledge_base_ids"),
featured_order=record.get("featured_order"),
)
@@ -830,7 +860,7 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
summary="Create Agent",
description="Create a new user-owned agent. The display name can be Chinese and may duplicate existing names.",
)
async def create_agent_endpoint(request: AgentCreateRequest, http_request: Request) -> AgentResponse:
async def create_agent_endpoint(request: AgentCreateRequest, http_request: Request) -> AgentResponse:
_require_agents_api_enabled()
name = _validate_display_name(request.name)
store = get_agent_store(http_request)
@@ -839,13 +869,13 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
agent_id = _validate_storage_id(request.id)
if await store.get_any(agent_id) is not None:
raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
else:
agent_id = uuid4().hex
agent_dir = get_paths().agent_dir(agent_id)
llmwiki_ids = await _validate_llmwiki_bindings(
http_request,
request.llmwiki_knowledge_base_ids,
)
else:
agent_id = uuid4().hex
agent_dir = get_paths().agent_dir(agent_id)
llmwiki_ids = await _validate_llmwiki_bindings(
http_request,
request.llmwiki_knowledge_base_ids,
)
try:
if agent_dir.exists():
@@ -860,8 +890,9 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
tool_groups=request.tool_groups,
common_questions=request.common_questions,
template_name=request.template_name,
seat_skill_directive=request.seat_skill_directive,
llmwiki_knowledge_base_ids=llmwiki_ids,
seat_skill_directive=request.seat_skill_directive,
job_responsibilities=request.job_responsibilities,
llmwiki_knowledge_base_ids=llmwiki_ids,
)
_write_agent_soul(agent_id, request.soul)
@@ -942,14 +973,15 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"]
next_template_name = request.template_name if "template_name" in fields_set else existing_extras["template_name"]
next_seat_skill_directive = request.seat_skill_directive if "seat_skill_directive" in fields_set else existing_extras["seat_skill_directive"]
next_llmwiki_ids = (
await _validate_llmwiki_bindings(http_request, request.llmwiki_knowledge_base_ids)
if "llmwiki_knowledge_base_ids" in fields_set
else existing_extras["llmwiki_knowledge_base_ids"]
)
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name", "seat_skill_directive", "llmwiki_knowledge_base_ids"))
next_seat_skill_directive = request.seat_skill_directive if "seat_skill_directive" in fields_set else existing_extras["seat_skill_directive"]
next_job_responsibilities = request.job_responsibilities if "job_responsibilities" in fields_set else existing_extras["job_responsibilities"]
next_llmwiki_ids = (
await _validate_llmwiki_bindings(http_request, request.llmwiki_knowledge_base_ids)
if "llmwiki_knowledge_base_ids" in fields_set
else existing_extras["llmwiki_knowledge_base_ids"]
)
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name", "seat_skill_directive", "job_responsibilities", "llmwiki_knowledge_base_ids"))
if config_dirty:
_write_agent_config(
agent_id,
@@ -960,8 +992,9 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
tool_groups=next_tool_groups,
common_questions=next_common_questions,
template_name=next_template_name,
seat_skill_directive=next_seat_skill_directive,
llmwiki_knowledge_base_ids=next_llmwiki_ids,
seat_skill_directive=next_seat_skill_directive,
job_responsibilities=next_job_responsibilities,
llmwiki_knowledge_base_ids=next_llmwiki_ids,
)
# Keep the listing cache in lock-step with config.yaml, but only
# when the cached fields were actually touched — a partial update
@@ -0,0 +1,168 @@
"""CRUD API for deep-research report structures (报告结构 / 工作流配置).
The deep-research workbench's 高级配置 → 工作流配置 dialog renders a card per
row; picking a card applies ``content`` to the research config's
``custom_outline``. Global / shared — every account sees the same cards.
Routes (prefix ``/api/report-structures``):
GET "" list (optional ``enabled`` / ``title`` filters)
POST "" create (title / name / content; enabled forced True)
GET "/{id}" get one
PUT "/{id}" update title / name / content
PUT "/{id}/enabled" toggle 启用/停用
DELETE "/{id}" delete
The first list call seeds the two built-in outlines (通用分析结构 /
递进式论证结构) when the table has never held a row — see
``deerflow.persistence.report_structures.defaults``.
"""
from __future__ import annotations
import logging
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_optional_user_from_request
from deerflow.persistence.report_structures import ReportStructureStore
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/report-structures", tags=["report-structures"])
# ── schemas ──────────────────────────────────────────────────────────────
class ReportStructureResponse(BaseModel):
id: str
title: str
name: str
content: str
enabled: bool = True
created_by: str | None = None
created_at: str | None = None
updated_at: str | None = None
class ReportStructureListResponse(BaseModel):
structures: list[ReportStructureResponse]
class ReportStructureCreateRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=256)
name: str = Field(default="", max_length=256)
content: str = Field(..., min_length=1, max_length=20_000)
class ReportStructureUpdateRequest(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=256)
name: str | None = Field(default=None, max_length=256)
content: str | None = Field(default=None, min_length=1, max_length=20_000)
class ReportStructureEnabledRequest(BaseModel):
enabled: bool
# ── helpers ──────────────────────────────────────────────────────────────
def _get_store(request: Request) -> ReportStructureStore:
store = getattr(request.app.state, "report_structure_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Report structure store not available")
return store
async def _current_username(request: Request) -> str | None:
"""创建用户名:邮箱(本部署的账号即邮箱),无认证时为 None。"""
user = await get_optional_user_from_request(request)
if user is None:
return None
return str(user.email) or str(user.id)
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=ReportStructureListResponse)
async def list_report_structures(
request: Request,
enabled: bool | None = Query(default=None, description="启用/停用筛选;缺省=全部"),
title: str | None = Query(default=None, max_length=256, description="标题模糊筛选(大小写不敏感)"),
) -> ReportStructureListResponse:
store = _get_store(request)
await store.ensure_defaults()
rows = await store.list_structures()
if enabled is not None:
rows = [r for r in rows if bool(r.get("enabled", True)) is enabled]
if title:
needle = title.strip().lower()
if needle:
rows = [r for r in rows if needle in str(r.get("title", "")).lower()]
return ReportStructureListResponse(structures=[ReportStructureResponse(**r) for r in rows])
@router.post("", response_model=ReportStructureResponse, status_code=201)
async def create_report_structure(request: Request, body: ReportStructureCreateRequest) -> ReportStructureResponse:
store = _get_store(request)
row = await store.create_structure(
{
"id": str(uuid4()),
"title": body.title.strip(),
"name": body.name.strip(),
"content": body.content.strip(),
"created_by": await _current_username(request),
}
)
return ReportStructureResponse(**row)
@router.get("/{structure_id}", response_model=ReportStructureResponse)
async def get_report_structure(request: Request, structure_id: str) -> ReportStructureResponse:
store = _get_store(request)
row = await store.get_structure(structure_id)
if row is None:
raise HTTPException(status_code=404, detail="报告结构不存在")
return ReportStructureResponse(**row)
@router.put("/{structure_id}", response_model=ReportStructureResponse)
async def update_report_structure(
request: Request, structure_id: str, body: ReportStructureUpdateRequest
) -> ReportStructureResponse:
store = _get_store(request)
data: dict = {}
if body.title is not None:
data["title"] = body.title.strip()
if body.name is not None:
data["name"] = body.name.strip()
if body.content is not None:
data["content"] = body.content.strip()
if not data:
raise HTTPException(status_code=400, detail="没有需要更新的字段")
row = await store.update_structure(structure_id, data)
if row is None:
raise HTTPException(status_code=404, detail="报告结构不存在")
return ReportStructureResponse(**row)
@router.put("/{structure_id}/enabled", response_model=ReportStructureResponse)
async def set_report_structure_enabled(
request: Request, structure_id: str, body: ReportStructureEnabledRequest
) -> ReportStructureResponse:
store = _get_store(request)
row = await store.set_enabled(structure_id, body.enabled)
if row is None:
raise HTTPException(status_code=404, detail="报告结构不存在")
return ReportStructureResponse(**row)
@router.delete("/{structure_id}", status_code=204)
async def delete_report_structure(request: Request, structure_id: str) -> None:
store = _get_store(request)
deleted = await store.delete_structure(structure_id)
if not deleted:
raise HTTPException(status_code=404, detail="报告结构不存在")
@@ -13,8 +13,9 @@ Routes (prefix ``/api/roundtable-chains``):
DELETE "/{chain_id}" delete
管理员权限:``system_role == "admin"`` 的用户(``_is_admin``)可 list ``scope=all``
(所有账号的全部链条),并可 get / 编辑 / 发布 / 删除**任意** owner 的链条;非管理员
(所有账号的全部链条),并可 get / 编辑 / 发布 / 启停 / 删除**任意** owner 的链条;非管理员
仍仅限本人链条(``scope=all`` 退化为 ``mine``)。编辑/删除不转移链条归属(``user_id`` 不变)。
``enabled`` 默认 True:停用后管理列表仍可见,但选链弹窗 / 公共入口不再选用。
"""
from __future__ import annotations
@@ -90,6 +91,8 @@ class ChainResponse(BaseModel):
gather_first: bool = False
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
is_public: bool = False
# 启停状态:True = 启用(可选入研讨);False = 停用。缺省 True。
enabled: bool = True
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
is_mine: bool = True
# 配置者(owner)的 user_id 及解析后的可读用户名(email)。owner_name 由路由层
@@ -116,6 +119,7 @@ class ChainCreateRequest(BaseModel):
coordinator_prompt: str | None = Field(default=None, max_length=4000)
seat_mode: str | None = Field(default=None, max_length=16)
gather_first: bool = False
enabled: bool = True
class ChainUpdateRequest(BaseModel):
@@ -131,6 +135,8 @@ class ChainUpdateRequest(BaseModel):
gather_first: bool | None = None
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
is_public: bool | None = None
# 启停开关(True 启用 / False 停用);仅 owner / 管理员可改。omit 不改。
enabled: bool | None = None
# ── helpers ──────────────────────────────────────────────────────────────
@@ -329,6 +335,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
"coordinator_prompt": body.coordinator_prompt,
"seat_mode": body.seat_mode,
"gather_first": body.gather_first,
"enabled": body.enabled,
},
)
await _enrich_owner_names([row])
@@ -7,7 +7,7 @@ import threading
from typing import Any
import yaml
from pydantic import BaseModel, field_validator, model_validator
from pydantic import BaseModel, field_validator, model_validator
from deerflow.config.paths import get_paths
@@ -82,7 +82,7 @@ class CommonQuestion(BaseModel):
return value
class AgentConfig(BaseModel):
class AgentConfig(BaseModel):
"""Configuration for a custom agent."""
id: str | None = None
@@ -108,20 +108,23 @@ class AgentConfig(BaseModel):
# 拼进本轮任务,避免弱模型无视系统提示里的技能块。None/False = 关(默认);True = 开。
# 与「业务链条配置」里的同名开关取 OR:任一打开即对该席位生效。见
# ``app/gateway/roundtable_seat_skills.py``。
seat_skill_directive: bool | None = None
# DeerFlow mapping ids this agent may offer in its new-conversation
# knowledge selector. WeKnora ids and credentials never enter agent files.
llmwiki_knowledge_base_ids: list[str] | None = None
@field_validator("llmwiki_knowledge_base_ids")
@classmethod
def _normalize_llmwiki_ids(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
normalized = list(dict.fromkeys(str(item).strip() for item in value if str(item).strip()))
if len(normalized) > 100:
raise ValueError("An agent can bind at most 100 LLMWiki knowledge bases")
return normalized
seat_skill_directive: bool | None = None
# 岗位职责:会商智能体配置面板展示用描述。Display/UX metadata only —
# the runtime never reads this. None/empty = not set.
job_responsibilities: str | None = None
# DeerFlow mapping ids this agent may offer in its new-conversation
# knowledge selector. WeKnora ids and credentials never enter agent files.
llmwiki_knowledge_base_ids: list[str] | None = None
@field_validator("llmwiki_knowledge_base_ids")
@classmethod
def _normalize_llmwiki_ids(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
normalized = list(dict.fromkeys(str(item).strip() for item in value if str(item).strip()))
if len(normalized) > 100:
raise ValueError("An agent can bind at most 100 LLMWiki knowledge bases")
return normalized
def load_agent_config(agent_id: str | None) -> AgentConfig | None:
@@ -0,0 +1,51 @@
"""Add enabled column to roundtable_chains.
Per-chain 启停 switch: True = 可在选链弹窗 / 公共入口里选用;False = 停用(管理列表
仍可见,但不可选入研讨)。Default True → existing chains stay enabled. BOOLEAN NOT
NULL DEFAULT 1, matching ``RoundtableChainRow.enabled``.
``create_all`` adds the column for brand-new DBs; the startup ``_ensure_orm_columns_sync``
sweep adds it to already-existing tables (sqlite/mysql/postgres). This revision keeps the
alembic graph consistent with the rest of the chain columns and documents the change.
Revision ID: 20260819_02
Revises: 20260819_01
Create Date: 2026-08-19
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260819_02"
down_revision = "20260819_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "enabled"):
op.add_column(
"roundtable_chains",
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
)
def downgrade() -> None:
if _has_column("roundtable_chains", "enabled"):
op.drop_column("roundtable_chains", "enabled")
@@ -0,0 +1,22 @@
"""Deep-research report-structure persistence (报告结构 / 工作流配置)."""
from __future__ import annotations
from deerflow.persistence.report_structures.base import ReportStructureStore
from deerflow.persistence.report_structures.memory import MemoryReportStructureStore
from deerflow.persistence.report_structures.model import ReportStructureRow
from deerflow.persistence.report_structures.sql import ReportStructureRepository
__all__ = [
"MemoryReportStructureStore",
"ReportStructureRepository",
"ReportStructureRow",
"ReportStructureStore",
"make_report_structure_store",
]
def make_report_structure_store(session_factory):
if session_factory is None:
return MemoryReportStructureStore()
return ReportStructureRepository(session_factory)
@@ -0,0 +1,64 @@
"""Abstract report-structure persistence API (报告结构 / 工作流配置).
Global / shared store: no ``user_id`` scoping — the deep-research 工作流配置
dialog shows the same cards to every account. ``created_by`` is a display-only
audit field (创建用户名). Rows are managed per-item (create / update /
toggle-enabled / delete); ``ensure_defaults`` lazily seeds the two built-in
outlines the first time the table is empty so a fresh deployment renders the
same 通用分析结构 / 递进式论证结构 cards the frontend used to hardcode.
"""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from typing import Any
from deerflow.persistence.report_structures.defaults import DEFAULT_REPORT_STRUCTURE_ROWS
logger = logging.getLogger(__name__)
class ReportStructureStore(ABC):
@abstractmethod
async def list_structures(self) -> list[dict[str, Any]]:
"""Return every stored structure, most recently modified first."""
raise NotImplementedError
@abstractmethod
async def get_structure(self, structure_id: str) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def create_structure(self, data: dict[str, Any]) -> dict[str, Any]:
"""Insert ``{id, title, name, content, created_by}`` (enabled forced True)."""
raise NotImplementedError
@abstractmethod
async def update_structure(self, structure_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
"""Update any of ``title`` / ``name`` / ``content``. None when missing."""
raise NotImplementedError
@abstractmethod
async def set_enabled(self, structure_id: str, enabled: bool) -> dict[str, Any] | None:
"""Flip 启用/停用 (also bumps updated_at). None when missing."""
raise NotImplementedError
@abstractmethod
async def delete_structure(self, structure_id: str) -> bool:
raise NotImplementedError
@abstractmethod
async def count(self) -> int:
raise NotImplementedError
async def ensure_defaults(self) -> None:
"""Seed the built-in outlines when the table has never held a row.
Concurrent first reads can race; seed ids are fixed so a losing insert
fails on the primary key and is simply skipped.
"""
if await self.count() > 0:
return
for row in DEFAULT_REPORT_STRUCTURE_ROWS:
await self.create_structure(dict(row))
@@ -0,0 +1,269 @@
"""深度研究「报告结构」默认种子(新机器部署开箱即有的内置结构库)。
前两套(通用分析结构 / 递进式论证结构)逐字提取自前端
``frontend-web/src/pages/deep-research/ReportConfigCard.tsx`` 的
``DEFAULT_REPORT_OUTLINE`` / ``PROGRESSIVE_REPORT_OUTLINE``;其余三套为本仓库
补充的常用研究结构(快速简报 / 事件分析 / 对象调查)。
表为空(全新部署、从未写过数据)时 ``ensure_defaults`` 会把全部内置结构插入
``report_structures``;已有数据的库不会追加。若调优了某套结构,请同步更新
本文件,否则新机器补建出来的还是旧版。
"""
from __future__ import annotations
GENERAL_OUTLINE = """# 报告标题
# 引言
用 1–2 段说明研究对象、提出本议题的背景、研究范围及资料边界;简要交代全文的分析路径,不单独拆分小节。
# 一、研究对象与事实基础
## 1.1 概念、范围与背景
界定研究对象、关键概念、时间和空间范围;说明读者理解问题所需的背景。
## 1.2 已有事实与证据
按时间或逻辑顺序梳理已知事实、数据、案例或材料发现;优先使用可核验的来源。
## 1.3 当前状态与主要特征
归纳研究对象目前呈现的状态、关键变化、共性与差异;没有数据支撑时明确说明。
# 二、问题形成与作用机制
## 2.1 直接原因与触发条件
分析近期事件、条件变化或关键节点如何推动问题出现或演变。
## 2.2 深层影响因素
从制度、技术、资源、行为、环境或历史条件中选择适用维度,分析长期影响。
## 2.3 因果关系与相互作用
说明不同因素如何共同作用;区分来源已证实的事实、分析判断和仍待验证的推断。
# 三、影响、问题与不同观点
## 3.1 对研究对象的直接影响
从结果、效率、质量、风险、成本、体验或其他适用维度,分析当前影响。
## 3.2 对相关主体与外部环境的影响
说明影响如何传导到相关群体、组织、地区、系统或后续事项;不适用的维度可省略。
## 3.3 主要争议与解释差异
并列材料支持的不同观点、解释或结论,并说明各自的证据基础与局限。
# 四、比较、趋势与不确定性
## 4.1 可比案例或横向比较
在材料允许时选择历史案例、不同地区、不同方案或不同阶段进行比较,说明异同与启示。
## 4.2 发展趋势与关键变量
识别后续可能变化、决定性变量、观察指标和潜在转折点;不把预测写成既成事实。
## 4.3 风险情景与不确定性
说明不同情景的触发条件、可能影响和证据不足之处;对没有依据的推测明确标注。
# 五、结论、建议与后续关注
## 5.1 核心结论
用编号或分点方式收束全文;每条结论都应能回溯到正文中的事实和分析。
## 5.2 可行建议或应对方向
仅在研究材料支持时提出建议;说明适用对象、前提条件、优先级和预期作用。
## 5.3 资料局限与后续关注
列出证据缺口、数据口径差异、尚不能下结论的问题,以及值得持续跟踪的事项。
# 参考资料
仅列出本报告实际使用的来源,并与正文引用保持一致。
> 写作要求:引言保持简洁;每个二级小节按材料充分程度写 2–5 段完整分析,每段围绕一个明确观点展开,采用“事实/证据 → 分析 → 影响或小结”的顺序。章节可根据研究主题合并、增删或调整,但不要为了套模板而编造不适用的主体、数据、案例或引用。"""
PROGRESSIVE_OUTLINE = """# 报告标题
# 引言
用 1–2 段说明研究对象、核心问题、研究范围和资料边界;简要说明本文将按“事实基线 → 成因机制 → 影响判断 → 应对方向 → 结论”的递进逻辑展开。
# 一、事实基线:问题是什么
## 1.1 概念界定与研究范围
明确关键概念、时间/对象边界和判断口径,避免后续讨论混用概念。
## 1.2 关键事实、数据与证据
按时间或逻辑顺序列出已经发生的事实、可核验数据、案例和来源。
## 1.3 基线判断与待解问题
基于前两节归纳当前状态、主要矛盾和需要解释的核心问题。
> 本章产出:后续分析必须以本章已经确认的事实和问题为基础,不得在后文补写没有来源支撑的新事实。
# 二、形成机制:为什么会这样
## 2.1 直接原因与触发条件
从第一章识别的问题出发,分析近期事件、条件变化或关键节点如何导致当前结果。
## 2.2 深层条件与关键约束
结合第一章的证据,说明制度、技术、资源、行为、环境或历史条件等长期影响。
## 2.3 因果链条与关键变量
把前两节串成清晰的“条件 → 机制 → 结果”链条,标出决定性变量及仍待验证的环节。
> 本章产出:第三章只能沿用本章已说明的机制评估影响,不能把相关性直接写成因果关系。
# 三、结果与影响:会带来什么
## 3.1 直接结果与短期影响
依据第二章的因果链条,分析对研究对象最直接、最先出现的影响。
## 3.2 传导效应与中长期影响
继续说明影响如何传导至相关主体、外部环境或后续事项;不适用的维度可省略。
## 3.3 权衡、分歧与边界
比较不同影响之间的取舍、材料支持的不同观点,以及结论适用的边界。
> 本章产出:第四章的趋势判断、情景和建议应以本章已评估的影响、风险和权衡为依据。
# 四、趋势与应对:接下来怎么办
## 4.1 未来走势与观察指标
从第三章的影响出发,识别可能变化、关键观察指标和潜在转折点;不把预测写成既成事实。
## 4.2 可选方案与适用条件
针对第三章发现的问题和风险,提出可行的应对方向、前提条件与优先级。
## 4.3 风险情景与验证重点
说明不同情景的触发条件、可能后果,以及下一步需要补充验证的数据或材料。
> 本章产出:第五章只综合前四章已经建立的事实、机制、影响和建议,不新增独立论点。
# 五、综合结论与后续关注
## 5.1 核心结论
按“事实基线 → 原因机制 → 主要影响 → 应对方向”逐项收束,每条结论都可回溯到前文证据。
## 5.2 局限与后续研究
列出资料缺口、数据口径差异、尚不能下结论的问题和后续跟踪清单。
# 参考资料
仅列出本报告实际使用的来源,并与正文引用保持一致。
> 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。"""
BRIEF_OUTLINE = """# 报告标题
# 核心结论
用 3–5 条要点直接给出最重要的结论与判断,每条一两句话并可回溯到后文依据;先说结论,再列支撑。
# 关键事实
## 事实清单
按重要性列出已核验的关键事实、数据与出处;不确定的信息单独注明置信度。
## 时间与背景
用最短的篇幅交代理解结论所需的事件顺序与背景,不展开细节。
# 风险与不确定性
列出可能推翻或削弱上述结论的因素、证据缺口和正在变化中的变量。
# 建议行动
按优先级给出可执行的建议或下一步关注点,说明适用前提与预期作用;材料不足时明确说明证据尚不支持。
# 参考资料
仅列出本简报实际使用的来源,并与正文引用保持一致。
> 写作要求:全文控制在较短篇幅(建议 800–1500 字),信息密度优先;不写套话与背景铺陈,所有论断要么有来源支撑,要么明确标注为分析判断。"""
EVENT_OUTLINE = """# 报告标题
# 引言
用 1–2 段说明事件的性质、发生时间地点、当前状态与本报告要回答的核心问题。
# 一、事件概述
## 1.1 事件是什么
客观描述发生了什么:主体、行为、规模、直接结果;只写已核验事实。
## 1.2 各方说法
并列当事方、官方与媒体的不同表述,注明各自的信息来源与立场。
# 二、时间线还原
## 2.1 起因与前情
梳理事件发生的背景、前置条件与诱发节点。
## 2.2 关键节点
按时间顺序还原事件演进的关键节点,每个节点注明时间与来源;存在争议的时间点单独标注。
## 2.3 当前状态
说明事件目前的进展、处置情况与未决事项。
# 三、原因与责任分析
## 3.1 直接原因
分析触发事件的直接因素与机制。
## 3.2 深层原因
从制度、管理、技术、环境等维度分析更深层的促成条件。
## 3.3 责任与归因
基于事实分析各方责任,区分已认定的结论与仍在调查的部分。
# 四、影响与反应
## 4.1 直接影响
评估事件对当事方与相关群体已造成的影响。
## 4.2 各方反应与后续动作
梳理官方处置、当事方回应、舆论反应与衍生事件。
## 4.3 潜在延伸影响
分析可能的中长期影响与传导路径,注明判断依据。
# 五、结论与后续关注
## 5.1 核心结论
收束事件性质、原因与影响的核心判断。
## 5.2 待验证问题与观察点
列出尚未证实的关键问题与值得持续跟踪的信号。
# 参考资料
仅列出本报告实际使用的来源,并与正文引用保持一致。
> 写作要求:严格区分“已证实事实—各方声称—分析推断”三个层次并分别标注;时间线每个节点须有来源;对争议点呈现多方说法,不单方采信。"""
PROFILE_OUTLINE = """# 报告标题
# 引言
说明调查对象的界定范围(个人、组织或产品)、研究目的与资料边界(公开信息的时间范围与覆盖面)。
# 一、基本情况
## 1.1 基本信息与背景
梳理对象的公认基本信息与来历,注明信息时效。
## 1.2 发展沿革
按时间梳理关键成长节点、转折与现状。
# 二、能力与表现
## 2.1 核心能力或竞争力
分析对象的核心能力、资源禀赋或产品力,给出可观察的证据。
## 2.2 代表性成果与表现
列举代表性成果、业绩或事件表现,并评估其含金量与可持续性。
## 2.3 短板与争议
并列材料中的短板、批评与争议点,说明各方依据。
# 三、关系网络与生态
## 3.1 关联方与合作关系
梳理重要的合作关系、关联方与利益结构。
## 3.2 立场与倾向
基于言行记录分析对象的立场、风格与决策倾向。
# 四、风险评估
## 4.1 已知风险
汇总已暴露的风险点、负面记录与可靠性问题。
## 4.2 潜在风险与信息缺口
指出资料不足导致的盲区与需要进一步核实的问题。
# 五、综合评估
## 5.1 画像结论
用几句话给出对象的综合画像与可信度评估。
## 5.2 交往或合作建议
给出与对象打交道的注意事项与建议,说明前提。
# 参考资料
仅列出本报告实际使用的来源,并与正文引用保持一致。
> 写作要求:只依据公开可核验信息,猜测与推断必须明确标注;负面信息须注明来源与时间,区分“事实—评价—传闻”;信息不足时如实说明,不为了完整而编造。"""
# 固定 id:并发首查时重复插入会撞主键而被吞掉,不会产生重复行。
DEFAULT_REPORT_STRUCTURE_ROWS: list[dict] = [
{
"id": "seed-report-outline-general",
"title": "通用分析结构",
"name": "通用分析结构",
"content": GENERAL_OUTLINE,
"created_by": "系统内置",
},
{
"id": "seed-report-outline-progressive",
"title": "递进式论证结构",
"name": "递进式论证结构",
"content": PROGRESSIVE_OUTLINE,
"created_by": "系统内置",
},
{
"id": "seed-report-outline-brief",
"title": "快速简报结构",
"name": "快速简报结构",
"content": BRIEF_OUTLINE,
"created_by": "系统内置",
},
{
"id": "seed-report-outline-event",
"title": "事件分析结构",
"name": "事件分析结构",
"content": EVENT_OUTLINE,
"created_by": "系统内置",
},
{
"id": "seed-report-outline-profile",
"title": "对象调查结构",
"name": "对象调查结构",
"content": PROFILE_OUTLINE,
"created_by": "系统内置",
},
]
@@ -0,0 +1,66 @@
"""In-memory report-structure store (auth-less tests / no-DB fallback)."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.report_structures.base import ReportStructureStore
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _sort_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return sorted(rows, key=lambda r: (r.get("updated_at") or "", r.get("created_at") or ""), reverse=True)
class MemoryReportStructureStore(ReportStructureStore):
def __init__(self) -> None:
self._rows: dict[str, dict[str, Any]] = {}
async def list_structures(self) -> list[dict[str, Any]]:
return _sort_rows(list(self._rows.values()))
async def get_structure(self, structure_id: str) -> dict[str, Any] | None:
row = self._rows.get(structure_id)
return dict(row) if row else None
async def create_structure(self, data: dict[str, Any]) -> dict[str, Any]:
row = {
"id": str(data["id"]),
"title": str(data.get("title") or ""),
"name": str(data.get("name") or ""),
"content": str(data.get("content") or ""),
"enabled": True,
"created_by": data.get("created_by"),
"created_at": _now_iso(),
"updated_at": _now_iso(),
}
self._rows[row["id"]] = row
return dict(row)
async def update_structure(self, structure_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
row = self._rows.get(structure_id)
if row is None:
return None
for key in ("title", "name", "content"):
if key in data:
row[key] = str(data[key])
row["updated_at"] = _now_iso()
return dict(row)
async def set_enabled(self, structure_id: str, enabled: bool) -> dict[str, Any] | None:
row = self._rows.get(structure_id)
if row is None:
return None
row["enabled"] = bool(enabled)
row["updated_at"] = _now_iso()
return dict(row)
async def delete_structure(self, structure_id: str) -> bool:
return self._rows.pop(structure_id, None) is not None
async def count(self) -> int:
return len(self._rows)
@@ -0,0 +1,45 @@
"""ORM model for deep-research report structures (报告结构 / 工作流配置).
Global / shared — every account sees the same structure cards in the deep
research workbench's 工作流配置 dialog. Each row is one reusable report
structure the user can pick to fill ``custom_outline``:
- ``title`` — 标题, the card's primary label and the list's 标题筛选 target.
- ``name`` — 名称, a secondary display label.
- ``content`` — 接口内容, the Markdown outline text applied to the research
config's ``custom_outline`` when the card is selected.
- ``enabled`` — 启用/停用; creation always defaults to True (the caller cannot
choose it) and a dedicated toggle flips it later.
- ``created_by`` — 创建用户名 (audit-only display field).
Fresh deployments get the two built-in outlines (通用分析结构 / 递进式论证结构)
seeded by ``ensure_defaults`` — see ``defaults.py``.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class ReportStructureRow(Base):
__tablename__ = "report_structures"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
title: Mapped[str] = mapped_column(String(256), nullable=False)
name: Mapped[str] = mapped_column(String(256), default="", nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
# ``server_default`` 必须显式声明:否则在已存在该表但缺列的旧库上,启动期
# ``_ensure_orm_columns_sync`` 会跳过「NOT NULL 且无 server_default」的列(见
# engine.py),该列永远补不上 → 查询报 Unknown column → 500。
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="1")
created_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
)
@@ -0,0 +1,108 @@
"""SQLAlchemy-backed report-structure store (global / shared)."""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import delete, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.report_structures.base import ReportStructureStore
from deerflow.persistence.report_structures.model import ReportStructureRow
logger = logging.getLogger(__name__)
def _to_dict(row: ReportStructureRow) -> dict[str, Any]:
data = row.to_dict()
for key in ("created_at", "updated_at"):
value = data.get(key)
if isinstance(value, datetime):
data[key] = value.isoformat()
return data
class ReportStructureRepository(ReportStructureStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_structures(self) -> list[dict[str, Any]]:
stmt = select(ReportStructureRow).order_by(
ReportStructureRow.updated_at.desc(), ReportStructureRow.created_at.asc()
)
async with self._sf() as session:
result = await session.execute(stmt)
return [_to_dict(row) for row in result.scalars()]
async def get_structure(self, structure_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ReportStructureRow, structure_id)
return _to_dict(row) if row is not None else None
async def create_structure(self, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = ReportStructureRow(
id=str(data["id"]),
title=str(data.get("title") or ""),
name=str(data.get("name") or ""),
content=str(data.get("content") or ""),
# 创建永远默认启用(前端不提供该项填写)。
enabled=True,
created_by=data.get("created_by"),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return _to_dict(row)
async def update_structure(self, structure_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ReportStructureRow, structure_id)
if row is None:
return None
for key in ("title", "name", "content"):
if key in data:
setattr(row, key, str(data[key]))
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _to_dict(row)
async def set_enabled(self, structure_id: str, enabled: bool) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ReportStructureRow, structure_id)
if row is None:
return None
row.enabled = bool(enabled)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _to_dict(row)
async def delete_structure(self, structure_id: str) -> bool:
async with self._sf() as session:
result = await session.execute(delete(ReportStructureRow).where(ReportStructureRow.id == structure_id))
await session.commit()
return bool(result.rowcount)
async def count(self) -> int:
async with self._sf() as session:
return int(await session.scalar(select(func.count()).select_from(ReportStructureRow)) or 0)
async def ensure_defaults(self) -> None:
"""Seed when empty; a concurrent first-read loses on the fixed PK silently."""
if await self.count() > 0:
return
from deerflow.persistence.report_structures.defaults import DEFAULT_REPORT_STRUCTURE_ROWS
for row in DEFAULT_REPORT_STRUCTURE_ROWS:
try:
await self.create_structure(dict(row))
except IntegrityError:
logger.debug("report-structure default '%s' already inserted by a concurrent request", row["id"])
@@ -63,6 +63,12 @@ class RoundtableChainRow(Base):
is_public: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0", index=True
)
# 启停开关:True = 启用(可在选链弹窗 / 公共入口里选用);False = 停用(管理列表仍可见,
# 但不可选入研讨)。默认启用,存量链条补列后也为 True。``server_default`` 必须显式声明,
# 否则既有表上启动期 ``_ensure_orm_columns_sync`` 会跳过「NOT NULL 且无 server_default」的列。
enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="1"
)
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
@@ -62,6 +62,8 @@ class RoundtableChainRepository:
"gather_first": bool(getattr(row, "gather_first", False)),
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
"is_public": bool(getattr(row, "is_public", False)),
# 启停状态:缺省 True(存量行 / 补列前视为启用)。
"enabled": bool(getattr(row, "enabled", True)),
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
# 配置者(owner)的 user_id;路由层据此解析成可读用户名(owner_name)。
"owner_id": row.user_id,
@@ -122,6 +124,7 @@ class RoundtableChainRepository:
seat_mode=(data.get("seat_mode") or None),
gather_first=bool(data.get("gather_first") or False),
is_public=bool(data.get("is_public") or False),
enabled=True if data.get("enabled") is None else bool(data.get("enabled")),
created_at=now,
updated_at=now,
)
@@ -145,6 +148,7 @@ class RoundtableChainRepository:
seat_mode=_UNSET,
gather_first=_UNSET,
is_public=_UNSET,
enabled=_UNSET,
is_admin: bool = False,
) -> dict[str, Any] | None:
async with self._sf() as session:
@@ -170,6 +174,8 @@ class RoundtableChainRepository:
row.gather_first = bool(gather_first)
if is_public is not _UNSET and is_public is not None:
row.is_public = bool(is_public)
if enabled is not _UNSET and enabled is not None:
row.enabled = bool(enabled)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
@@ -94,6 +94,11 @@ CALL _add_column_if_missing('roundtable_chains', 'seat_mode', 'VARCHAR(16) NULL
-- ORM: RoundtableChainRow.gather_first = Boolean, NOT NULL, server_default '0'。
CALL _add_column_if_missing('roundtable_chains', 'gather_first', 'TINYINT(1) NOT NULL DEFAULT 0');
-- enabled: 业务链条启停开关,对应 migration 20260819_02。True=启用(可选入研讨);
-- False=停用(管理列表仍可见)。默认启用,存量行补列后也为 True。
-- ORM: RoundtableChainRow.enabled = Boolean, NOT NULL, server_default '1'。
CALL _add_column_if_missing('roundtable_chains', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1');
-- ---- 岗位 (positions) 订阅系统,对应 migration 20260616_01 -------------------
-- positions / position_tag_bindings 是【新表】,由 create_all 自动建,无需补列。
-- 以下是给【已存在的表】补的列:
@@ -212,5 +217,6 @@ SELECT table_name, column_name
OR (table_name = 'roundtable_jobs' AND column_name = 'task_id')
OR (table_name = 'position_roundtable_sessions' AND column_name = 'task_scoped')
OR (table_name = 'task_buttons' AND column_name IN ('append_auth', 'auth_token_param', 'auth_name_param'))
OR (table_name = 'compact_menu_overrides' AND column_name = 'menu_type'))
OR (table_name = 'compact_menu_overrides' AND column_name = 'menu_type')
OR (table_name = 'roundtable_chains' AND column_name = 'enabled'))
ORDER BY table_name, column_name;
@@ -26,7 +26,7 @@ from deerflow.persistence.tags.memory import MemoryTagStore
from deerflow.services.position_sync import PositionSyncService
@pytest.fixture
@pytest.fixture
def isolated_paths(tmp_path, monkeypatch):
"""Redirect get_paths() at a fresh temp directory for the duration of one test."""
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
@@ -119,20 +119,20 @@ async def _default_positions() -> MemoryPositionStore:
}
)
return positions
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_agent_store_allows_duplicate_chinese_names_and_filters_visibility() -> None:
store = MemoryAgentStore()
chinese_name = "\u4e2d\u6587\u52a9\u624b"
await store.ensure_builtin({"id": "builtin-agent", "name": chinese_name, "description": "built in"})
await store.create({"id": "private-a", "name": chinese_name, "description": "private", "user_id": "user-a"})
await store.create({"id": "published-b", "name": chinese_name, "description": "published", "user_id": "user-b", "published": True})
user_a_ids = {agent["id"] for agent in await store.list_visible("user-a")}
assert user_a_ids == {"builtin-agent", "private-a", "published-b"}
await store.ensure_builtin({"id": "builtin-agent", "name": chinese_name, "description": "built in"})
await store.create({"id": "private-a", "name": chinese_name, "description": "private", "user_id": "user-a"})
await store.create({"id": "published-b", "name": chinese_name, "description": "published", "user_id": "user-b", "published": True})
user_a_ids = {agent["id"] for agent in await store.list_visible("user-a")}
assert user_a_ids == {"builtin-agent", "private-a", "published-b"}
user_c_ids = {agent["id"] for agent in await store.list_visible("user-c")}
assert user_c_ids == {"builtin-agent", "published-b"}
@@ -271,254 +271,297 @@ async def test_manual_favorite_upgrades_existing_auto_builtin_favorite(sql_sessi
async def test_agent_store_only_owner_can_update_or_delete() -> None:
store = MemoryAgentStore()
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
await store.create({"id": "private-a", "name": "Owned", "user_id": "user-a"})
assert await store.update("builtin-agent", "user-a", {"name": "Nope"}) is None
assert await store.update("private-a", "user-b", {"name": "Nope"}) is None
updated = await store.update("private-a", "user-a", {"name": "Renamed"})
assert updated is not None
assert updated["name"] == "Renamed"
assert await store.delete("private-a", "user-b") is False
assert await store.delete("private-a", "user-a") is True
@pytest.mark.asyncio
async def test_delete_builtin_only_targets_builtins() -> None:
store = MemoryAgentStore()
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
# delete_builtin refuses to touch a user-owned agent
assert await store.delete_builtin("owned-agent") is False
assert await store.get_any("owned-agent") is not None
# delete_builtin succeeds on a built-in
assert await store.delete_builtin("builtin-agent") is True
assert await store.get_any("builtin-agent") is None
# And no-ops on a missing id
assert await store.delete_builtin("does-not-exist") is False
@pytest.mark.asyncio
async def test_update_builtin_only_targets_builtins() -> None:
store = MemoryAgentStore()
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
# update_builtin succeeds on a built-in agent
updated = await store.update_builtin("builtin-agent", {"name": "Renamed by admin"})
assert updated is not None
assert updated["name"] == "Renamed by admin"
# update_builtin refuses to touch a user-owned agent (returns None)
assert await store.update_builtin("owned-agent", {"name": "Hijacked"}) is None
# And on a missing agent
assert await store.update_builtin("does-not-exist", {"name": "Ghost"}) is None
@pytest.mark.asyncio
async def test_get_editable_or_raise_admin_can_edit_builtin() -> None:
"""Route helper: admin gets edit access to any agent; non-admin still 403."""
from fastapi import HTTPException
from app.gateway.routers.agents import _get_editable_or_raise
store = MemoryAgentStore()
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
# Owner edits their own agent → returns ('owner', record)
record, mode = await _get_editable_or_raise(
store, "owned-agent", "user-a", is_admin=False
)
assert mode == "owner" and record["id"] == "owned-agent"
# Admin edits a built-in agent → returns ('admin', record)
record, mode = await _get_editable_or_raise(
store, "builtin-agent", "user-a", is_admin=True
)
assert mode == "admin" and record["id"] == "builtin-agent"
# Non-admin tries to edit a built-in → 403
with pytest.raises(HTTPException) as exc:
await _get_editable_or_raise(store, "builtin-agent", "user-a", is_admin=False)
assert exc.value.status_code == 403
# Admin edits someone else's custom agent → returns ('admin', record).
# Behavior changed: admins manage the full agent catalog (built-in + any
# user's agents), not just built-ins. Non-admins are still owner-only.
await store.create(
{"id": "published-other", "name": "Pub", "user_id": "user-x", "published": True}
)
record, mode = await _get_editable_or_raise(
store, "published-other", "user-a", is_admin=True
)
assert mode == "admin" and record["id"] == "published-other"
def test_build_run_config_injects_custom_assistant_as_agent_id() -> None:
config = build_run_config("thread-1", None, None, assistant_id="agent-123")
assert config["configurable"]["thread_id"] == "thread-1"
assert config["configurable"]["agent_id"] == "agent-123"
assert "agent_name" not in config["configurable"]
def test_write_agent_config_persists_skills_into_config_yaml(isolated_paths) -> None:
agent_id = "agent-001"
_write_agent_config(
agent_id,
name="中文助手",
description="desc",
skills=["s1", "s2"],
model="gpt-x",
tool_groups=["tg-a"],
)
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert raw["id"] == agent_id
assert raw["name"] == "中文助手"
assert raw["description"] == "desc"
assert raw["skills"] == ["s1", "s2"]
assert raw["model"] == "gpt-x"
assert raw["tool_groups"] == ["tg-a"]
def test_load_config_extras_returns_values_written_to_yaml(isolated_paths) -> None:
agent_id = "agent-002"
_write_agent_config(
agent_id,
name="agent-002",
description="",
skills=["s1"],
model=None,
tool_groups=None,
)
extras = _load_config_extras(agent_id)
assert extras == {"skills": ["s1"], "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None}
def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths) -> None:
extras = _load_config_extras("does-not-exist")
assert extras == {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None}
def test_write_agent_config_persists_common_questions(isolated_paths) -> None:
agent_id = "agent-cq-1"
# Structured {title, prompt} questions round-trip as clean dict mappings.
questions = [
{"title": "能做什么", "prompt": "这个智能体能做什么?"},
{"title": "上传文件", "prompt": "如何上传文件?"},
]
_write_agent_config(agent_id, name="助手", description="", common_questions=questions)
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert raw["common_questions"] == questions
extras = _load_config_extras(agent_id)
assert [q.model_dump() for q in extras["common_questions"]] == questions
def test_write_agent_config_coerces_legacy_string_questions(isolated_paths) -> None:
"""Legacy ``list[str]`` configs coerce to {title, prompt} with title=content."""
agent_id = "agent-cq-legacy"
_write_agent_config(
agent_id, name="助手", description="", common_questions=["如何上传文件?"]
)
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert raw["common_questions"] == [
{"title": "如何上传文件?", "prompt": "如何上传文件?"}
]
def test_write_agent_config_omits_common_questions_when_none(isolated_paths) -> None:
agent_id = "agent-cq-2"
_write_agent_config(agent_id, name="x", description="")
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert "common_questions" not in raw
assert _load_config_extras(agent_id)["common_questions"] is None
def test_write_agent_config_explicit_empty_common_questions_is_persisted(isolated_paths) -> None:
agent_id = "agent-cq-3"
_write_agent_config(agent_id, name="x", description="", common_questions=[])
assert _load_config_extras(agent_id)["common_questions"] == []
def test_write_agent_config_explicit_empty_skills_is_persisted(isolated_paths) -> None:
agent_id = "agent-003"
_write_agent_config(agent_id, name="x", description="", skills=[])
extras = _load_config_extras(agent_id)
assert extras["skills"] == []
def test_write_agent_config_omits_skills_when_none(isolated_paths) -> None:
agent_id = "agent-004"
_write_agent_config(agent_id, name="x", description="")
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert "skills" not in raw
assert "model" not in raw
assert "tool_groups" not in raw
@pytest.mark.asyncio
async def test_backfill_listing_cache_mirrors_config_values() -> None:
store = MemoryAgentStore()
cfg = AgentConfig(id="a1", name="Agent", skills=["s1", "s2"], tool_groups=["g1"], model="m1")
await _backfill_listing_cache(store, [("a1", cfg)])
extras = await store.get_extras_for(["a1"])
assert extras["a1"]["skills"] == ["s1", "s2"]
assert extras["a1"]["tool_groups"] == ["g1"]
assert extras["a1"]["model"] == "m1"
@pytest.mark.asyncio
async def test_backfill_listing_cache_is_idempotent_and_skips_unchanged(monkeypatch) -> None:
store = MemoryAgentStore()
cfg = AgentConfig(id="a1", name="Agent", skills=["s1"], tool_groups=None, model=None)
await _backfill_listing_cache(store, [("a1", cfg)])
# On a second run with identical config, no re-write should occur.
calls = {"replace": 0, "set": 0}
orig_replace = store.replace_skills
orig_set = store.set_extras
async def _replace(agent_id, skills):
calls["replace"] += 1
await orig_replace(agent_id, skills)
async def _set(agent_id, *, tool_groups, model):
calls["set"] += 1
await orig_set(agent_id, tool_groups=tool_groups, model=model)
monkeypatch.setattr(store, "replace_skills", _replace)
monkeypatch.setattr(store, "set_extras", _set)
await _backfill_listing_cache(store, [("a1", cfg)])
assert calls == {"replace": 0, "set": 0}
# Changing the config triggers a re-write.
cfg2 = AgentConfig(id="a1", name="Agent", skills=["s1", "s2"], tool_groups=None, model=None)
await _backfill_listing_cache(store, [("a1", cfg2)])
assert calls["replace"] == 1
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
await store.create({"id": "private-a", "name": "Owned", "user_id": "user-a"})
assert await store.update("builtin-agent", "user-a", {"name": "Nope"}) is None
assert await store.update("private-a", "user-b", {"name": "Nope"}) is None
updated = await store.update("private-a", "user-a", {"name": "Renamed"})
assert updated is not None
assert updated["name"] == "Renamed"
assert await store.delete("private-a", "user-b") is False
assert await store.delete("private-a", "user-a") is True
@pytest.mark.asyncio
async def test_delete_builtin_only_targets_builtins() -> None:
store = MemoryAgentStore()
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
# delete_builtin refuses to touch a user-owned agent
assert await store.delete_builtin("owned-agent") is False
assert await store.get_any("owned-agent") is not None
# delete_builtin succeeds on a built-in
assert await store.delete_builtin("builtin-agent") is True
assert await store.get_any("builtin-agent") is None
# And no-ops on a missing id
assert await store.delete_builtin("does-not-exist") is False
@pytest.mark.asyncio
async def test_update_builtin_only_targets_builtins() -> None:
store = MemoryAgentStore()
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
# update_builtin succeeds on a built-in agent
updated = await store.update_builtin("builtin-agent", {"name": "Renamed by admin"})
assert updated is not None
assert updated["name"] == "Renamed by admin"
# update_builtin refuses to touch a user-owned agent (returns None)
assert await store.update_builtin("owned-agent", {"name": "Hijacked"}) is None
# And on a missing agent
assert await store.update_builtin("does-not-exist", {"name": "Ghost"}) is None
@pytest.mark.asyncio
async def test_get_editable_or_raise_admin_can_edit_builtin() -> None:
"""Route helper: admin gets edit access to any agent; non-admin still 403."""
from fastapi import HTTPException
from app.gateway.routers.agents import _get_editable_or_raise
store = MemoryAgentStore()
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
# Owner edits their own agent → returns ('owner', record)
record, mode = await _get_editable_or_raise(
store, "owned-agent", "user-a", is_admin=False
)
assert mode == "owner" and record["id"] == "owned-agent"
# Admin edits a built-in agent → returns ('admin', record)
record, mode = await _get_editable_or_raise(
store, "builtin-agent", "user-a", is_admin=True
)
assert mode == "admin" and record["id"] == "builtin-agent"
# Non-admin tries to edit a built-in → 403
with pytest.raises(HTTPException) as exc:
await _get_editable_or_raise(store, "builtin-agent", "user-a", is_admin=False)
assert exc.value.status_code == 403
# Admin edits someone else's custom agent → returns ('admin', record).
# Behavior changed: admins manage the full agent catalog (built-in + any
# user's agents), not just built-ins. Non-admins are still owner-only.
await store.create(
{"id": "published-other", "name": "Pub", "user_id": "user-x", "published": True}
)
record, mode = await _get_editable_or_raise(
store, "published-other", "user-a", is_admin=True
)
assert mode == "admin" and record["id"] == "published-other"
def test_build_run_config_injects_custom_assistant_as_agent_id() -> None:
config = build_run_config("thread-1", None, None, assistant_id="agent-123")
assert config["configurable"]["thread_id"] == "thread-1"
assert config["configurable"]["agent_id"] == "agent-123"
assert "agent_name" not in config["configurable"]
def test_write_agent_config_persists_skills_into_config_yaml(isolated_paths) -> None:
agent_id = "agent-001"
_write_agent_config(
agent_id,
name="中文助手",
description="desc",
skills=["s1", "s2"],
model="gpt-x",
tool_groups=["tg-a"],
)
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert raw["id"] == agent_id
assert raw["name"] == "中文助手"
assert raw["description"] == "desc"
assert raw["skills"] == ["s1", "s2"]
assert raw["model"] == "gpt-x"
assert raw["tool_groups"] == ["tg-a"]
def test_load_config_extras_returns_values_written_to_yaml(isolated_paths) -> None:
agent_id = "agent-002"
_write_agent_config(
agent_id,
name="agent-002",
description="",
skills=["s1"],
model=None,
tool_groups=None,
)
extras = _load_config_extras(agent_id)
assert extras == {
"skills": ["s1"],
"model": None,
"tool_groups": None,
"common_questions": None,
"template_name": None,
"seat_skill_directive": None,
"job_responsibilities": None,
"llmwiki_knowledge_base_ids": None,
}
def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths) -> None:
extras = _load_config_extras("does-not-exist")
assert extras == {
"skills": None,
"model": None,
"tool_groups": None,
"common_questions": None,
"template_name": None,
"seat_skill_directive": None,
"job_responsibilities": None,
"llmwiki_knowledge_base_ids": None,
}
def test_write_agent_config_persists_common_questions(isolated_paths) -> None:
agent_id = "agent-cq-1"
# Structured {title, prompt} questions round-trip as clean dict mappings.
questions = [
{"title": "能做什么", "prompt": "这个智能体能做什么?"},
{"title": "上传文件", "prompt": "如何上传文件?"},
]
_write_agent_config(agent_id, name="助手", description="", common_questions=questions)
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert raw["common_questions"] == questions
extras = _load_config_extras(agent_id)
assert [q.model_dump() for q in extras["common_questions"]] == questions
def test_write_agent_config_coerces_legacy_string_questions(isolated_paths) -> None:
"""Legacy ``list[str]`` configs coerce to {title, prompt} with title=content."""
agent_id = "agent-cq-legacy"
_write_agent_config(
agent_id, name="助手", description="", common_questions=["如何上传文件?"]
)
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert raw["common_questions"] == [
{"title": "如何上传文件?", "prompt": "如何上传文件?"}
]
def test_write_agent_config_omits_common_questions_when_none(isolated_paths) -> None:
agent_id = "agent-cq-2"
_write_agent_config(agent_id, name="x", description="")
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert "common_questions" not in raw
assert _load_config_extras(agent_id)["common_questions"] is None
def test_write_agent_config_explicit_empty_common_questions_is_persisted(isolated_paths) -> None:
agent_id = "agent-cq-3"
_write_agent_config(agent_id, name="x", description="", common_questions=[])
assert _load_config_extras(agent_id)["common_questions"] == []
def test_write_agent_config_explicit_empty_skills_is_persisted(isolated_paths) -> None:
agent_id = "agent-003"
_write_agent_config(agent_id, name="x", description="", skills=[])
extras = _load_config_extras(agent_id)
assert extras["skills"] == []
def test_write_agent_config_omits_skills_when_none(isolated_paths) -> None:
agent_id = "agent-004"
_write_agent_config(agent_id, name="x", description="")
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert "skills" not in raw
assert "model" not in raw
assert "tool_groups" not in raw
def test_write_agent_config_persists_job_responsibilities(isolated_paths) -> None:
agent_id = "agent-job-1"
_write_agent_config(
agent_id,
name="助手",
description="",
job_responsibilities=" 负责意图识别与业务链确认 ",
)
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert raw["job_responsibilities"] == "负责意图识别与业务链确认"
assert _load_config_extras(agent_id)["job_responsibilities"] == "负责意图识别与业务链确认"
def test_write_agent_config_omits_blank_job_responsibilities(isolated_paths) -> None:
agent_id = "agent-job-2"
_write_agent_config(agent_id, name="助手", description="", job_responsibilities=" ")
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert "job_responsibilities" not in raw
assert _load_config_extras(agent_id)["job_responsibilities"] is None
@pytest.mark.asyncio
async def test_backfill_listing_cache_mirrors_config_values() -> None:
store = MemoryAgentStore()
cfg = AgentConfig(id="a1", name="Agent", skills=["s1", "s2"], tool_groups=["g1"], model="m1")
await _backfill_listing_cache(store, [("a1", cfg)])
extras = await store.get_extras_for(["a1"])
assert extras["a1"]["skills"] == ["s1", "s2"]
assert extras["a1"]["tool_groups"] == ["g1"]
assert extras["a1"]["model"] == "m1"
@pytest.mark.asyncio
async def test_backfill_listing_cache_is_idempotent_and_skips_unchanged(monkeypatch) -> None:
store = MemoryAgentStore()
cfg = AgentConfig(id="a1", name="Agent", skills=["s1"], tool_groups=None, model=None)
await _backfill_listing_cache(store, [("a1", cfg)])
# On a second run with identical config, no re-write should occur.
calls = {"replace": 0, "set": 0}
orig_replace = store.replace_skills
orig_set = store.set_extras
async def _replace(agent_id, skills):
calls["replace"] += 1
await orig_replace(agent_id, skills)
async def _set(agent_id, *, tool_groups, model):
calls["set"] += 1
await orig_set(agent_id, tool_groups=tool_groups, model=model)
monkeypatch.setattr(store, "replace_skills", _replace)
monkeypatch.setattr(store, "set_extras", _set)
await _backfill_listing_cache(store, [("a1", cfg)])
assert calls == {"replace": 0, "set": 0}
# Changing the config triggers a re-write.
cfg2 = AgentConfig(id="a1", name="Agent", skills=["s1", "s2"], tool_groups=None, model=None)
await _backfill_listing_cache(store, [("a1", cfg2)])
assert calls["replace"] == 1