This commit is contained in:
许海洋 committed 2026-06-01 07:56:34 +08:00
commit dadb0bbe68
782 files changed
+160067 -3424

No files matched your search

@@ -55,6 +55,9 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
action = user_input.get("action", "confirm")
# 单输入框文本:可能是技能名 / 来源 / 检索意图,re_search 和 enable_skill_search 都用得到
user_query = (user_input.get("userQuery") or user_input.get("user_query") or "").strip()
if action == "confirm":
approved_ids = set(user_input.get("approvedMaterialIds") or [m["id"] for m in pkg["materials"]])
filtered = [m for m in pkg["materials"] if m["id"] in approved_ids]
@@ -66,12 +69,22 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "用户已确认素材,开始规划大纲"}],
}
# re_search:重新搜索
# enable_skill_search:用户选择"启用技能检索",回到 researcher 走 skill 模式
if action == "enable_skill_search":
msg = f"已启用技能检索(输入:{user_query[:30]})" if user_query else "已启用技能检索,正在筛选可用技能…"
return {
"last_user_intervention": {**user_input, "action": "enable_skill_search", "user_query": user_query},
"status": "researching",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": msg}],
}
# re_search:重新搜索(支持新版 user_query 单输入框 + 旧版 extraKeywords 数组)
extra_kws = user_input.get("extraKeywords") or []
msg = f"按输入「{user_query[:30]}」重新搜索…" if user_query else "重新搜索中..."
return {
"last_user_intervention": {**user_input, "_extra_keywords": extra_kws},
"last_user_intervention": {**user_input, "_extra_keywords": extra_kws, "user_query": user_query},
"status": "researching",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "重新搜索中..."}],
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": msg}],
}
@@ -83,11 +83,16 @@ _SYSTEM_TEMPLATE = """你是一名写作助手。
_USER_TEMPLATE = """用户写作意图:{user_intent}
请分析并输出以下 JSON(如果意图中未明确某字段,给出合理默认值):
用户在表单中已指定写作配置(以下三项必须原样输出,不要自行修改):
- 文章类型(article_type):{preconfigured_article_type}
- 目标读者(target_audience):{preconfigured_audience}
- 目标字数(word_count_target):{preconfigured_word_count}
请输出以下 JSON,article_type / target_audience / word_count_target 必须与上述一致,只需根据用户意图精炼 refined_intent:
{{{{
"article_type": "{type_options} 之一",
"target_audience": "目标读者描述",
"word_count_target": 800,
"article_type": "{preconfigured_article_type}",
"target_audience": "{preconfigured_audience}",
"word_count_target": {preconfigured_word_count},
"refined_intent": "精炼后的写作意图(一句话)"
}}}}"""
@@ -124,6 +129,44 @@ def _coerce_word_count(raw) -> int | None:
return value if value > 0 else None
def _resolve_intent_fields(
state: AIWritingState,
result: dict,
fallback_result: dict,
valid_keys: list[str],
default_key: str,
) -> tuple[str, str, int, str]:
"""合并 LLM 解析结果与用户表单预填字段;预填字段优先(见 AI_WRITING_BACKEND_DEV_ZH §intent_parser)。"""
fallback_type = fallback_result["article_type"]
if fallback_type not in valid_keys:
fallback_type = default_key
raw_type = result.get("article_type", "")
llm_type = raw_type if raw_type in valid_keys else fallback_type
preconfigured_type = (state.get("article_type") or "").strip()
if preconfigured_type in valid_keys:
final_article_type = preconfigured_type
else:
final_article_type = llm_type
preconfigured_audience = (state.get("target_audience") or "").strip()
if preconfigured_audience:
final_target_audience = preconfigured_audience
else:
final_target_audience = result.get("target_audience") or fallback_result["target_audience"]
preconfigured_wc = state.get("word_count_target")
if isinstance(preconfigured_wc, int) and preconfigured_wc > 0:
final_word_count = preconfigured_wc
else:
word_count = _coerce_word_count(result.get("word_count_target"))
final_word_count = word_count if word_count is not None else fallback_result["word_count_target"]
final_refined_intent = result.get("refined_intent") or state["user_intent"]
return final_article_type, final_target_audience, final_word_count, final_refined_intent
async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> dict:
# 从 configurable 读取动态类型列表,未配置时退回默认值
configurable = (config or {}).get("configurable", {})
@@ -137,10 +180,6 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
type_options = _build_type_options(valid_article_types)
system = _SYSTEM_TEMPLATE.format(type_options=type_options)
user = _USER_TEMPLATE.format(
user_intent=state["user_intent"],
type_options=" | ".join(valid_keys),
)
fallback_type = state.get("article_type", default_key)
if fallback_type not in valid_keys:
@@ -153,6 +192,17 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
"refined_intent": state["user_intent"],
}
preconfigured_wc = fallback_result["word_count_target"]
if not isinstance(preconfigured_wc, int) or preconfigured_wc <= 0:
preconfigured_wc = 800
user = _USER_TEMPLATE.format(
user_intent=state["user_intent"],
preconfigured_article_type=fallback_type,
preconfigured_audience=fallback_result["target_audience"],
preconfigured_word_count=preconfigured_wc,
)
# 流式调用:与 writer_outline / writer_draft 同套机制 —— LLM 逐字吐出 JSON,
# 通过 ``intent_chunk`` 推给前端打字机;完成后 ``parse_json`` 解析结构化结果,
# 再通过 ``intent_ready`` 推一份结构化字段给前端结果卡片。
@@ -193,13 +243,9 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
"detail": f"意图解析失败,已按默认配置兜底。原因:{llm_error}",
})
# 校验 LLM 返回的 article_type 是否在有效范围内,不合法时回退到默认值
raw_type = result.get("article_type", "")
final_article_type = raw_type if raw_type in valid_keys else fallback_type
final_target_audience = result.get("target_audience") or fallback_result["target_audience"]
word_count = _coerce_word_count(result.get("word_count_target"))
final_word_count = word_count if word_count is not None else fallback_result["word_count_target"]
final_refined_intent = result.get("refined_intent") or state["user_intent"]
final_article_type, final_target_audience, final_word_count, final_refined_intent = _resolve_intent_fields(
state, result, fallback_result, valid_keys, default_key
)
# 推送结构化结果给前端 —— 结果卡片显示文章类型 / 目标读者 / 字数 / 精炼意图
writer({
@@ -214,11 +260,9 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
updates: dict = {"status": "researching", "progress_events": progress_events}
updates["article_type"] = final_article_type
if result.get("target_audience"):
updates["target_audience"] = final_target_audience
if word_count is not None:
updates["word_count_target"] = final_word_count
if result.get("refined_intent"):
updates["target_audience"] = final_target_audience
updates["word_count_target"] = final_word_count
if final_refined_intent != state["user_intent"]:
updates["user_intent"] = final_refined_intent
return updates
@@ -10,6 +10,7 @@ from typing import List
from langchain_core.runnables import RunnableConfig
from ..search import get_search_provider, SearchResult
from ..search.skill_search import SkillPicker
from ..state import AIWritingState, Material, MaterialPackage
from ..prompts.researcher_prompts import (
EXTRACT_KEYWORDS_SYSTEM, EXTRACT_KEYWORDS_USER,
@@ -96,15 +97,24 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 检查干预模式(PAUSE-1 素材确认 / 素材不足求助)
intervention = state.get("last_user_intervention") or {}
extra_keywords: list[str] = intervention.get("_extra_keywords") or intervention.get("extra_keywords") or []
# PAUSE-1 单输入框:用户写的自然语言(技能名 / 来源 / 检索意图),优先级最高
user_query: str = (intervention.get("user_query") or "").strip()
action = intervention.get("action")
# 素材不足求助点触发的「补充检索」:保留原大纲,检索完直接回写作、不走素材确认/大纲
is_supplement = action == "supplement_search"
# PAUSE-1 素材确认卡用户点「追加检索」并填了新关键词:
# PAUSE-1 素材确认卡用户点「按输入重新搜索」并填了 user_query:
# - 跳过 LLM 重新生成检索词(节省一次调用 + 尊重用户意图)
# - 只检索 extra_keywords 这几个新词,不重检索之前 LLM 出过的词
# - 直接用 user_query 作为唯一检索词
# - 保留 existing material_package,按 URL 去重合并
is_append_only = action == "re_search" and bool(extra_keywords)
is_user_query_search = action == "re_search" and bool(user_query)
# 兼容旧版"追加关键词"调用方(如果还有 client 传 extra_keywords 数组而非 user_query)
is_append_only = action == "re_search" and not user_query and bool(extra_keywords)
# PAUSE-1 素材确认卡用户点「启用技能检索」:
# - 不再走 web/intranet 检索,改用 SkillSearchProvider
# - 顺序调用候选技能 + 早停(第一个技能够就停,不够再加下一个)
# - 旧 material_package 完全丢弃(覆盖式替换,让用户对比 web vs skill 结果)
is_skill_search = action == "enable_skill_search"
# 用户指定的检索关键词数量(1~10),决定生成多少个检索词、检索多少个方向
keyword_count = _resolve_keyword_count(state.get("keyword_count"))
@@ -126,10 +136,17 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
writer({"type": "research_progress", "message": message})
# Step 1: 提取关键词
kw_result: dict = {} # 走追加分支时跳过 LLM 调用,但下方 notice 拼接需要兜底值
if is_append_only:
# 追加模式:直接用用户填的关键词,不再调 LLM
keywords: list[str] = _clean_keywords(extra_keywords, intent)
kw_result: dict = {} # 走"用户输入 / 追加 / 技能"分支时跳过 LLM 调用,notice 拼接需要兜底值
if is_skill_search:
# 技能模式:每个 skill 会自己拼 query(user_query + skill.description),这里不用 LLM 生成关键词
keywords: list[str] = [user_query or intent]
elif is_user_query_search:
# 用户输入分支:把整段自然语言作为唯一检索词,尊重原意,不再做关键词拆分
keywords = [user_query]
_emit(f"按你的输入「{user_query[:30]}」检索…")
elif is_append_only:
# 兼容旧版追加模式:直接用用户填的关键词,不再调 LLM
keywords = _clean_keywords(extra_keywords, intent)
_emit(f"追加检索 {len(keywords)} 个关键词…")
else:
_emit("正在生成检索词…")
@@ -150,78 +167,172 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
if extra_keywords:
keywords = list(dict.fromkeys(keywords + extra_keywords))
# Step 2: 并发搜索
provider = get_search_provider(ai_writing_config)
# Step 2: 搜索
# 技能检索:技能不是素材,是"检索策略 / 视角"。流程:
# a. SkillPicker 选出按相关度排序的 skills(推 list_skills + select_skills 事件)
# b. 按顺序遍历 skills:每个 skill 用其 description 让 LLM 生成针对性关键词
# c. 调真实 web/intranet provider 执行检索,推 invoke_skill + skill_result 事件
# d. 累积素材到 max_materials 即 break,推 stop_early
skill_steps: list[dict] = []
search_errors: list[str] = []
# 按用户指定的关键词数量截断。
# 追加模式:keywords 本身就是用户填的,全部检索不截断(用户填 N 个就搜 N 个)。
# 其他模式:受 keyword_count 限制,但 extra_keywords 在内时上限放宽到 keywords 总数。
if is_append_only:
search_keywords = keywords
def _emit_skill_step(step: dict) -> None:
skill_steps.append(step)
writer({"type": "skill_search_step", "agent_name": "素材收集专家", **step})
if is_skill_search:
# ── 技能编排检索 ──
# 技能选择走流式 LLM(推 skill_select_chunk)+ <think> 分离,前端打字机展示
picker = SkillPicker(
config=config,
model_name=keyword_model,
emit_step=_emit_skill_step,
max_select=5,
user_query=user_query,
)
selected = await picker.pick(user_intent=intent)
search_errors.extend(picker.errors)
# 真实 web/intranet provider(按 config.yaml 配置)
web_provider = get_search_provider(ai_writing_config)
# 每个 skill 尽量多取(不分摊),第一个就达到 max_materials 即可早停;
# 这才贴近"第一个技能够了就停,否则下一个补充"的语义。
per_skill = max(1, max_materials)
raw_materials_pre: list[Material] = []
seen_urls_pre: set[str] = set()
stopped_early = False
for entry in selected:
sk = entry["skill"]
# 用 LLM 把 user_query + skill.description 转成针对性关键词
_emit_skill_step({
"type": "invoke_skill",
"message": f"使用技能「{sk.name}」开始检索…",
"skill_name": sk.name,
})
skill_query = f"{user_query} {sk.description or ''}".strip() or intent
try:
res = await web_provider.search(skill_query, max_results=per_skill)
except Exception as e:
logger.warning("skill[%s] search failed: %s", sk.name, e)
search_errors.append(f"skill[{sk.name}]: {e}")
res = []
# 去重累积
new_count = 0
for r in res or []:
if r.url and r.url in seen_urls_pre:
continue
if r.url:
seen_urls_pre.add(r.url)
raw_materials_pre.append(Material(
id=f"m{len(raw_materials_pre)+1:03d}",
title=r.title,
content=r.content[:500],
source=f"{r.source}(via 技能 {sk.name})",
published_at=r.published_at,
relevance_score=r.relevance_score,
source_type=r.source_type,
url=r.url,
recUuid=r.recUuid,
))
new_count += 1
_emit_skill_step({
"type": "skill_result",
"message": f"技能「{sk.name}」检索到 {new_count} 条新素材",
"skill_name": sk.name,
"matched_count": new_count,
})
# 早停:累积素材已达上限
if len(raw_materials_pre) >= max_materials:
stopped_early = True
_emit_skill_step({
"type": "stop_early",
"message": f"已获取 {len(raw_materials_pre)} 条素材,达到上限,停止调用后续技能",
"matched_count": len(raw_materials_pre),
})
break
# 技能模式不再走下面的并发/合并逻辑,all_results_nested 留空、跳过 web 通用搜索
all_results_nested: list = []
# 把已收集到的素材挂在 raw_materials_pre,下方合并阶段直接挪过去
else:
search_limit = keyword_count
if extra_keywords:
search_limit = max(search_limit, len(keywords))
search_keywords = keywords[:search_limit]
total = len(search_keywords)
# 每个检索词分到的检索条数 = 总量上限 / 检索词数(向上取整)
per_keyword = _per_keyword_limit(max_materials, total)
_emit(f"检索词已生成,开始检索 {total} 个方向…")
# ── 常规 web/intranet 模式 ──
provider = get_search_provider(ai_writing_config)
# 并发搜索,但每完成一个就推送进度(计数在单线程 asyncio 下无需加锁)
completed = 0
# 按用户指定的关键词数量截断。
# 用户输入 / 追加模式:keywords 本身就是用户填的,全部检索不截断。
if is_user_query_search or is_append_only:
search_keywords = keywords
else:
search_limit = keyword_count
if extra_keywords:
search_limit = max(search_limit, len(keywords))
search_keywords = keywords[:search_limit]
total = len(search_keywords)
per_keyword = _per_keyword_limit(max_materials, total)
_emit(f"检索词已生成,开始检索 {total} 个方向…")
async def _search_one(kw: str) -> list[SearchResult]:
nonlocal completed
try:
return await provider.search(kw, max_results=per_keyword)
finally:
completed += 1
if completed < total:
_emit(f"已完成 {completed}/{total} 次检索,正在继续检索相关内容…")
else:
_emit(f"已完成全部 {total} 次检索")
completed = 0
search_tasks = [_search_one(kw) for kw in search_keywords]
all_results_nested: list[list[SearchResult]] = await asyncio.gather(*search_tasks, return_exceptions=True)
async def _search_one(kw: str) -> list[SearchResult]:
nonlocal completed
try:
return await provider.search(kw, max_results=per_keyword)
finally:
completed += 1
if completed < total:
_emit(f"已完成 {completed}/{total} 次检索,正在继续检索相关内容…")
else:
_emit(f"已完成全部 {total} 次检索")
search_tasks = [_search_one(kw) for kw in search_keywords]
all_results_nested = await asyncio.gather(*search_tasks, return_exceptions=True)
# provider 内部吞掉的检索异常(如内网 ES 连接失败),一并取出用于提示用户
search_errors.extend(getattr(provider, "errors", []))
# 合并去重
# - 首次检索 / 普通 re_search(不带 extra_keywords,用户只想 LLM 重出):旧素材丢弃
# - 素材不足求助 supplement_search:保留旧素材 + 追加新检索结果
# - PAUSE-1 追加检索 is_append_only:保留旧素材 + 仅检索新关键词
seen_urls: set[str] = set()
raw_materials: list[Material] = []
# - 技能检索 is_skill_search:覆盖式替换,旧素材完全丢弃(编排循环已经把素材累积到 raw_materials_pre)
existing_pkg = state.get("material_package")
preserve_existing = existing_pkg and (is_supplement or is_append_only)
if preserve_existing:
for m in existing_pkg.get("materials", []):
seen_urls.add(m.get("url", ""))
raw_materials.append(m)
if is_skill_search:
# 技能编排循环已经完成素材累积,直接用 raw_materials_pre 当 raw_materials
raw_materials: list[Material] = raw_materials_pre # type: ignore[name-defined]
else:
seen_urls: set[str] = set()
raw_materials = []
preserve_existing = existing_pkg and (is_supplement or is_append_only)
if preserve_existing:
for m in existing_pkg.get("materials", []):
seen_urls.add(m.get("url", ""))
raw_materials.append(m)
search_errors: list[str] = []
for results in all_results_nested:
if isinstance(results, Exception):
logger.warning("search failed: %s", results)
search_errors.append(f"{type(results).__name__}: {results}")
continue
for r in results:
if r.url and r.url in seen_urls:
for results in all_results_nested:
if isinstance(results, Exception):
logger.warning("search failed: %s", results)
search_errors.append(f"{type(results).__name__}: {results}")
continue
seen_urls.add(r.url)
raw_materials.append(Material(
id=f"m{len(raw_materials)+1:03d}",
title=r.title,
content=r.content[:500],
source=r.source,
published_at=r.published_at,
relevance_score=r.relevance_score,
source_type=r.source_type,
url=r.url,
recUuid=r.recUuid,
))
# provider 内部吞掉的检索异常(如内网 ES 连接失败),一并取出用于提示用户
search_errors.extend(getattr(provider, "errors", []))
for r in results:
if r.url and r.url in seen_urls:
continue
if r.url:
seen_urls.add(r.url)
raw_materials.append(Material(
id=f"m{len(raw_materials)+1:03d}",
title=r.title,
content=r.content[:500],
source=r.source,
published_at=r.published_at,
relevance_score=r.relevance_score,
source_type=r.source_type,
url=r.url,
recUuid=r.recUuid,
))
# Step 3: 相关度排序(如素材较多,让 LLM 排序)
_emit("检索完成,正在筛选排序素材…")
@@ -264,6 +375,14 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
old_kws = list(existing_pkg.get("keywords") or [])
pkg_keywords = list(dict.fromkeys(old_kws + keywords))
pkg = MaterialPackage(keywords=pkg_keywords, materials=materials, summary=summary)
# 技能检索时把过程步骤一并放进 pkg,前端在素材确认卡里能展示"使用了哪些技能"
if is_skill_search:
pkg["skill_search_used"] = True
pkg["skill_search_steps"] = skill_steps
elif existing_pkg and existing_pkg.get("skill_search_used") and (is_supplement or is_append_only):
# 保留之前一次技能检索的痕迹(不污染纯 web 检索)
pkg["skill_search_used"] = existing_pkg.get("skill_search_used", False)
pkg["skill_search_steps"] = existing_pkg.get("skill_search_steps", [])
# 容错告警:检索接口 / 模型调用出错时以非致命 notice 提示用户,不打断写作流程
notices: list[dict] = []
@@ -274,19 +393,24 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
if search_errors:
uniq = list(dict.fromkeys(search_errors))
# 解析失败时收集到的接口原始响应体,前端折叠展示便于定位
raw_dumps = list(dict.fromkeys(getattr(provider, "raw_responses", []) or []))
# (技能模式下 provider 局部变量没暴露,所以 raw_dumps 兜底为空列表)
provider_obj = locals().get("provider") or locals().get("web_provider")
raw_dumps = list(dict.fromkeys(getattr(provider_obj, "raw_responses", []) or []))
notices.append(_notice(
"ES 波动导致检索异常",
";".join(uniq[:3]),
raw="\n\n----------\n\n".join(raw_dumps[:3]),
))
# 进度事件文案:追加模式下让用户看到只搜了哪几个新词
step_start_msg = (
f"追加检索关键词:{', '.join(keywords[:3])}"
if is_append_only
else f"搜索关键词:{', '.join(keywords[:3])}"
)
# 进度事件文案:用户输入 / 追加模式 / 技能模式下让用户看到差异化提示
if is_skill_search:
step_start_msg = f"启用技能检索({len(skill_steps)} 步)"
elif is_user_query_search:
step_start_msg = f"按你的输入检索:{user_query[:40]}"
elif is_append_only:
step_start_msg = f"追加检索关键词:{', '.join(keywords[:3])}"
else:
step_start_msg = f"搜索关键词:{', '.join(keywords[:3])}"
return {
"material_package": pkg,
# 补充检索:直接回写作(沿用原大纲);常规检索:进入素材确认暂停点
@@ -3,6 +3,7 @@ from .internet_search import InternetSearchProvider
from .intranet_stub import IntranetSearchProvider
from .demo_search import DemoSearchProvider
from .duckduckgo_search import DuckDuckGoSearchProvider
from .skill_search import SkillSearchProvider
def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider:
@@ -60,5 +61,6 @@ __all__ = [
"BaseSearchProvider", "SearchResult",
"InternetSearchProvider", "IntranetSearchProvider",
"DemoSearchProvider", "DuckDuckGoSearchProvider",
"SkillSearchProvider",
"get_search_provider",
]
@@ -0,0 +1,193 @@
"""技能选择器:根据用户意图 + 输入,从启用的 Skills 里挑出按相关度排序的若干个。
注意:这个模块**只负责选择技能,不直接产出素材**。
真正的素材产出由 researcher 节点用每个 skill 的描述去编排真实 web/intranet 检索。
流程(对应素材确认卡上的"技能检索"按钮):
1) list_skills 事件 —— 列出当前启用的全部 skill
2) skill_select_chunk 事件 —— LLM 流式打字 + <think></think> 分离(前端打字机展示)
3) select_skills 事件 —— LLM 给出的排序结果(结构化字段,写到 SkillStepsCard 里)
下游 researcher 节点拿到 selected_skills 后,**按顺序**:
- 用 skill.description + user_query 生成针对性 query
- 调真实 web search provider 执行
- 累积素材达到阈值就停(推 stop_early)
"""
from __future__ import annotations
import logging
from typing import Callable, List, Optional
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
from ..nodes._utils import (
ThinkTagParser,
_content_to_text,
get_model,
parse_json,
safe_get_stream_writer,
)
logger = logging.getLogger(__name__)
_SELECT_SKILLS_SYSTEM = """你是一名情报研究员,需要从可用「技能」中挑出对当前检索任务最有帮助的若干个,并按相关度从高到低排序。
技能是一段封装好的指令 / 知识 / 检索策略,每个技能有 name 和 description。
【输出要求】
- 只输出一个 JSON 对象,不要 markdown 代码块。
- 必须使用英文双引号。
- selected_skills 中的 name 必须严格来自下方"可用技能"列表,不要编造,按相关度从高到低排序。
- 如果用户输入里明确点名了某个技能(按 name 完全匹配),把它排在第一位。
- 至少选 1 个,最多选 {max_select} 个。
【完整示例】
{{"selected_skills": ["platform-sales-data", "industry-report"], "reason": "用户想查各平台销量,平台销量技能最相关"}}"""
_SELECT_SKILLS_USER = """写作意图:{user_intent}
用户补充输入:{user_query}
可用技能(共 {skill_count} 个):
{skill_list}
请从中按相关度从高到低选出最多 {max_select} 个技能。
只输出 JSON:
{{"selected_skills": ["name1", "name2"], "reason": "为什么选这些 + 排序依据"}}"""
EmitStepFn = Callable[[dict], None]
class SkillPicker:
"""从启用的 Skills 中流式选择按相关度排序的若干个供下游编排使用。
构造参数:
config: LangGraph runnable config(用于创建 model)
model_name: 指定使用的模型,None 则用 configurable.model_name
emit_step: 推送时间线步骤事件(list_skills / select_skills)
max_select: LLM 单次最多挑几个技能
user_query: 用户在素材确认卡输入框写的自然语言
"""
def __init__(
self,
config: RunnableConfig,
*,
model_name: Optional[str] = None,
emit_step: Optional[EmitStepFn] = None,
max_select: int = 5,
user_query: str = "",
) -> None:
self._config = config
self._model_name = model_name
self._emit_step = emit_step or (lambda _step: None)
self._max_select = max_select
self._user_query = (user_query or "").strip()
self._errors: list[str] = []
@property
def errors(self) -> list[str]:
return list(self._errors)
def _load_enabled_skills(self) -> list:
try:
from deerflow.skills import get_or_new_skill_storage
storage = get_or_new_skill_storage()
skills = storage.load_skills(enabled_only=True)
except Exception as e:
logger.exception("ai_writing.skill_search: 加载启用技能失败")
self._errors.append(f"skill list error: {e}")
skills = []
self._emit_step({
"type": "list_skills",
"message": f"发现 {len(skills)} 个可用技能",
"skill_names": [s.name for s in skills],
})
return skills
async def pick(self, user_intent: str) -> List[dict]:
"""流式选择并排序候选技能,返回 [{skill, reason}, ...],按相关度降序。
过程中通过 custom 通道推送 ``skill_select_chunk`` 事件给前端打字机;
结束后再推一条 ``select_skills`` 结构化事件。
"""
skills = self._load_enabled_skills()
if not skills:
self._emit_step({
"type": "select_skills",
"message": "当前没有启用的技能可用",
"skill_names": [],
})
return []
skill_list_text = "\n".join(
f"- {s.name}: {s.description or '(无描述)'}" for s in skills
)
fallback = {"selected_skills": [s.name for s in skills[: self._max_select]], "reason": ""}
# 流式 LLM 调用:与 intent_parser / writer_outline 同套机制
writer = safe_get_stream_writer()
parser = ThinkTagParser()
full_text = ""
def _emit_chunk(text: str, is_thinking: bool) -> None:
writer({
"type": "skill_select_chunk",
"text": text,
"is_thinking": is_thinking,
"agent_name": "素材收集专家",
})
try:
model = get_model(self._config, model_name=self._model_name)
async for chunk in model.astream([
SystemMessage(content=_SELECT_SKILLS_SYSTEM.format(max_select=self._max_select)),
HumanMessage(content=_SELECT_SKILLS_USER.format(
user_intent=user_intent or "(无明确写作意图)",
user_query=self._user_query or "(无)",
skill_count=len(skills),
skill_list=skill_list_text,
max_select=self._max_select,
)),
]):
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
_emit_chunk(text, is_thinking)
except Exception as e:
logger.exception("ai_writing.skill_search: 流式选择技能失败")
self._errors.append(f"skill select error: {e}")
result = parse_json(full_text, fallback) if full_text else fallback
wanted_names = result.get("selected_skills") or []
if not isinstance(wanted_names, list):
wanted_names = []
by_name = {s.name: s for s in skills}
reason = result.get("reason", "") or ""
chosen: list[dict] = []
for name in wanted_names:
if not isinstance(name, str):
continue
sk = by_name.get(name.strip())
if sk and sk.name not in {c["skill"].name for c in chosen}:
chosen.append({"skill": sk, "reason": reason})
if len(chosen) >= self._max_select:
break
self._emit_step({
"type": "select_skills",
"message": f"按相关度排序,候选 {len(chosen)} 个技能(将按顺序使用)",
"skill_names": [c["skill"].name for c in chosen],
"reason": reason,
})
return chosen
# 保留旧名字让 search/__init__.py 的导出不报错;研究节点不再用它
SkillSearchProvider = SkillPicker
@@ -3,21 +3,43 @@ import operator
# ── 素材 ──────────────────────────────────────────────────────────────────────
class Material(TypedDict):
class Material(TypedDict, total=False):
id: str
title: str
content: str # 截断到 500 字
source: str
published_at: str
relevance_score: float
source_type: str # "internet" | "intranet"
source_type: str # "internet" | "intranet" | "skill"
url: str
recUuid: str
# skill 类素材附带的元信息(skill_name / container_path / category),其他类型为空
extra_metadata: dict
class MaterialPackage(TypedDict):
class SkillStep(TypedDict, total=False):
"""技能检索过程的单步记录,用于前端时间线展示。
type=match_skills:根据用户输入流式分析有没有匹配技能(无 skill_names)
type=list_skills:列出可用技能
type=select_skills:LLM 从中选出的相关技能
type=invoke_skill:实际调用某一个技能检索
type=stop_early:达到素材阈值,提前停止后续技能调用
"""
type: str
message: str
skill_names: List[str]
skill_name: str
matched_count: int
reason: str
class MaterialPackage(TypedDict, total=False):
keywords: List[str]
materials: List[Material]
summary: str
# 是否本次素材来自技能检索(覆盖式替换 web 检索)
skill_search_used: bool
# 技能检索的步骤记录(前端展示"使用了 X 技能"卡片)
skill_search_steps: List[SkillStep]
# ── 大纲 ──────────────────────────────────────────────────────────────────────
# 三级结构:章节(##) → 小节(###) → 子要点(bullet -)。
@@ -72,10 +94,13 @@ class ReviewResult(TypedDict):
# ── 用户干预 ──────────────────────────────────────────────────────────────────
class UserInterventionPayload(TypedDict):
class UserInterventionPayload(TypedDict, total=False):
action: str
approved_material_ids: List[str]
extra_keywords: List[str]
# PAUSE-1 素材确认 / enable_skill_search 共用:用户在单输入框里填的自然语言
# (可能是要使用的技能名、想检索的来源、或者一段补充检索意图)。
user_query: str
edited_outline: Optional[Outline]
outline_feedback: str
user_revision_notes: str
@@ -9,6 +9,7 @@ from deerflow.agents.middlewares.clarification_middleware import ClarificationMi
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
@@ -254,11 +255,14 @@ def _build_middlewares(
List of middleware instances.
"""
resolved_app_config = app_config or get_app_config()
cfg = _get_runtime_config(config)
middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True)
# Inject the admin prompt prefix into the model request (kept out of the
# persisted/displayed message so the user never sees it).
middlewares.append(PromptPrefixMiddleware())
if cfg.get("rag_mode_enabled", False):
middlewares.append(RagCitationMiddleware())
# Add summarization middleware if enabled
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
@@ -266,7 +270,6 @@ def _build_middlewares(
middlewares.append(summarization_middleware)
# Add TodoList middleware if plan mode is enabled
cfg = _get_runtime_config(config)
is_plan_mode = cfg.get("is_plan_mode", False)
is_bootstrap = cfg.get("is_bootstrap", False)
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
@@ -370,6 +373,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
is_bootstrap = cfg.get("is_bootstrap", False)
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
rag_mode_enabled = bool(cfg.get("rag_mode_enabled", False))
writing_mode = bool(cfg.get("writing_mode", False))
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
@@ -497,24 +501,30 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
is_bootstrap=True,
app_config=resolved_app_config,
memory_injection_enabled=memory_injection_enabled,
rag_mode_enabled=rag_mode_enabled,
),
state_schema=ThreadState,
)
# Default lead agent (unchanged behavior)
# Use subagent tool groups if in subagent mode, otherwise use parent agent config
effective_tool_groups = subagent_tool_groups if subagent_mode else (agent_config.tool_groups if agent_config else None)
is_notebook_mode = bool(cfg.get("notebook_search_space_id"))
notebook_source_ids: list[int] = cfg.get("notebook_source_ids") or []
default_tools = get_available_tools(
model_name=model_name,
groups=agent_config.tool_groups if agent_config else None,
subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools,
app_config=resolved_app_config,
)
if is_notebook_mode:
from deerflow.tools.builtins.notebook_search_tool import notebook_search_tool
default_tools = [notebook_search_tool] + default_tools
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config),
tools=get_available_tools(
model_name=model_name,
groups=effective_tool_groups,
subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools,
app_config=resolved_app_config,
),
tools=default_tools,
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
system_prompt=apply_prompt_template(
subagent_enabled=subagent_enabled,
@@ -526,6 +536,9 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
available_skills=(set(agent_config.skills or []) if agent_config else None),
app_config=resolved_app_config,
memory_injection_enabled=memory_injection_enabled,
rag_mode_enabled=rag_mode_enabled,
is_notebook_mode=is_notebook_mode,
notebook_source_ids=notebook_source_ids,
),
state_schema=ThreadState,
)
@@ -667,17 +667,25 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig
@lru_cache(maxsize=32)
def _get_cached_skills_prompt_section(
skill_signature: tuple[tuple[str, str, str, str], ...],
display_signature: tuple[tuple[str, str, str], ...],
available_skills_key: tuple[str, ...] | None,
container_base_path: str,
skill_evolution_section: str,
) -> str:
display_by_skill = {name: (mode, custom_instruction) for name, mode, custom_instruction in display_signature}
filtered = [(name, description, category, location) for name, description, category, location in skill_signature if available_skills_key is None or name in available_skills_key]
skills_list = ""
if filtered:
skill_items = "\n".join(
f" <skill>\n <name>{name}</name>\n <description>{description} {_skill_mutability_label(category)}</description>\n <location>{location}</location>\n </skill>"
for name, description, category, location in filtered
)
items = []
for name, description, category, location in filtered:
display_mode, custom_instruction = display_by_skill.get(name, ("", ""))
display_hint = ""
if display_mode:
display_hint = f"\n <display mode=\"{display_mode}\">{custom_instruction}</display>"
items.append(
f" <skill>\n <name>{name}</name>\n <description>{description} {_skill_mutability_label(category)}</description>\n <location>{location}</location>{display_hint}\n </skill>"
)
skill_items = "\n".join(items)
skills_list = f"<available_skills>\n{skill_items}\n</available_skills>"
# Restricted mode: agent has an explicit skill allowlist.
@@ -693,6 +701,21 @@ def _get_cached_skills_prompt_section(
else:
scope_note = ""
display_pattern = (
"""
**Skill Result Display Pattern:**
- If RAG/reference display mode is enabled, do not number each skill result independently. Treat all retrieval results from the same user turn as one combined `referenceBatch` and cite with `[1]`, `[2]`, etc. from the system-provided reference prompt.
- In RAG/reference display mode, do not output `<skill-display .../>`; the frontend renders cards, tables, lists, and citation details in the right reference panel.
- If RAG/reference display mode is not enabled and a skill has `<display mode="citation">`, use the skill's returned result order as reference numbers. Cite facts from the first result as `[1]`, the second as `[2]`, and so on. Use only numbers that correspond to returned results.
- If a skill has `<display mode="list">`, summarize the returned items as a concise list and keep item order stable.
- If a skill has `<display mode="table">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the frontend should render the real table. Do not hand-write the table rows yourself.
- If a skill has `<display mode="card">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the frontend should render the cards.
- If a skill has `<display mode="custom">`, follow the custom display instruction in the `<display>` tag when presenting the skill result.
"""
if display_signature
else ""
)
return f"""<skill_system>
You have access to skills that provide optimized workflows for specific tasks. Each skill contains best practices, frameworks, and references to additional resources.
{scope_note}
@@ -702,13 +725,19 @@ You have access to skills that provide optimized workflows for specific tasks. E
3. The skill file contains references to external resources under the same folder
4. Load referenced resources only when needed during execution
5. Follow the skill's instructions precisely
{display_pattern}
{location_hint}{evolution_block}
{skills_list}
</skill_system>"""
def get_skills_prompt_section(available_skills: set[str] | None = None, *, app_config: AppConfig | None = None) -> str:
def get_skills_prompt_section(
available_skills: set[str] | None = None,
*,
app_config: AppConfig | None = None,
display_enabled: bool = False,
) -> str:
"""Generate the skills prompt section with available skills list."""
skills = _get_enabled_skills_for_config(app_config)
@@ -739,11 +768,30 @@ def get_skills_prompt_section(available_skills: set[str] | None = None, *, app_c
return ""
skill_signature = tuple((skill.name, skill.description, skill.category, skill.get_container_file_path(container_base_path)) for skill in skills)
display_signature: tuple[tuple[str, str, str], ...] = ()
if display_enabled:
try:
from deerflow.config.extensions_config import get_extensions_config
extensions = get_extensions_config()
display_signature = tuple(
(
name,
skill_config.display.mode,
skill_config.display.custom_instruction,
)
for name, skill_config in sorted(extensions.skills.items())
if skill_config.display is not None
and skill_config.display.enabled
and skill_config.display.mode != "none"
)
except Exception:
logger.debug("Failed to load skill display configs for prompt injection", exc_info=True)
available_key = tuple(sorted(available_skills)) if available_skills is not None else None
if not skill_signature and available_key is not None:
return ""
skill_evolution_section = _build_skill_evolution_section(effective_skill_evolution_enabled)
return _get_cached_skills_prompt_section(skill_signature, available_key, container_base_path, skill_evolution_section)
return _get_cached_skills_prompt_section(skill_signature, display_signature, available_key, container_base_path, skill_evolution_section)
def get_agent_soul(agent_id: str | None) -> str:
@@ -869,6 +917,9 @@ def apply_prompt_template(
app_config: AppConfig | None = None,
soul_override: str | None = None,
memory_injection_enabled: bool = True,
rag_mode_enabled: bool = False,
is_notebook_mode: bool = False,
notebook_source_ids: list[int] | None = None,
) -> str:
storage_agent_id = agent_id if agent_id is not None else agent_name
@@ -901,7 +952,11 @@ def apply_prompt_template(
bootstrap_section = _build_bootstrap_section() if is_bootstrap else ""
# Get skills section
skills_section = get_skills_prompt_section(available_skills, app_config=app_config)
skills_section = get_skills_prompt_section(
available_skills,
app_config=app_config,
display_enabled=rag_mode_enabled,
)
# Get deferred tools section (tool_search)
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
@@ -939,6 +994,21 @@ Writing mode is enabled for this run.
# Build the offline-network policy block (empty unless DEER_FLOW_OFFLINE_MODE is set).
network_policy_section = _build_network_policy_section()
rag_mode_section = (
"""
<rag_citation_mode>
RAG/reference display mode is enabled.
When a search, knowledge, skill, or MCP tool returns a result list:
- If multiple search terms are needed, prefer calling the retrieval tools for all known terms in the same step, then answer after the tool results are returned.
- Treat every retrieval result from the same user turn as one combined `referenceBatch`; do not restart numbering for each search term, tool call, skill, or display adapter.
- Use only the system-provided merged reference numbers in the final answer, formatted as `[1]`, `[2]`, `[1][3][5]`.
- Do not output `<skill-display .../>` in RAG/reference mode. The frontend renders configured cards, tables, lists, and citation details in the right reference panel.
- If no display adapter exists, the frontend defaults the right reference panel to numbered citations.
</rag_citation_mode>
"""
if rag_mode_enabled
else ""
)
# Format the prompt with dynamic skills and memory
prompt = SYSTEM_PROMPT_TEMPLATE.format(
@@ -956,4 +1026,46 @@ Writing mode is enabled for this run.
acp_section=acp_and_mounts_section,
)
return prompt + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
prompt = prompt + rag_mode_section + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
if is_notebook_mode:
source_ids = notebook_source_ids or []
if source_ids:
source_line = f"当前检索范围:{len(source_ids)} 个来源(IDs: {', '.join(str(i) for i in source_ids)})"
else:
source_line = "当前检索范围:全部来源"
prompt += f"""
<notebook_mode>
## 角色
你是一个笔记本 AI 助手,**只能**基于笔记本文档来源回答用户问题,禁止使用任何其他知识。
## 可用知识来源
{source_line}
## 强制工作流程(每次用户提问都必须严格执行,无例外)
1. **第一步(必须)**:立即调用 `search_notebook_sources` 工具,`query` 填写用户问题或精炼的检索关键词。
- 无论问题多简单,都必须先检索,不得跳过。
- 若检索结果不足,可再次调用不同 query 补充检索。
2. **第二步**:仅基于检索到的内容组织回答,**不得使用训练知识或推断**。
3. **第三步**:若检索结果中确实无相关内容,明确告知用户"暂未在来源中找到相关信息"。
## 引用格式(强制)
- 每个事实/句子后立即插入 `[[cite:<chunk_id>]]` 标记,chunk_id 来自检索结果中每个文本块的 id 字段。
- 多个来源支持同一句话时连续插入:`[[cite:101]][[cite:102]]`
- 引用标记紧跟被引用内容末尾,不加空格,不单独成行。
## 示例
用户:合同中违约金是多少?
(调用 search_notebook_sources query="违约金",返回 chunk_id=101 的结果)
回答:根据合同第五条,违约金为合同总额的 0.5%[[cite:101]],乙方拒绝收货视为违约[[cite:101]]。
## 绝对禁止
- 未调用 `search_notebook_sources` 就直接回答
- 引用不在检索结果中的 chunk_id
- 省略引用标记
- 使用笔记本来源之外的知识
</notebook_mode>"""
return prompt
@@ -61,6 +61,21 @@ _AUTH_PATTERNS = (
"无权",
"未授权",
)
# Upstream model gateway returned a malformed payload (typically a JSON decode
# error wrapped as `bad_response_status_code`, or a truncated/garbled inference
# response). These are almost always transient inference-side issues and safe
# to retry.
_UPSTREAM_GARBAGE_PATTERNS = (
"bad_response_status_code",
"bad response status",
"expecting ',' delimiter",
"expecting value",
"expecting property name",
"unexpected character",
"unterminated string",
"extra data",
"jsondecodeerror",
)
class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
@@ -139,9 +154,10 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
detail = _extract_error_detail(exc)
lowered = detail.lower()
error_code = _extract_error_code(exc)
error_code_lower = str(error_code).lower() if error_code is not None else ""
status_code = _extract_status_code(exc)
if _matches_any(lowered, _QUOTA_PATTERNS) or _matches_any(str(error_code).lower(), _QUOTA_PATTERNS):
if _matches_any(lowered, _QUOTA_PATTERNS) or _matches_any(error_code_lower, _QUOTA_PATTERNS):
return False, "quota"
if _matches_any(lowered, _AUTH_PATTERNS):
return False, "auth"
@@ -159,6 +175,11 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
return True, "transient"
if _matches_any(lowered, _BUSY_PATTERNS):
return True, "busy"
# Treat upstream gateway garbage (malformed JSON, truncated responses)
# as a retriable transient — the deterministic char-position errors we
# see from vLLM/proxy layers usually clear on a re-roll.
if _matches_any(lowered, _UPSTREAM_GARBAGE_PATTERNS) or _matches_any(error_code_lower, _UPSTREAM_GARBAGE_PATTERNS):
return True, "upstream"
return False, "generic"
@@ -171,21 +192,34 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
def _build_retry_message(self, attempt: int, wait_ms: int, reason: str) -> str:
seconds = max(1, round(wait_ms / 1000))
reason_text = "服务繁忙" if reason == "busy" else "网络/连接异常"
if reason == "busy":
reason_text = "服务繁忙"
elif reason == "upstream":
reason_text = "上游响应异常"
else:
reason_text = "网络/连接异常"
return f"大模型因{reason_text}调用失败,正在第 {attempt}/{self.retry_max_attempts} 次重试,{seconds} 秒后继续…"
def _build_circuit_breaker_message(self) -> str:
return "大模型因连续多次调用失败已被熔断保护,请稍候再试。"
def _build_user_message(self, exc: BaseException, reason: str) -> str:
detail = _extract_error_detail(exc)
def _build_user_message(self, reason: str) -> str:
if reason == "quota":
return "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。"
if reason == "auth":
return "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。"
if reason in {"busy", "transient"}:
return "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。"
return f"大模型因「{detail}」原因调用失败。"
if reason == "upstream":
return "大模型当前响应异常(上游模型或网关临时故障),多次重试后仍未恢复,请稍后再试或更换模型。"
# NOTE: We intentionally do NOT embed the raw exception detail into the
# user-facing message. Raw `Error code: 400 - {...}` strings — especially
# Python repr-style dict literals — get persisted as AI messages and then
# re-fed to the model on the next turn, where their unescaped quotes /
# braces can corrupt the upstream JSON payload and cause deterministic
# JSONDecodeError loops. Keep the user message clean; full detail is
# already in the backend log via `logger.warning(..., exc_info=exc)`.
return "大模型本次调用未能成功完成,请稍后再试或更换模型。"
def _emit_retry_event(self, attempt: int, wait_ms: int, reason: str) -> None:
try:
@@ -249,7 +283,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
)
if retriable:
self._record_failure()
return AIMessage(content=self._build_user_message(exc, reason))
return AIMessage(content=self._build_user_message(reason))
@override
async def awrap_model_call(
@@ -295,7 +329,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
)
if retriable:
self._record_failure()
return AIMessage(content=self._build_user_message(exc, reason))
return AIMessage(content=self._build_user_message(reason))
def _matches_any(detail: str, patterns: tuple[str, ...]) -> bool:
@@ -137,7 +137,17 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
召回结果包在 ``<memory-context>`` 标签里并带系统提示,告诉模型这是
背景信息而非用户的新输入。
**跳过开关**:若 ``runtime.context.memory_recall_disabled`` 为真,直接
返回 None。圆桌规划等临时性任务(intent / recommend / leader / special)
不需要从用户个人记忆里召回,Hindsight HTTP 同步阻塞会显著增加 LLM 首
字节延迟(前端"调度中"卡顿);由路由层通过 ``_run_payload`` 的 context
字段统一关闭。
"""
runtime = getattr(request, "runtime", None)
if runtime is not None and getattr(runtime, "context", None):
if runtime.context.get("memory_recall_disabled"):
return None
if not self._injection_enabled:
return None
config = self._memory_config or get_memory_config()
@@ -0,0 +1,128 @@
"""Inject stable citation numbering before RAG final answers."""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from collections.abc import Awaitable, Callable
from typing import Any, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.messages import SystemMessage
from deerflow.runtime.references import (
build_latest_reference_prompt,
build_reference_debug_payload,
build_reference_debug_summary,
)
logger = logging.getLogger(__name__)
_RAG_DEBUG_LOGGER_NAME = "rag_citation_debug"
def _get_rag_debug_logger() -> logging.Logger:
debug_logger = logging.getLogger(_RAG_DEBUG_LOGGER_NAME)
if getattr(debug_logger, "_rag_file_configured", False):
return debug_logger
log_path = Path(os.environ.get("RAG_CITATION_DEBUG_LOG", "logs/rag_citation_debug.log"))
if not log_path.is_absolute():
log_path = Path.cwd() / log_path
log_path.parent.mkdir(parents=True, exist_ok=True)
handler = logging.FileHandler(log_path, encoding="utf-8")
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
debug_logger.addHandler(handler)
debug_logger.setLevel(logging.INFO)
debug_logger.propagate = False
setattr(debug_logger, "_rag_file_configured", True)
setattr(debug_logger, "_rag_file_path", str(log_path))
return debug_logger
def _log_rag_debug(message: str, *args: Any) -> None:
logger.info(message, *args)
_get_rag_debug_logger().info(message, *args)
class RagCitationMiddleware(AgentMiddleware[AgentState]):
"""Give the model the same numbered references the frontend will render."""
@staticmethod
def _enabled(request: ModelRequest) -> bool:
runtime = getattr(request, "runtime", None)
context = getattr(runtime, "context", None) if runtime is not None else None
if isinstance(context, dict) and context.get("rag_mode_enabled"):
return True
config = getattr(runtime, "config", None) if runtime is not None else None
if isinstance(config, dict):
configurable = config.get("configurable")
if isinstance(configurable, dict) and configurable.get("rag_mode_enabled"):
return True
runtime_context = config.get("context")
if isinstance(runtime_context, dict) and runtime_context.get("rag_mode_enabled"):
return True
return False
@staticmethod
def _patch(request: ModelRequest) -> ModelRequest | None:
if not RagCitationMiddleware._enabled(request):
return None
messages = list(request.messages)
prompt, batch = build_latest_reference_prompt(messages)
if not prompt:
_log_rag_debug(
"middleware reference-prompt enabled=true prompt=false messages=%s",
len(messages),
)
return None
summary = build_reference_debug_summary(messages, [batch] if batch else None)
_log_rag_debug(
"middleware reference-prompt enabled=true prompt=true single_batch=true messages=%s "
"tool_messages=%s raw_items=%s normalized_items=%s kept_items=%s latest_batch_id=%s "
"latest_batch_sources=%s",
summary["messages"],
summary["tool_messages"],
summary["raw_items"],
summary["normalized_items"],
summary["kept_items"],
(batch or {}).get("id"),
len((batch or {}).get("sources") or []),
)
_log_rag_debug(
"middleware reference-prompt-payload %s",
json.dumps(
build_reference_debug_payload(
messages=messages,
batch=batch,
prompt=prompt,
),
ensure_ascii=False,
default=str,
),
)
patched_messages = [
*messages,
SystemMessage(content=prompt),
]
return request.override(messages=patched_messages)
@override
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult:
patched = self._patch(request)
return handler(patched or request)
@override
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelCallResult:
patched = self._patch(request)
return await handler(patched or request)
@@ -840,10 +840,13 @@ class DeerFlowClient:
current_config = get_extensions_config()
config_data = {
"mcpServers": mcp_servers,
"skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()},
}
config_data = {
"mcpServers": mcp_servers,
"skills": {
name: skill.model_dump(by_alias=True, exclude_none=True)
for name, skill in current_config.skills.items()
},
}
self._atomic_write_json(config_path, config_data)
@@ -903,13 +906,20 @@ class DeerFlowClient:
if config_path is None:
raise FileNotFoundError("Cannot locate extensions_config.json. Set DEER_FLOW_EXTENSIONS_CONFIG_PATH or ensure it exists in the project root.")
extensions_config = get_extensions_config()
extensions_config.skills[name] = SkillStateConfig(enabled=enabled)
config_data = {
"mcpServers": {n: s.model_dump() for n, s in extensions_config.mcp_servers.items()},
"skills": {n: {"enabled": sc.enabled} for n, sc in extensions_config.skills.items()},
}
extensions_config = get_extensions_config()
current_skill_config = extensions_config.skills.get(name)
extensions_config.skills[name] = SkillStateConfig(
enabled=enabled,
display=current_skill_config.display if current_skill_config else None,
)
config_data = {
"mcpServers": {n: s.model_dump(by_alias=True) for n, s in extensions_config.mcp_servers.items()},
"skills": {
n: sc.model_dump(by_alias=True, exclude_none=True)
for n, sc in extensions_config.skills.items()
},
}
self._atomic_write_json(config_path, config_data)
@@ -1,7 +1,9 @@
"""Configuration and loaders for custom agents."""
import logging
import os
import re
import threading
from typing import Any
import yaml
@@ -13,6 +15,25 @@ logger = logging.getLogger(__name__)
SOUL_FILENAME = "SOUL.md"
AGENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
# Per-file mtime caches. These are read on every /api/agents list/detail request
# (one read per agent) — caching keyed by (path, mtime_ns, size) is correct
# because any edit to the file updates mtime, and an external tool re-writing
# the same bytes doesn't actually change the result we'd return. Cache size is
# unbounded but lookup is O(1) and entries are tiny (one parsed dict / string
# per agent file); for the order of N≤10⁴ agents the memory cost is negligible.
_config_cache: dict[str, tuple[tuple[int, int], "AgentConfig | None"]] = {}
_soul_cache: dict[str, tuple[tuple[int, int], str | None]] = {}
_cache_lock = threading.Lock()
def _file_stat_key(path) -> tuple[int, int] | None:
"""Return (mtime_ns, size) for cache invalidation, or None when missing."""
try:
st = os.stat(path)
except OSError:
return None
return (st.st_mtime_ns, st.st_size)
# Backward-compatible alias. Historically this validated the display name;
# after the DB-backed agent table, display names can be Chinese and duplicated.
# The pattern now applies only to storage/runtime ids.
@@ -96,6 +117,18 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None:
)
return None
# mtime-based cache. The config file is rewritten every time the agent is
# edited via PUT /api/agents/{id}, which bumps mtime — so the next read
# picks up the change automatically. Missing config_file = key None below,
# bypassing the cache (cheap path: just return AgentConfig with id/name).
cache_path_key = str(config_file)
stat_key = _file_stat_key(config_file) if config_file.exists() else None
if stat_key is not None:
with _cache_lock:
cached = _config_cache.get(cache_path_key)
if cached is not None and cached[0] == stat_key:
return cached[1]
data: dict[str, Any] = {}
if config_file.exists():
try:
@@ -114,7 +147,11 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None:
known_fields = set(AgentConfig.model_fields.keys())
data = {k: v for k, v in data.items() if k in known_fields}
return AgentConfig(**data)
result = AgentConfig(**data)
if stat_key is not None:
with _cache_lock:
_config_cache[cache_path_key] = (stat_key, result)
return result
def load_agent_soul(agent_id: str | None) -> str | None:
@@ -133,8 +170,21 @@ def load_agent_soul(agent_id: str | None) -> str | None:
soul_path = agent_dir / SOUL_FILENAME
if not soul_path.exists():
return None
# mtime-based cache, same rationale as load_agent_config: SOUL.md is
# rewritten on edit (PUT /api/agents/{id}), which bumps mtime.
cache_path_key = str(soul_path)
stat_key = _file_stat_key(soul_path)
if stat_key is not None:
with _cache_lock:
cached = _soul_cache.get(cache_path_key)
if cached is not None and cached[0] == stat_key:
return cached[1]
content = soul_path.read_text(encoding="utf-8").strip()
return content or None
result = content or None
if stat_key is not None:
with _cache_lock:
_soul_cache[cache_path_key] = (stat_key, result)
return result
def list_custom_agents() -> list[AgentConfig]:
@@ -3,7 +3,7 @@
import json
import os
from pathlib import Path
from typing import Any, Literal
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -53,10 +53,64 @@ class McpServerConfig(BaseModel):
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillStateConfig(BaseModel):
"""Configuration for a single skill's state."""
enabled: bool = Field(default=True, description="Whether this skill is enabled")
class SkillDisplayTableColumn(BaseModel):
"""A configured table column for structured skill result display."""
label: str = Field(description="Column label shown in the frontend table")
field: str = Field(description="Field name or dot path read from each result item")
width: str | None = Field(default=None, description="Optional CSS width for this column")
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillDisplayConfig(BaseModel):
"""Frontend display adapter for structured skill results."""
enabled: bool = Field(default=False, description="Whether result display adaptation is enabled")
mode: Literal["citation", "list", "table", "card", "custom", "none"] = Field(
default="none",
description="How the frontend should render this skill's structured results",
)
result_path: str = Field(default="results", description="Dot path to the array of result items", alias="resultPath")
title_field: str | None = Field(default=None, description="Field used as item title", alias="titleField")
summary_field: str | None = Field(default=None, description="Field used as short summary/snippet", alias="summaryField")
content_field: str | None = Field(default=None, description="Field used as full item content", alias="contentField")
source_field: str | None = Field(default=None, description="Field used as source label", alias="sourceField")
author_field: str | None = Field(default=None, description="Field used as author/byline label", alias="authorField")
type_field: str | None = Field(default=None, description="Field used as source/type badge", alias="typeField")
time_field: str | None = Field(default=None, description="Field used as publication/update time", alias="timeField")
score_field: str | None = Field(default=None, description="Field used as relevance score", alias="scoreField")
url_field: str | None = Field(default=None, description="Field containing a direct URL", alias="urlField")
id_field: str | None = Field(default=None, description="Field containing an ID for template URLs", alias="idField")
link_strategy: Literal["auto", "url", "id"] = Field(
default="auto",
description="Whether links use a URL field, an ID template, or automatic fallback",
alias="linkStrategy",
)
link_template: str | None = Field(default=None, description="Template used when linkStrategy is id", alias="linkTemplate")
citation_token: Literal["doubleBracket", "singleBracket"] = Field(
default="doubleBracket",
description="Citation token style expected in model output",
alias="citationToken",
)
max_items: int = Field(default=8, ge=1, le=100, description="Maximum displayed items", alias="maxItems")
custom_instruction: str = Field(
default="",
description="User-authored instruction for custom rendering schema generation",
alias="customInstruction",
)
table_columns: list[SkillDisplayTableColumn] = Field(
default_factory=list,
description="Columns used when mode is table",
alias="tableColumns",
)
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillStateConfig(BaseModel):
"""Configuration for a single skill's state."""
enabled: bool = Field(default=True, description="Whether this skill is enabled")
display: SkillDisplayConfig | None = Field(default=None, description="Optional frontend display adapter")
class ExtensionsConfig(BaseModel):
@@ -17,6 +17,7 @@ import json
import logging
import threading
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -37,10 +38,53 @@ class PromptPrefixSettings(BaseModel):
model_config = ConfigDict(extra="ignore")
class AppearanceSettings(BaseModel):
"""System-wide appearance defaults that any *new* user inherits.
Once a user explicitly toggles their theme in the UI, that choice is
persisted in the browser and supersedes this default — so changing
``default_theme`` here only affects users who have never touched the
toggle, and any future first-time loads.
"""
default_theme: Literal["light", "dark"] = Field(
default="light",
description="Theme applied for users who haven't picked their own (light / dark)",
)
model_config = ConfigDict(extra="ignore")
class CitationDisplaySettings(BaseModel):
"""System-wide cleanup rules for citation cards and detail dialogs."""
cleanup_words: list[str] = Field(
default_factory=list,
description="Words or phrases used to remove noisy advertisement/disclaimer lines from citation display.",
)
model_config = ConfigDict(extra="ignore")
class PrivateSquareSettings(BaseModel):
"""Admin-configurable settings for the agent private-square (私密广场).
The password gates read access to all agents flagged ``is_private=True``.
The hash is stored opaquely — the router layer is responsible for hashing
and verifying so the harness package keeps zero dependencies on
``app.gateway.auth``.
"""
enabled: bool = Field(default=False, description="Whether the private square is offered to users at all")
password_hash: str = Field(default="", description="Opaque hash of the admin-configured password. Empty = no password set yet.")
model_config = ConfigDict(extra="ignore")
class SystemSettings(BaseModel):
"""Top-level system settings document."""
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
model_config = ConfigDict(extra="ignore")
@@ -4,10 +4,11 @@ from __future__ import annotations
from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.agents.memory import MemoryAgentStore
from deerflow.persistence.agents.model import AgentRow
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
from deerflow.persistence.agents.sql import AgentRepository
__all__ = [
"AgentFavoriteRow",
"AgentRepository",
"AgentRow",
"AgentStore",
@@ -81,3 +81,61 @@ class AgentStore(ABC):
async def list_by_ids(self, agent_ids: list[str], user_id: str) -> list[dict[str, Any]]:
"""Return rows for ``agent_ids`` visible to ``user_id``, preserving the input order."""
raise NotImplementedError
@abstractmethod
async def list_private(self) -> list[dict[str, Any]]:
"""Return every agent flagged ``is_private=True``.
Caller is responsible for authorization — typically the caller has
already verified the admin-configured private-square password.
"""
raise NotImplementedError
@abstractmethod
async def list_all(self) -> list[dict[str, Any]]:
"""Return every agent regardless of ownership/visibility.
Caller is responsible for authorization — typically the caller has
already verified ``system_role == "admin"``.
"""
raise NotImplementedError
@abstractmethod
async def update_any(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
"""Update any agent row by id, regardless of ownership.
Caller is responsible for authorization (admin only). Returns ``None``
if the agent does not exist.
"""
raise NotImplementedError
@abstractmethod
async def delete_any(self, agent_id: str) -> bool:
"""Delete any agent row by id, regardless of ownership.
Caller is responsible for authorization (admin only). Returns
``False`` if the agent does not exist.
"""
raise NotImplementedError
# --- Favorites ("收藏") ---
@abstractmethod
async def add_favorite(self, user_id: str, agent_id: str) -> None:
"""Bookmark ``agent_id`` for ``user_id``. Idempotent."""
raise NotImplementedError
@abstractmethod
async def remove_favorite(self, user_id: str, agent_id: str) -> bool:
"""Remove a bookmark. Returns ``False`` when none existed."""
raise NotImplementedError
@abstractmethod
async def list_favorite_ids(self, user_id: str) -> set[str]:
"""Return the set of agent ids ``user_id`` has favorited."""
raise NotImplementedError
@abstractmethod
async def delete_favorites_for_agent(self, agent_id: str) -> None:
"""Drop every user's favorite of ``agent_id`` (called on agent delete)."""
raise NotImplementedError
@@ -21,6 +21,7 @@ def _copy(data: dict[str, Any]) -> dict[str, Any]:
class MemoryAgentStore(AgentStore):
def __init__(self) -> None:
self._agents: dict[str, dict[str, Any]] = {}
self._favorites: set[tuple[str, str]] = set()
@staticmethod
def _is_visible(agent: dict[str, Any], user_id: str) -> bool:
@@ -138,3 +139,46 @@ class MemoryAgentStore(AgentStore):
rows = [self._agents[aid] for aid in agent_ids if aid in self._agents and self._is_visible(self._agents[aid], user_id)]
rows.sort(key=lambda a: order_map.get(a["id"], 0))
return [_copy(a) for a in rows]
async def list_private(self) -> list[dict[str, Any]]:
rows = [_copy(a) for a in self._agents.values() if bool(a.get("is_private"))]
rows.sort(key=lambda a: a.get("created_at") or "")
return rows
async def list_all(self) -> list[dict[str, Any]]:
rows = [_copy(a) for a in self._agents.values()]
rows.sort(key=lambda a: a.get("created_at") or "")
return rows
async def update_any(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
row = self._agents.get(agent_id)
if row is None:
return None
for key in ("name", "description", "published", "is_private"):
if key in data:
row[key] = data[key]
row["updated_at"] = _now()
return _copy(row)
async def delete_any(self, agent_id: str) -> bool:
if agent_id not in self._agents:
return False
self._agents.pop(agent_id, None)
return True
# --- Favorites ("收藏") ---
async def add_favorite(self, user_id: str, agent_id: str) -> None:
self._favorites.add((user_id, agent_id))
async def remove_favorite(self, user_id: str, agent_id: str) -> bool:
if (user_id, agent_id) in self._favorites:
self._favorites.discard((user_id, agent_id))
return True
return False
async def list_favorite_ids(self, user_id: str) -> set[str]:
return {agent_id for (uid, agent_id) in self._favorites if uid == user_id}
async def delete_favorites_for_agent(self, agent_id: str) -> None:
self._favorites = {(uid, aid) for (uid, aid) in self._favorites if aid != agent_id}
@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import BigInteger, Boolean, String
from sqlalchemy import BigInteger, Boolean, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
@@ -21,6 +21,12 @@ class AgentRow(Base):
description: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
# When True, the agent is moved into the password-gated "private square"
# (私密广场) and is hidden from the regular public square / built-in list.
# The flag is independent of ``published``: a private agent is still
# visible to its owner in the "我的" tab, and visible to anyone in the
# private-square listing once the password has been verified.
is_private: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
# NULL = not featured on the homepage selector. Non-NULL = admin-curated
# featured rank (lower sorts first). Only ``published=True`` agents are
# eligible; the router enforces that invariant on writes. BIGINT so the
@@ -29,3 +35,20 @@ class AgentRow(Base):
featured_order: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=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))
class AgentFavoriteRow(Base):
"""A user's bookmark of a (typically public-square) agent.
Lets a user collect agents from the 广场 (square) into their "我的" tab
without owning them. One row per (user, agent); the unique constraint makes
favoriting idempotent.
"""
__tablename__ = "agent_favorites"
__table_args__ = (UniqueConstraint("user_id", "agent_id", name="uq_agent_favorites_user_agent"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
@@ -4,12 +4,14 @@ from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import func, or_, select
from sqlalchemy import and_, delete, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.agents.model import AgentRow
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
def _row_to_dict(row: AgentRow) -> dict[str, Any]:
@@ -28,10 +30,31 @@ class AgentRepository(AgentStore):
@staticmethod
def _visible_filter(user_id: str):
"""Visibility for single-record fetches (``get_visible``).
Lenient: a caller who already knows the agent id can fetch it even
when it's flagged ``is_private``. The private-square password is the
*discovery* gate (listings); individual reads are not re-gated, so a
bookmarked / linked private agent stays openable.
"""
return or_(AgentRow.user_id.is_(None), AgentRow.user_id == user_id, AgentRow.published.is_(True))
@staticmethod
def _listing_filter(user_id: str):
"""Visibility for *listings* — hides private-square agents from
non-owners so they only surface inside the password-gated endpoint.
Owners keep seeing their own private agents in '我的'.
"""
return or_(
AgentRow.user_id == user_id,
and_(
or_(AgentRow.user_id.is_(None), AgentRow.published.is_(True)),
AgentRow.is_private.is_(False),
),
)
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
stmt = select(AgentRow).where(self._visible_filter(user_id)).order_by(AgentRow.created_at.asc())
stmt = select(AgentRow).where(self._listing_filter(user_id)).order_by(AgentRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
@@ -61,6 +84,7 @@ class AgentRepository(AgentStore):
description=data.get("description") or "",
user_id=data.get("user_id"),
published=bool(data.get("published", False)),
is_private=bool(data.get("is_private", False)),
created_at=now,
updated_at=now,
)
@@ -99,7 +123,7 @@ class AgentRepository(AgentStore):
row = await session.get(AgentRow, agent_id)
if row is None or row.user_id != user_id:
return None
for key in ("name", "description", "published"):
for key in ("name", "description", "published", "is_private"):
if key in data:
setattr(row, key, data[key])
row.updated_at = datetime.now(UTC)
@@ -112,7 +136,7 @@ class AgentRepository(AgentStore):
row = await session.get(AgentRow, agent_id)
if row is None or row.user_id is not None:
return None
for key in ("name", "description", "published"):
for key in ("name", "description", "published", "is_private"):
if key in data:
setattr(row, key, data[key])
row.updated_at = datetime.now(UTC)
@@ -157,7 +181,7 @@ class AgentRepository(AgentStore):
async def list_featured(self, user_id: str) -> list[dict[str, Any]]:
stmt = (
select(AgentRow)
.where(AgentRow.featured_order.is_not(None), self._visible_filter(user_id))
.where(AgentRow.featured_order.is_not(None), self._listing_filter(user_id))
.order_by(AgentRow.featured_order.asc(), AgentRow.created_at.asc())
)
async with self._sf() as session:
@@ -168,9 +192,87 @@ class AgentRepository(AgentStore):
if not agent_ids:
return []
order_map = {aid: idx for idx, aid in enumerate(agent_ids)}
stmt = select(AgentRow).where(AgentRow.id.in_(agent_ids), self._visible_filter(user_id))
stmt = select(AgentRow).where(AgentRow.id.in_(agent_ids), self._listing_filter(user_id))
async with self._sf() as session:
result = await session.execute(stmt)
rows = list(result.scalars())
rows.sort(key=lambda r: order_map.get(r.id, len(order_map)))
return [_row_to_dict(r) for r in rows]
async def list_private(self) -> list[dict[str, Any]]:
stmt = select(AgentRow).where(AgentRow.is_private.is_(True)).order_by(AgentRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
async def list_all(self) -> list[dict[str, Any]]:
stmt = select(AgentRow).order_by(AgentRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
async def update_any(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(AgentRow, agent_id)
if row is None:
return None
for key in ("name", "description", "published", "is_private"):
if key in data:
setattr(row, key, data[key])
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def delete_any(self, agent_id: str) -> bool:
async with self._sf() as session:
row = await session.get(AgentRow, agent_id)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
# --- Favorites ("收藏") ---
async def add_favorite(self, user_id: str, agent_id: str) -> None:
async with self._sf() as session:
existing = (
await session.execute(
select(AgentFavoriteRow.id)
.where(AgentFavoriteRow.user_id == user_id, AgentFavoriteRow.agent_id == agent_id)
.limit(1)
)
).scalar_one_or_none()
if existing is not None:
return
session.add(AgentFavoriteRow(id=uuid4().hex, user_id=user_id, agent_id=agent_id))
try:
await session.commit()
except IntegrityError:
# Lost a race against a concurrent favorite of the same agent —
# the unique constraint already guarantees the desired state.
await session.rollback()
async def remove_favorite(self, user_id: str, agent_id: str) -> bool:
async with self._sf() as session:
result = await session.execute(
delete(AgentFavoriteRow).where(
AgentFavoriteRow.user_id == user_id,
AgentFavoriteRow.agent_id == agent_id,
)
)
await session.commit()
return (result.rowcount or 0) > 0
async def list_favorite_ids(self, user_id: str) -> set[str]:
async with self._sf() as session:
result = await session.execute(
select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id)
)
return {row[0] for row in result.all()}
async def delete_favorites_for_agent(self, agent_id: str) -> None:
async with self._sf() as session:
await session.execute(delete(AgentFavoriteRow).where(AgentFavoriteRow.agent_id == agent_id))
await session.commit()
@@ -0,0 +1,62 @@
"""Add ``agents.is_private`` for the private-square (私密广场) feature.
A nullable-default bool column flagging agents that should live in the
password-gated private square instead of the regular public square.
Revision ID: 20260526_01
Revises: 20260522_01
Create Date: 2026-05-26
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260526_01"
down_revision = "20260522_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 _has_index(table_name: str, index_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
def upgrade() -> None:
if _has_table("agents") and not _has_column("agents", "is_private"):
op.add_column(
"agents",
sa.Column(
"is_private",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
if _has_table("agents") and not _has_index("agents", "ix_agents_is_private"):
op.create_index("ix_agents_is_private", "agents", ["is_private"])
def downgrade() -> None:
if _has_index("agents", "ix_agents_is_private"):
op.drop_index("ix_agents_is_private", table_name="agents")
if _has_column("agents", "is_private"):
op.drop_column("agents", "is_private")
@@ -0,0 +1,45 @@
"""Add ``agent_favorites`` for the agent collection (收藏) feature.
Lets a user bookmark agents from the public square (广场) so they surface in
their "我的" tab without owning them. One row per (user, agent).
Revision ID: 20260528_01
Revises: 20260526_01
Create Date: 2026-05-28
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260528_01"
down_revision = "20260526_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 upgrade() -> None:
if not _has_table("agent_favorites"):
op.create_table(
"agent_favorites",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("user_id", sa.String(64), nullable=False),
sa.Column("agent_id", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("user_id", "agent_id", name="uq_agent_favorites_user_agent"),
)
op.create_index("ix_agent_favorites_user_id", "agent_favorites", ["user_id"])
op.create_index("ix_agent_favorites_agent_id", "agent_favorites", ["agent_id"])
def downgrade() -> None:
if _has_table("agent_favorites"):
op.drop_table("agent_favorites")
@@ -0,0 +1,45 @@
"""Add ``user_custom_prompts`` for per-user saved prompts (常用提示词).
Bound to the user (not the browser) so prompts follow the account.
Revision ID: 20260528_02
Revises: 20260528_01
Create Date: 2026-05-28
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260528_02"
down_revision = "20260528_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 upgrade() -> None:
if not _has_table("user_custom_prompts"):
op.create_table(
"user_custom_prompts",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("user_id", sa.String(64), nullable=False),
sa.Column("title", sa.String(255), nullable=False, server_default=""),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_user_custom_prompts_user_id", "user_custom_prompts", ["user_id"])
def downgrade() -> None:
if _has_table("user_custom_prompts"):
op.drop_table("user_custom_prompts")
@@ -15,7 +15,7 @@ its storage implementation lives in ``deerflow.runtime.events.store.db`` and
there is no matching entity directory.
"""
from deerflow.persistence.agents.model import AgentRow
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
@@ -30,15 +30,17 @@ from deerflow.persistence.scheduled_tasks.model import (
ScheduledTaskSubscriptionRow,
SchedulerThreadRow,
)
from deerflow.persistence.skills.model import SkillRow
from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillRow
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.thread_shares.model import ThreadShareRow
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
from deerflow.persistence.user.model import UserRow
from deerflow.persistence.user_prompts.model import UserCustomPromptRow
__all__ = [
"FeedbackRow",
"AgentFavoriteRow",
"AgentRow",
"LlmCallMetricRow",
"NotificationRow",
@@ -51,11 +53,13 @@ __all__ = [
"ScheduledTaskSubscriptionRow",
"SchedulerThreadRow",
"SkillRow",
"SkillDisplayAdapterRow",
"TagAssignmentRow",
"TagRow",
"ThreadMetaRow",
"ThreadShareRow",
"ToolCallMetricRow",
"UserCustomPromptRow",
"UserMemoryConfigRow",
"UserRow",
]
@@ -68,6 +68,14 @@ class SkillStore(ABC):
"""Return the number of skills owned by ``user_id`` (owner_user_id IS NOT NULL)."""
raise NotImplementedError
@abstractmethod
async def list_all(self) -> list[dict[str, Any]]:
"""Return every skill record regardless of ownership/visibility.
Caller is responsible for authorization (admin only).
"""
raise NotImplementedError
@abstractmethod
async def list_published_names(self) -> set[str]:
"""Return the set of skill names with ``published = True`` (any owner).
@@ -134,5 +134,11 @@ class MemorySkillStore(SkillStore):
async def count_owned(self, user_id: str) -> int:
return sum(1 for s in self._skills.values() if s.get("owner_user_id") == user_id)
async def list_all(self) -> list[dict[str, Any]]:
return [
_with_derived(_copy(record)) # type: ignore[arg-type]
for record in self._skills.values()
]
async def list_published_names(self) -> set[str]:
return {name for name, record in self._skills.items() if record.get("published")}
@@ -31,3 +31,16 @@ class SkillRow(Base):
detail: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
class SkillDisplayAdapterRow(Base):
__tablename__ = "skill_display_adapters"
skill_name: Mapped[str] = mapped_column(String(64), primary_key=True)
# Store JSON as text for maximum MySQL compatibility. Some deployments still
# run MySQL variants where native JSON columns or unquoted names such as
# ``display`` are not accepted consistently.
display_config: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
sample_payload: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -149,6 +149,12 @@ class SkillRepository(SkillStore):
async with self._sf() as session:
return await session.scalar(stmt) or 0
async def list_all(self) -> list[dict[str, Any]]:
stmt = select(SkillRow).order_by(SkillRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
async def list_published_names(self) -> set[str]:
stmt = select(SkillRow.name).where(SkillRow.published.is_(True))
async with self._sf() as session:
@@ -0,0 +1,18 @@
"""Per-user custom prompt persistence."""
from __future__ import annotations
from deerflow.persistence.user_prompts.model import UserCustomPromptRow
from deerflow.persistence.user_prompts.sql import UserCustomPromptRepository
__all__ = [
"UserCustomPromptRepository",
"UserCustomPromptRow",
"make_user_prompt_store",
]
def make_user_prompt_store(session_factory):
if session_factory is None:
return None
return UserCustomPromptRepository(session_factory)
@@ -0,0 +1,23 @@
"""ORM model for per-user custom prompts (常用提示词)."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class UserCustomPromptRow(Base):
__tablename__ = "user_custom_prompts"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False, default="")
content: Mapped[str] = mapped_column(Text, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
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,74 @@
"""SQLAlchemy-backed per-user custom prompt repository."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.user_prompts.model import UserCustomPromptRow
def _row_to_dict(row: UserCustomPromptRow) -> 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 UserCustomPromptRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_for_user(self, user_id: str) -> list[dict[str, Any]]:
stmt = (
select(UserCustomPromptRow)
.where(UserCustomPromptRow.user_id == user_id)
.order_by(UserCustomPromptRow.sort_order.asc(), UserCustomPromptRow.created_at.asc())
)
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
async def create(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = UserCustomPromptRow(
id=data["id"],
user_id=user_id,
title=data.get("title") or "",
content=data["content"],
sort_order=data.get("sort_order", 0),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def update(self, prompt_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(UserCustomPromptRow, prompt_id)
if row is None or row.user_id != user_id:
return None
for key in ("title", "content", "sort_order"):
if key in data:
setattr(row, key, data[key])
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def delete(self, prompt_id: str, user_id: str) -> bool:
async with self._sf() as session:
row = await session.get(UserCustomPromptRow, prompt_id)
if row is None or row.user_id != user_id:
return False
await session.delete(row)
await session.commit()
return True
@@ -0,0 +1,865 @@
"""Build stable per-turn citation batches from LangGraph messages."""
from __future__ import annotations
import json
import re
from typing import Any
from urllib.parse import quote
from deerflow.config.extensions_config import SkillDisplayConfig, get_extensions_config
_SKILL_PATH_RE = re.compile(r"[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)")
def build_reference_batches(messages: list[Any]) -> list[dict[str, Any]]:
batches: list[dict[str, Any]] = []
for index, message in enumerate(messages):
if _message_type(message) != "human":
continue
end = len(messages)
for cursor in range(index + 1, len(messages)):
if _message_type(messages[cursor]) == "human":
end = cursor
break
sources = _collect_sources(messages[index + 1 : end])
if sources:
anchor = _find_reference_batch_anchor(messages, index + 1, end)
message_id = _message_id(anchor) or _message_id(message) or str(index)
batches.append(
{
"id": message_id,
"assistant_message_id": message_id,
"sources": sources,
}
)
return batches
def build_latest_reference_batch(messages: list[Any]) -> dict[str, Any] | None:
"""Return the single combined citation batch for the latest human turn."""
human_index = _find_latest_human_index(messages)
if human_index is None:
return None
sources = _collect_sources(messages[human_index + 1 :])
if not sources:
return None
assistant = _find_reference_batch_anchor(messages, human_index + 1, len(messages))
message_id = _message_id(assistant) or _message_id(messages[human_index]) or str(human_index)
return {
"id": message_id,
"assistant_message_id": message_id,
"source_count": len(sources),
"sources": sources,
}
def build_latest_reference_prompt(messages: list[Any]) -> tuple[str, dict[str, Any] | None]:
batch = build_latest_reference_batch(messages)
if batch is None:
return "", None
return build_reference_prompt_for_batch(batch), batch
def build_reference_prompt_for_batch(batch: dict[str, Any]) -> str:
"""Build the authoritative model prompt from one already-combined batch."""
sources = [
source
for source in batch.get("sources", [])
if isinstance(source, dict)
]
source_count = len(sources)
lines = [
f"本轮所有检索结果已合并为同一个 referenceBatch,共 {source_count} 条,编号从 [1] 到 [{source_count}] 唯一连续。",
"下面这一批就是当前回答唯一可用的参考文献批次;不要按检索词、工具调用次数或工具原始编号重新分批编号。",
"如果你仍需要补充检索,请只继续调用工具,不要输出正文;最终回答只能引用补充检索后系统重新合并出的最新 referenceBatch。",
"",
f"<reference_batch source_count=\"{source_count}\">",
]
for source in sources:
title = source.get("title") or f"参考文献 {source.get('index')}"
meta = " · ".join(
str(item)
for item in (source.get("source"), source.get("author"), source.get("time"))
if item
)
content = str(source.get("content") or source.get("snippet") or "").strip()
if len(content) > 520:
content = content[:517] + "..."
head = f"[{source.get('index')}] {title}"
if meta:
head += f"({meta})"
lines.append(head)
if content:
lines.append(content)
lines.append("")
lines.append("</reference_batch>")
lines.append("")
lines.extend(
[
"引用规则:",
"1. 回答正文中的关键结论必须标注对应编号,例如 [1]、[2]。",
"2. 多个来源可写为 [1][3][5]。",
"3. 只能引用上方存在的编号,不要编造编号。",
"4. 不要使用工具原始编号,也不要重新排序、重新分批或重新从 [1] 开始编号。",
"5. 不要在正文末尾堆完整参考文献列表,前端会展示右侧参考文献。",
"6. 参考文献模式下不要输出 <skill-display .../> 占位符,也不要在正文里额外渲染卡片、表格或列表;前端会在右侧参考文献区域展示。",
]
)
return "\n".join(lines).strip()
def _find_latest_human_index(messages: list[Any]) -> int | None:
for index in range(len(messages) - 1, -1, -1):
if _message_type(messages[index]) == "human":
return index
return None
def build_reference_debug_summary(messages: list[Any], batches: list[dict[str, Any]] | None = None) -> dict[str, Any]:
"""Return compact counts for citation extraction logs."""
turn_summaries: list[dict[str, Any]] = []
total_tool_messages = 0
total_raw_items = 0
total_normalized_items = 0
total_kept_items = 0
for index, message in enumerate(messages):
if _message_type(message) != "human":
continue
end = len(messages)
for cursor in range(index + 1, len(messages)):
if _message_type(messages[cursor]) == "human":
end = cursor
break
tool_messages = 0
raw_items = 0
normalized_items = 0
kept_items = 0
for tool_message in messages[index + 1 : end]:
if _message_type(tool_message) != "tool":
continue
tool_messages += 1
items = _first_list(_extract_tool_payload(tool_message))
raw_items += len(items)
for raw_item in items:
normalized = _normalize_reference_item(raw_item, normalized_items + 1)
if not normalized:
continue
normalized_items += 1
kept_items += 1
total_tool_messages += tool_messages
total_raw_items += raw_items
total_normalized_items += normalized_items
total_kept_items += kept_items
turn_summaries.append(
{
"human_index": index,
"tool_messages": tool_messages,
"raw_items": raw_items,
"normalized_items": normalized_items,
"kept_items": kept_items,
}
)
batch_summaries = [
{
"id": batch.get("id"),
"assistant_message_id": batch.get("assistant_message_id"),
"source_count": len(batch.get("sources") or []),
}
for batch in (batches or build_reference_batches(messages))
]
return {
"messages": len(messages),
"turns": len(turn_summaries),
"tool_messages": total_tool_messages,
"raw_items": total_raw_items,
"normalized_items": total_normalized_items,
"kept_items": total_kept_items,
"turns_detail": turn_summaries,
"batches": batch_summaries,
}
def build_reference_debug_payload(
*,
messages: list[Any] | None = None,
batch: dict[str, Any] | None = None,
batches: list[dict[str, Any]] | None = None,
prompt: str | None = None,
) -> dict[str, Any]:
"""Return a JSON-safe payload for comparing model prompts and UI batches."""
payload: dict[str, Any] = {}
if messages is not None:
payload["message_trace"] = [_message_debug_item(index, message) for index, message in enumerate(messages)]
if prompt is not None:
payload["model_reference_prompt"] = prompt
if batch is not None:
payload["batch"] = _batch_debug_item(batch)
if batches is not None:
payload["batches"] = [_batch_debug_item(item) for item in batches]
return payload
def adapt_reference_batches_with_displays(
batches: list[dict[str, Any]],
displays: dict[str, SkillDisplayConfig | dict[str, Any]],
) -> list[dict[str, Any]]:
"""Apply persisted skill display configs to already-built reference batches.
The reference builder can run before the gateway has a fresh extensions
cache, while the skill adapter editor persists the user's latest mapping in
the database. This pass lets the API response use that DB-backed mapping
without changing citation indexes.
"""
normalized_displays: dict[str, SkillDisplayConfig] = {}
for skill_name, display in displays.items():
try:
config = (
display
if isinstance(display, SkillDisplayConfig)
else SkillDisplayConfig.model_validate(display)
)
except Exception:
continue
if not config.enabled or config.mode == "none":
continue
normalized_displays[skill_name] = config
if not normalized_displays:
return batches
displays_by_normalized_name = {
_normalize_name(name): (name, display)
for name, display in normalized_displays.items()
}
adapted_batches: list[dict[str, Any]] = []
for batch in batches:
sources: list[dict[str, Any]] = []
for source in batch.get("sources", []):
if not isinstance(source, dict):
continue
skill_name = str(source.get("skillName") or "")
display = normalized_displays.get(skill_name)
if display is None:
normalized_source = _normalize_name(skill_name)
normalized_match = displays_by_normalized_name.get(normalized_source)
if normalized_match is None:
for normalized_skill, candidate in displays_by_normalized_name.items():
if (
normalized_source.endswith(normalized_skill)
or normalized_skill in normalized_source
):
normalized_match = candidate
break
if normalized_match is not None:
skill_name, display = normalized_match
raw = source.get("raw")
if display is not None and isinstance(raw, dict):
index_value = int(source.get("index") or len(sources) + 1)
configured = _normalize_with_display_config(
raw,
index_value,
skill_name,
display,
)
if configured is not None:
sources.append(
{
**source,
**configured,
"index": index_value,
"skillName": skill_name,
"raw": raw,
}
)
continue
sources.append(source)
adapted_batches.append({**batch, "sources": sources})
return adapted_batches
def _message_debug_item(index: int, message: Any) -> dict[str, Any]:
content = _message_content(message)
tool_calls = _extract_tool_calls(message)
return {
"position": index,
"type": _message_type(message),
"id": _message_id(message),
"name": _message_name(message) if _message_type(message) == "tool" else None,
"tool_call_id": _message_tool_call_id(message) if _message_type(message) == "tool" else None,
"tool_calls": [
{
"id": call.get("id"),
"name": call.get("name"),
"args": call.get("args"),
}
for call in tool_calls
],
"content_length": len(content) if isinstance(content, str) else None,
}
def _batch_debug_item(batch: dict[str, Any]) -> dict[str, Any]:
sources = [source for source in batch.get("sources", []) if isinstance(source, dict)]
return {
"id": batch.get("id"),
"assistant_message_id": batch.get("assistant_message_id"),
"source_count": len(sources),
"sources": [_source_debug_item(source) for source in sources],
}
def _source_debug_item(source: dict[str, Any]) -> dict[str, Any]:
raw = source.get("raw")
raw_record = raw if isinstance(raw, dict) else {}
return {
"index": source.get("index"),
"id": source.get("id"),
"skillName": source.get("skillName"),
"mode": source.get("mode"),
"title": source.get("title"),
"source": source.get("source"),
"author": source.get("author"),
"time": source.get("time"),
"url": source.get("url"),
"snippet": source.get("snippet"),
"content": source.get("content"),
"raw_id_candidates": {
"recUuid": raw_record.get("recUuid"),
"rec_uuid": raw_record.get("rec_uuid"),
"uuid": raw_record.get("uuid"),
"docId": raw_record.get("docId"),
"doc_id": raw_record.get("doc_id"),
"id": raw_record.get("id"),
"url": raw_record.get("url"),
},
"raw": raw,
}
def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
sources: list[dict[str, Any]] = []
pending_tool_contexts: list[dict[str, Any]] = []
tool_contexts_by_id: dict[str, dict[str, Any]] = {}
for message in messages:
message_type = _message_type(message)
if message_type == "ai":
for tool_call in _extract_tool_calls(message):
context = _build_tool_context(tool_call)
pending_tool_contexts.append(context)
if context.get("id"):
tool_contexts_by_id[str(context["id"])] = context
continue
if message_type != "tool":
continue
tool_context = _resolve_tool_context(
message,
pending_tool_contexts,
tool_contexts_by_id,
)
payload = _extract_tool_payload(message)
for raw_item in _first_list(payload):
normalized = _normalize_reference_item(
raw_item,
len(sources) + 1,
payload=payload,
tool_context=tool_context,
)
if not normalized:
continue
normalized["index"] = len(sources) + 1
sources.append(normalized)
return sources
def _find_reference_batch_anchor(messages: list[Any], start: int, end: int) -> Any | None:
answer = _find_last_assistant_answer(messages, start, end)
if answer is not None:
return answer
for index in range(end - 1, start - 1, -1):
candidate = messages[index]
if _message_type(candidate) == "ai" and _message_id(candidate):
return candidate
return None
def _find_last_assistant_answer(messages: list[Any], start: int, end: int) -> Any | None:
for index in range(end - 1, start - 1, -1):
candidate = messages[index]
if (
_message_type(candidate) == "ai"
and _message_id(candidate)
and _has_answer_content(candidate)
):
return candidate
return None
def _message_type(message: Any) -> str:
if isinstance(message, dict):
return str(message.get("type") or message.get("role") or "")
return str(getattr(message, "type", "") or "")
def _message_id(message: Any) -> str | None:
value = message.get("id") if isinstance(message, dict) else getattr(message, "id", None)
return str(value) if value else None
def _message_content(message: Any) -> Any:
return message.get("content") if isinstance(message, dict) else getattr(message, "content", None)
def _message_name(message: Any) -> str:
if isinstance(message, dict):
return str(message.get("name") or (message.get("additional_kwargs") or {}).get("name") or "tool")
return str(getattr(message, "name", None) or "tool")
def _message_tool_call_id(message: Any) -> str | None:
if isinstance(message, dict):
value = message.get("tool_call_id") or message.get("toolCallId")
else:
value = getattr(message, "tool_call_id", None) or getattr(message, "toolCallId", None)
return str(value) if value else None
def _has_tool_calls(message: Any) -> bool:
if isinstance(message, dict):
direct = message.get("tool_calls")
chunks = message.get("tool_call_chunks")
extra = (message.get("additional_kwargs") or {}).get("tool_calls")
else:
direct = getattr(message, "tool_calls", None)
chunks = getattr(message, "tool_call_chunks", None)
extra = (getattr(message, "additional_kwargs", None) or {}).get("tool_calls")
return bool(direct or chunks or extra)
def _has_answer_content(message: Any) -> bool:
content = _message_content(message)
if isinstance(content, str):
return bool(content.strip())
if isinstance(content, list):
return any(
isinstance(part, dict)
and part.get("type") == "text"
and str(part.get("text") or "").strip()
for part in content
)
return False
def _extract_tool_calls(message: Any) -> list[dict[str, Any]]:
candidates: list[Any] = []
if isinstance(message, dict):
for key in ("tool_calls", "tool_call_chunks"):
value = message.get(key)
if isinstance(value, list):
candidates.extend(value)
extra = (message.get("additional_kwargs") or {}).get("tool_calls")
if isinstance(extra, list):
candidates.extend(extra)
else:
for key in ("tool_calls", "tool_call_chunks"):
value = getattr(message, key, None)
if isinstance(value, list):
candidates.extend(value)
extra = (getattr(message, "additional_kwargs", None) or {}).get("tool_calls")
if isinstance(extra, list):
candidates.extend(extra)
calls: list[dict[str, Any]] = []
seen_ids: set[str] = set()
for raw in candidates:
call = _normalize_tool_call(raw)
if not call:
continue
call_id = str(call.get("id") or "")
if call_id and call_id in seen_ids:
continue
if call_id:
seen_ids.add(call_id)
calls.append(call)
return calls
def _normalize_tool_call(raw: Any) -> dict[str, Any] | None:
if not isinstance(raw, dict):
return None
fn = raw.get("function") if isinstance(raw.get("function"), dict) else {}
name = raw.get("name") or fn.get("name") or raw.get("tool_name")
if not name:
return None
return {
"id": raw.get("id") or raw.get("tool_call_id") or raw.get("call_id"),
"name": str(name),
"args": _normalize_tool_args(raw.get("args") or raw.get("arguments") or fn.get("arguments")),
}
def _normalize_tool_args(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
return parsed
except Exception:
pass
return {"command": value}
return {}
def _build_tool_context(tool_call: dict[str, Any]) -> dict[str, Any]:
tool_name = str(tool_call.get("name") or "tool")
args = tool_call.get("args") if isinstance(tool_call.get("args"), dict) else {}
return {
"id": tool_call.get("id"),
"tool_name": tool_name,
"args": args,
"skill_name": _infer_skill_name(tool_name, args),
}
def _resolve_tool_context(
message: Any,
pending_tool_contexts: list[dict[str, Any]],
tool_contexts_by_id: dict[str, dict[str, Any]],
) -> dict[str, Any]:
tool_call_id = _message_tool_call_id(message)
if tool_call_id and tool_call_id in tool_contexts_by_id:
context = tool_contexts_by_id[tool_call_id]
pending_tool_contexts[:] = [
item for item in pending_tool_contexts if str(item.get("id") or "") != tool_call_id
]
return context
if pending_tool_contexts:
return pending_tool_contexts.pop(0)
tool_name = _message_name(message)
return {
"id": tool_call_id,
"tool_name": tool_name,
"args": {},
"skill_name": _infer_skill_name(tool_name, {}),
}
def _extract_tool_payload(message: Any) -> Any:
content = _message_content(message)
if isinstance(content, dict):
return _unwrap_tool_payload(content)
if isinstance(content, list):
texts: list[str] = []
for part in content:
if not isinstance(part, dict):
continue
if isinstance(part.get("json"), dict):
return _unwrap_tool_payload(part["json"])
if part.get("type") == "json" and isinstance(part.get("json"), dict):
return _unwrap_tool_payload(part["json"])
if part.get("type") == "text" and isinstance(part.get("text"), str):
texts.append(part["text"])
unwrapped = _unwrap_tool_payload(part)
if unwrapped is not None:
return unwrapped
content = "\n".join(texts)
if isinstance(content, str):
return _parse_json_like(content)
return None
def _unwrap_tool_payload(value: dict[str, Any]) -> Any:
list_keys = ("results", "data", "items", "hits", "documents", "records", "sources")
if any(isinstance(value.get(key), list) for key in list_keys):
return value
for key in ("result", "output", "stdout", "content", "text", "payload", "response"):
nested = value.get(key)
if isinstance(nested, str):
parsed = _parse_json_like(nested)
if parsed is not None:
return parsed
if isinstance(nested, dict):
unwrapped = _unwrap_tool_payload(nested)
if unwrapped is not None:
return unwrapped
return value
def _parse_json_like(text: str) -> Any:
trimmed = text.strip()
if not trimmed:
return None
try:
return json.loads(trimmed)
except Exception:
pass
first = trimmed.find("{")
last = trimmed.rfind("}")
if 0 <= first < last:
try:
return json.loads(trimmed[first : last + 1])
except Exception:
return None
return None
def _first_list(payload: Any) -> list[Any]:
if isinstance(payload, list):
return payload
if not isinstance(payload, dict):
return []
for key in ("results", "data", "items", "hits", "documents", "records", "entries", "matches", "chunks", "sources"):
value = payload.get(key)
if isinstance(value, list):
return value
for key in ("result", "payload", "response", "output", "data"):
found = _first_list(payload.get(key))
if found:
return found
return []
def _nested_records(raw: Any) -> list[dict[str, Any]]:
if not isinstance(raw, dict):
return []
layers = [raw]
for key in ("metadata", "meta", "attrs", "attributes", "properties", "extra"):
nested = raw.get(key)
if isinstance(nested, dict):
layers.append(nested)
return layers
def _pick(layers: list[dict[str, Any]], keys: tuple[str, ...]) -> str | None:
for layer in layers:
for key in keys:
value = layer.get(key)
if isinstance(value, (str, int, float)) and str(value).strip():
return str(value).strip()
if isinstance(value, list):
text = _stringify(value, "")
if text:
return text
return None
def _enabled_skill_displays() -> dict[str, SkillDisplayConfig]:
try:
return {
name: state.display
for name, state in get_extensions_config().skills.items()
if state.display is not None
and state.enabled
and state.display.enabled
and state.display.mode != "none"
}
except Exception:
return {}
def _infer_skill_name(tool_name: str, args: dict[str, Any]) -> str | None:
displays = _enabled_skill_displays()
if tool_name in displays:
return tool_name
normalized_tool = _normalize_name(tool_name)
for skill_name in displays:
normalized_skill = _normalize_name(skill_name)
if (
normalized_tool == normalized_skill
or normalized_tool.endswith(normalized_skill)
or normalized_skill in normalized_tool
):
return skill_name
for value in args.values():
if not isinstance(value, str):
continue
match = _SKILL_PATH_RE.search(value)
if match:
return match.group(1)
return None
def _resolve_display_match(
raw: dict[str, Any],
payload: Any,
tool_context: dict[str, Any] | None,
) -> tuple[str, SkillDisplayConfig] | None:
displays = _enabled_skill_displays()
if not displays:
return None
skill_name = str((tool_context or {}).get("skill_name") or "")
display = displays.get(skill_name)
if display is not None:
return skill_name, display
tool_name = str((tool_context or {}).get("tool_name") or "")
inferred = _infer_skill_name(tool_name, (tool_context or {}).get("args") or {})
if inferred and inferred in displays:
return inferred, displays[inferred]
return None
def _normalize_with_display_config(
raw: dict[str, Any],
fallback_index: int,
skill_name: str,
display: SkillDisplayConfig,
) -> dict[str, Any] | None:
title = _stringify(_get_by_path(raw, display.title_field), f"结果 {fallback_index}")
snippet = _stringify(_get_by_path(raw, display.summary_field), "")
content = _stringify(_get_by_path(raw, display.content_field), snippet)
source = _stringify(_get_by_path(raw, display.source_field), "") or None
author = _stringify(_get_by_path(raw, display.author_field), "") or None
item_type = _stringify(_get_by_path(raw, display.type_field), "result") or "result"
time = _stringify(_get_by_path(raw, display.time_field), "") or None
item_id = _stringify(_get_by_path(raw, display.id_field), "") or None
score = _number_or_none(_get_by_path(raw, display.score_field))
url = _resolve_display_link(raw, display)
if not title and not content and not url:
return None
return {
"id": item_id or url,
"skillName": skill_name,
"mode": display.mode,
"title": title,
"type": item_type,
"snippet": snippet,
"content": content,
"source": source,
"author": author,
"time": time,
"url": url,
"score": score,
"tableColumns": [column.model_dump(by_alias=True) for column in display.table_columns],
"raw": raw,
}
def _resolve_display_link(raw: dict[str, Any], display: SkillDisplayConfig) -> str | None:
url = _stringify(_get_by_path(raw, display.url_field), "")
item_id = _stringify(_get_by_path(raw, display.id_field), "")
if display.link_strategy == "url":
return url or None
if display.link_strategy == "id":
return _render_template(display.link_template, raw) if item_id and display.link_template else None
return url or (_render_template(display.link_template, raw) if item_id and display.link_template else None) or None
def _render_template(template: str | None, raw: dict[str, Any]) -> str | None:
if not template:
return None
def replace(match: re.Match[str]) -> str:
value = _get_by_path(raw, match.group(1).strip())
return quote(_stringify(value, ""), safe="")
return re.sub(r"\{([^}]+)\}", replace, template)
def _get_by_path(value: Any, path: str | None) -> Any:
if not path:
return None
cursor = value
for part in path.split("."):
if isinstance(cursor, dict):
cursor = cursor.get(part)
else:
return None
return cursor
def _stringify(value: Any, fallback: str = "") -> str:
if value is None:
return fallback
if isinstance(value, (str, int, float)):
text = str(value).strip()
return text if text else fallback
if isinstance(value, list):
parts = [
str(item).strip()
for item in value
if isinstance(item, (str, int, float)) and str(item).strip()
]
return "、".join(parts) if parts else fallback
return fallback
def _number_or_none(value: Any) -> float | None:
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value)
except Exception:
return None
return None
def _normalize_name(name: str) -> str:
return re.sub(r"[^a-z0-9]+", "", name.lower())
def _normalize_reference_item(
raw: Any,
fallback_index: int,
*,
payload: Any = None,
tool_context: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
if not isinstance(raw, dict):
return None
display_match = _resolve_display_match(raw, payload, tool_context)
if display_match is not None:
skill_name, display = display_match
configured = _normalize_with_display_config(raw, fallback_index, skill_name, display)
if configured is not None:
return configured
layers = _nested_records(raw)
title = _pick(layers, ("title", "m_title", "name", "question", "keyword", "query", "text"))
content = _pick(layers, ("content", "page_content", "summary", "snippet", "description", "content_preview", "abstract", "body"))
url = _pick(layers, ("url", "link", "href", "source_url", "web_url"))
source = _pick(layers, ("source", "source1", "site", "provider", "from"))
author = _pick(layers, ("author", "authors", "creator", "writer", "byline", "publisher", "owner"))
time = _pick(layers, ("time", "date", "published_date", "publishedDate", "m_publish", "publish_time", "created_at"))
item_id = _pick(layers, ("recUuid", "rec_uuid", "uuid", "docId", "doc_id", "id", "recordId", "taskId"))
score_raw = _pick(layers, ("score", "relevance", "similarity"))
score = None
if score_raw is not None:
try:
score = float(score_raw)
except Exception:
score = None
if not title:
title = source or f"结果 {fallback_index}"
if not content:
content = ""
if not title and not content and not url:
return None
return {
"id": item_id or url,
"skillName": str((tool_context or {}).get("skill_name") or (tool_context or {}).get("tool_name") or "backend-reference"),
"mode": "citation",
"title": title,
"type": "result",
"snippet": content,
"content": content,
"source": source,
"author": author,
"time": time,
"url": url,
"score": score,
"raw": raw,
}
def _reference_key(source: dict[str, Any]) -> str:
raw = source.get("raw") if isinstance(source.get("raw"), dict) else {}
item_id = source.get("id") or source.get("url") or raw.get("recUuid") or raw.get("uuid") or raw.get("docId") or raw.get("id")
if item_id:
return str(item_id).casefold().strip()
return "|".join(
str(source.get(key) or "").casefold().strip()
for key in ("title", "source", "time", "content", "snippet")
)
@@ -1,4 +1,10 @@
from .registry import get_scheduled_task_service, set_scheduled_task_service
from .service import DuplicateTaskNameError, ScheduledTaskService
from .service import DuplicateTaskNameError, MarkdownRequirementError, ScheduledTaskService
__all__ = ["DuplicateTaskNameError", "ScheduledTaskService", "get_scheduled_task_service", "set_scheduled_task_service"]
__all__ = [
"DuplicateTaskNameError",
"MarkdownRequirementError",
"ScheduledTaskService",
"get_scheduled_task_service",
"set_scheduled_task_service",
]
@@ -36,6 +36,27 @@ _RUN_ATTEMPT_TIMEOUT_SECONDS = 30 * 60 # 30 minutes
# Retries allowed after the first attempt before the run is marked "failed".
_MAX_RUN_RETRIES = 3
# Minimum length (in characters) the generated Markdown file's content must
# reach when a task has ``require_markdown`` enabled. The model is NOT told about
# this threshold — it is enforced silently by the service.
_MARKDOWN_MIN_CHARS = 100
# Instruction appended to the prompt when ``require_markdown`` is on. It only
# asks the model to produce a ``.md`` file; the length requirement is enforced
# in code (not surfaced to the model).
_MARKDOWN_INSTRUCTION = (
"\n\n【输出要求】请将本次任务的最终结果生成为一个 Markdown(.md) 格式的文件,"
"保存到 /mnt/user-data/outputs 目录,并使用 present_files 展示该文件。"
)
class MarkdownRequirementError(Exception):
"""Raised when a ``require_markdown`` task did not produce a valid .md file.
The run is marked failed (no retry) so the user can see the output did not
meet the requirement (a Markdown file longer than the minimum length).
"""
class DuplicateTaskNameError(Exception):
"""A user already owns a scheduled task with the requested name.
@@ -166,6 +187,42 @@ def _looks_like_markdown(content: str) -> bool:
return any(re.search(pattern, content, flags=re.MULTILINE) for pattern in markdown_patterns)
def _task_requires_markdown(task: dict[str, Any]) -> bool:
"""Whether a task has the ``require_markdown`` flag set.
The flag lives inside ``execution_context`` (no dedicated column) so the
feature works without a schema migration on existing databases.
"""
ctx = task.get("execution_context")
return bool(isinstance(ctx, dict) and ctx.get("require_markdown"))
def _has_valid_markdown_file(result_files: list[dict[str, Any]] | None) -> bool:
"""True when the run produced a ``.md`` file whose text exceeds the minimum.
For tasks with ``require_markdown`` enabled the run only succeeds if at least
one generated file is Markdown (``.md``/``.markdown``) and its content is
longer than :data:`_MARKDOWN_MIN_CHARS` characters. The file is read from its
archived path; binary/undecodable files never qualify.
"""
for item in result_files or []:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "")
if not name.lower().endswith((".md", ".markdown")):
continue
path = item.get("path")
if not isinstance(path, str) or not path:
continue
try:
text = Path(path).read_text(encoding="utf-8", errors="ignore")
except Exception:
continue
if len(text.strip()) > _MARKDOWN_MIN_CHARS:
return True
return False
def _build_result(
task: dict[str, Any],
*,
@@ -451,6 +508,7 @@ class ScheduledTaskService:
source_agent_name: str | None = None,
execution_agent_name: str | None = None,
execution_context: dict[str, Any] | None = None,
require_markdown: bool = False,
) -> dict[str, Any]:
tz = _normalize_timezone(timezone)
cron_expr = _schedule_to_cron(schedule)
@@ -461,6 +519,7 @@ class ScheduledTaskService:
context = dict(execution_context or {})
if execution_agent_name:
context["agent_name"] = execution_agent_name
context["require_markdown"] = bool(require_markdown)
normalized_description = description.strip() if isinstance(description, str) else None
task = await self.store.create_task(
{
@@ -519,6 +578,18 @@ class ScheduledTaskService:
update_data["next_run_at"] = _next_run_at(cron_expr, tz)
if data.get("execution_context") is not None:
update_data["execution_context"] = data["execution_context"]
# ``require_markdown`` is persisted inside execution_context (no dedicated
# column), so merge it into the context being written — preferring a
# context supplied in the same request, else the task's existing one.
if data.get("require_markdown") is not None:
base_ctx = (
update_data["execution_context"]
if "execution_context" in update_data
else (existing or {}).get("execution_context")
)
ctx = dict(base_ctx or {})
ctx["require_markdown"] = bool(data["require_markdown"])
update_data["execution_context"] = ctx
# Re-enabling a task must also schedule it forward from now, otherwise a
# paused task with a stale/empty next_run_at would never fire again.
if update_data.get("enabled") is True and "next_run_at" not in update_data:
@@ -831,6 +902,10 @@ class ScheduledTaskService:
run_id=run_id,
output_snapshot=output_snapshot,
)
if _task_requires_markdown(task) and not _has_valid_markdown_file(result_files):
raise MarkdownRequirementError(
f"未满足输出要求:需生成 Markdown(.md) 文件,且文件正文不少于 {_MARKDOWN_MIN_CHARS} 字"
)
return _build_result(task, scheduled_for=scheduled_for, content=result_text, files=result_files)
async def _ensure_thread_meta(self, user_id: str, thread_id: str) -> None:
@@ -895,6 +970,8 @@ class ScheduledTaskService:
f"计划时间:{scheduled_for.astimezone(ZoneInfo(task.get('timezone') or 'UTC')).isoformat()}\n\n"
f"{task['prompt']}"
)
if execution_context.get("require_markdown"):
content += _MARKDOWN_INSTRUCTION
body = RunCreateRequest(
assistant_id="lead_agent",
input={"messages": [{"type": "human", "content": content}]},
@@ -37,14 +37,26 @@ class LocalSandbox(Sandbox):
"""Return whether the selected shell is a PowerShell executable."""
return LocalSandbox._shell_name(shell) in {"powershell", "powershell.exe", "pwsh", "pwsh.exe"}
@staticmethod
def _is_cmd_shell(shell: str) -> bool:
"""Return whether the selected shell is cmd.exe."""
return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
@staticmethod
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
"""Return the first executable shell path or command found from candidates."""
@staticmethod
def _is_cmd_shell(shell: str) -> bool:
"""Return whether the selected shell is cmd.exe."""
return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
@staticmethod
def _normalize_powershell_command(command: str) -> str:
"""Make bash-style command glue usable in Windows PowerShell 5.
The public tool is still named ``bash`` for model compatibility, but on
Windows local sandbox it may fall back to PowerShell. PowerShell 5 does
not support ``&&``, while the sandbox layer prepends commands with
``cd <workspace> &&``. Rewriting the separator keeps skill scripts
executable in that fallback shell.
"""
return command.replace("&&", ";")
@staticmethod
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
"""Return the first executable shell path or command found from candidates."""
for shell in candidates:
if os.path.isabs(shell):
if os.path.isfile(shell) and os.access(shell, os.X_OK):
@@ -302,30 +314,39 @@ class LocalSandbox(Sandbox):
resolved_command = self._resolve_paths_in_command(command)
shell = self._get_shell()
if os.name == "nt":
if self._is_powershell(shell):
args = [shell, "-NoProfile", "-Command", resolved_command]
if os.name == "nt":
if self._is_powershell(shell):
args = [
shell,
"-NoProfile",
"-Command",
self._normalize_powershell_command(resolved_command),
]
elif self._is_cmd_shell(shell):
args = [shell, "/c", resolved_command]
else:
args = [shell, "-c", resolved_command]
result = subprocess.run(
args,
shell=False,
capture_output=True,
text=True,
timeout=600,
)
else:
args = [shell, "-c", resolved_command]
result = subprocess.run(
args,
shell=False,
capture_output=True,
text=True,
timeout=600,
)
result = subprocess.run(
args,
shell=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=600,
)
else:
args = [shell, "-c", resolved_command]
result = subprocess.run(
args,
shell=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=600,
)
output = result.stdout
if result.stderr:
output += f"\nStd Error:\n{result.stderr}" if output else result.stderr
@@ -149,3 +149,58 @@ async def cascade_skill_unpublished_or_deleted(
counters["notified"] += 1
return counters
def sync_agent_skills_to_available(available_skill_names: set[str]) -> dict[str, Any]:
"""Strip skill references that no longer exist on disk from every agent.
Walks every agent dir and rewrites its ``config.yaml`` ``skills`` list to
contain only names that appear in ``available_skill_names``. Used by the
admin "agent-skills sync" endpoint to repair historical drift — skills
deleted before the cascade existed, deleted via a path that skipped the
cascade, or removed from the filesystem manually.
Synchronous because it only touches local files and the admin endpoint
runs it in a worker thread.
Returns ``{checked, modified, removals: [{agent_id, removed_skills}]}``.
"""
counters: dict[str, Any] = {"checked": 0, "modified": 0, "removals": []}
for agent_dir in _list_agent_dirs():
config_file = agent_dir / "config.yaml"
if not config_file.exists():
continue
counters["checked"] += 1
try:
with config_file.open("r", encoding="utf-8") as f:
data: dict[str, Any] = yaml.safe_load(f) or {}
except yaml.YAMLError:
logger.warning("Sync: failed to parse %s, skipping", config_file)
continue
skills = data.get("skills")
if not isinstance(skills, list) or not skills:
continue
kept: list[str] = []
removed: list[str] = []
for name in skills:
if isinstance(name, str) and name in available_skill_names:
kept.append(name)
else:
removed.append(name if isinstance(name, str) else str(name))
if not removed:
continue
data["skills"] = kept
with config_file.open("w", encoding="utf-8") as f:
yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True)
counters["modified"] += 1
counters["removals"].append({"agent_id": agent_dir.name, "removed_skills": removed})
logger.info("Sync: removed orphan skills %s from agent '%s'", removed, agent_dir.name)
return counters
@@ -0,0 +1,71 @@
"""笔记本 RAG 检索工具。
从 LangGraph configurable 中读取 notebook_search_space_id / notebook_source_ids /
notebook_token,调用外部检索接口返回相关文本块,供 LLM 在笔记本模式下引用回答。
API base URL 通过环境变量 DEER_FLOW_NOTEBOOK_API_BASE 配置。
"""
from __future__ import annotations
import logging
import os
import httpx
from langchain.tools import tool
from langgraph.config import get_config
logger = logging.getLogger(__name__)
_SEARCH_PATH = "/api/v1/source/mentioned-documents/search"
def _notebook_api_base() -> str:
return (os.getenv("DEER_FLOW_NOTEBOOK_API_BASE") or "").rstrip("/")
@tool("search_notebook_sources", parse_docstring=True)
def notebook_search_tool(query: str, top_k: int = 10) -> str:
"""Search the notebook's knowledge sources to find relevant content.
Call this tool for EVERY user question. After retrieving results, cite
sources inline using [[cite:<document_id>]] markers immediately after
each claim, e.g. "违约金为0.5%[[cite:101]]".
Args:
query: The user's question or a refined search query.
top_k: Number of top chunks to retrieve (default 10).
"""
base = _notebook_api_base()
if not base:
return "Notebook API base URL not configured (DEER_FLOW_NOTEBOOK_API_BASE)."
cfg = get_config().get("configurable", {})
search_space_id: int | None = cfg.get("notebook_search_space_id")
source_ids: list[int] = cfg.get("notebook_source_ids") or []
token: str = cfg.get("notebook_token") or ""
if not search_space_id:
return "No notebook search space configured."
logger.info("notebook_search_tool query=%r source_ids=%s search_space_id=%s", query, source_ids, search_space_id)
try:
resp = httpx.post(
f"{base}{_SEARCH_PATH}",
headers={"Authorization": f"Bearer {token}"},
json={
"query": query,
"source_ids": [source_ids],
"search_space_id": search_space_id,
"top_k": top_k,
},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
content = data.get("content") if isinstance(data, dict) else None
return content if content else "No relevant content found."
except Exception as e:
logger.warning("notebook_search_tool failed: %s", e)
return f"Search failed: {e}"
@@ -0,0 +1,96 @@
"""Tests for RAG citation batch construction."""
from __future__ import annotations
import json
from deerflow.runtime.references import (
build_latest_reference_batch,
build_latest_reference_prompt,
build_reference_batches,
)
def _tool_message(call_id: str, query: str, start: int) -> dict:
return {
"type": "tool",
"name": "web_search",
"tool_call_id": call_id,
"content": json.dumps(
{
"query": query,
"results": [
{
"title": f"{query} result {start}",
"content": f"{query} content {start}",
"url": f"https://example.test/{start}",
},
{
"title": f"{query} result {start + 1}",
"content": f"{query} content {start + 1}",
"url": f"https://example.test/{start + 1}",
},
],
},
ensure_ascii=False,
),
}
def test_latest_reference_prompt_uses_one_combined_batch_for_multi_search_turn() -> None:
messages = [
{"type": "human", "id": "human-1", "content": "search two terms"},
{
"type": "ai",
"id": "ai-search-1",
"content": "",
"tool_calls": [{"id": "call-1", "name": "web_search", "args": {"query": "Trump"}}],
},
_tool_message("call-1", "Trump", 1),
{
"type": "ai",
"id": "ai-search-2",
"content": "",
"tool_calls": [{"id": "call-2", "name": "web_search", "args": {"query": "White House"}}],
},
_tool_message("call-2", "White House", 3),
]
batch = build_latest_reference_batch(messages)
assert batch is not None
assert batch["source_count"] == 4
assert [source["index"] for source in batch["sources"]] == [1, 2, 3, 4]
assert [source["title"] for source in batch["sources"]] == [
"Trump result 1",
"Trump result 2",
"White House result 3",
"White House result 4",
]
prompt, prompt_batch = build_latest_reference_prompt(messages)
assert prompt_batch == batch
assert '<reference_batch source_count="4">' in prompt
assert "[1] Trump result 1" in prompt
assert "[4] White House result 4" in prompt
assert "重新分批" in prompt
def test_reference_batches_keep_separate_user_turns_but_merge_searches_inside_turn() -> None:
messages = [
{"type": "human", "id": "human-1", "content": "first"},
{"type": "ai", "id": "ai-1", "content": "", "tool_calls": [{"id": "c1", "name": "web_search"}]},
_tool_message("c1", "first", 1),
{"type": "ai", "id": "answer-1", "content": "answer [1]"},
{"type": "human", "id": "human-2", "content": "second"},
{"type": "ai", "id": "ai-2", "content": "", "tool_calls": [{"id": "c2", "name": "web_search"}]},
_tool_message("c2", "second-a", 3),
{"type": "ai", "id": "ai-3", "content": "", "tool_calls": [{"id": "c3", "name": "web_search"}]},
_tool_message("c3", "second-b", 5),
{"type": "ai", "id": "answer-2", "content": "answer [1][4]"},
]
batches = build_reference_batches(messages)
assert [len(batch["sources"]) for batch in batches] == [2, 4]
assert [source["index"] for source in batches[1]["sources"]] == [1, 2, 3, 4]
assert batches[0]["assistant_message_id"] == "answer-1"
assert batches[1]["assistant_message_id"] == "answer-2"