feat(ai写作): 素材收集专家按真实智能体运行时跑技能 + 步骤条对齐
- 新增 run_skill_via_real_agent:复用 SubagentExecutor(与 lead agent / 子智能体 同一套运行时),加载技能 SKILL.md + 全套工具 + 懒加载沙箱,让技能的真实检索逻辑 (读文件 / 调内网接口 / MCP)真正执行,再按 JSON 契约收割结构化素材;researcher 的 _execute_skill 在 researcher_skill_agent 开启时切到它,失败/空结果回退直连检索。 解决内网技能(在别的智能体里好使)在 AI 写作里检不出东西的问题。 - 步骤条轻量对齐别的智能体:技能调用拆成「读取 SKILL.md → 执行检索 → 检索到 N 条」 三段(前两段新增,结果段沿用 skill_result);前端 SkillStepsCard 零改动。 - 含 roundtable-planning(业务链选择 / Step2 编排)的进行中改动。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1a127ef602
commit
afb78a6943
6 files changed
+164
-17
No files matched your search
@@ -140,7 +140,7 @@ export function BusinessChainPicker({
|
|||||||
const hasCoord = (plan.coordinatorPrompt?.trim() ?? "") !== "";
|
const hasCoord = (plan.coordinatorPrompt?.trim() ?? "") !== "";
|
||||||
return hasParallel || hasGoal || hasCoord ? plan : null;
|
return hasParallel || hasGoal || hasCoord ? plan : null;
|
||||||
}, [selectedChain, survivingSeats]);
|
}, [selectedChain, survivingSeats]);
|
||||||
const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("fast");
|
const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("sandbox");
|
||||||
|
|
||||||
const handleUse = () => {
|
const handleUse = () => {
|
||||||
if (!canUse || !selectedChain) return;
|
if (!canUse || !selectedChain) return;
|
||||||
|
|||||||
@@ -460,7 +460,7 @@ export function useStep2Orchestration(
|
|||||||
gatherFirst = false,
|
gatherFirst = false,
|
||||||
orchestrationMode,
|
orchestrationMode,
|
||||||
orchestrationPlan,
|
orchestrationPlan,
|
||||||
dagExecMode = "fast",
|
dagExecMode = "sandbox",
|
||||||
buildInitialIntent,
|
buildInitialIntent,
|
||||||
triggerToast,
|
triggerToast,
|
||||||
setRoles,
|
setRoles,
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export function buildChainSeatSelection(
|
|||||||
const agents = plan.stages
|
const agents = plan.stages
|
||||||
.flatMap((st) => st.agentIds)
|
.flatMap((st) => st.agentIds)
|
||||||
.map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id), seat_skill_directive: skillOf(id), seatMode: seatModeOf(id) }));
|
.map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id), seat_skill_directive: skillOf(id), seatMode: seatModeOf(id) }));
|
||||||
return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast", seatMode: chain.seatMode, gatherFirst: chain.gatherFirst ?? false };
|
return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "sandbox", seatMode: chain.seatMode, gatherFirst: chain.gatherFirst ?? false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ export default function RoundtablePlanningPage() {
|
|||||||
const [orchestrationPlan, setOrchestrationPlan] = useState<OrchestrationPlan | null>(null);
|
const [orchestrationPlan, setOrchestrationPlan] = useState<OrchestrationPlan | null>(null);
|
||||||
// dag 执行子模式:fast(并行双列禁写)/ sandbox(并行单列可写、沙箱跟随)。
|
// dag 执行子模式:fast(并行双列禁写)/ sandbox(并行单列可写、沙箱跟随)。
|
||||||
// 由推荐弹窗的「执行方式」选择决定(仅 dag 有意义)。
|
// 由推荐弹窗的「执行方式」选择决定(仅 dag 有意义)。
|
||||||
const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("fast");
|
const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("sandbox");
|
||||||
const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true);
|
const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true);
|
||||||
const [step2Runs, setStep2Runs] = useState<DraftStep2RunSnapshot[]>([]);
|
const [step2Runs, setStep2Runs] = useState<DraftStep2RunSnapshot[]>([]);
|
||||||
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
|
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
|
||||||
@@ -684,7 +684,7 @@ export default function RoundtablePlanningPage() {
|
|||||||
setOrchestrationMode(run.orchestrationMode ?? "recommend");
|
setOrchestrationMode(run.orchestrationMode ?? "recommend");
|
||||||
setChainMeta(run.chain ?? null);
|
setChainMeta(run.chain ?? null);
|
||||||
setOrchestrationPlan(run.orchestrationPlan ?? null);
|
setOrchestrationPlan(run.orchestrationPlan ?? null);
|
||||||
setDagExecMode(run.dagExecMode ?? "fast");
|
setDagExecMode(run.dagExecMode ?? "sandbox");
|
||||||
setStep3Report(run.step3 ?? null);
|
setStep3Report(run.step3 ?? null);
|
||||||
step2.hydrateFromDraft(run as unknown as Step2Snapshot);
|
step2.hydrateFromDraft(run as unknown as Step2Snapshot);
|
||||||
markRestoredConsensusAsEmitted(Boolean(run.hasConsensus));
|
markRestoredConsensusAsEmitted(Boolean(run.hasConsensus));
|
||||||
@@ -791,7 +791,7 @@ export default function RoundtablePlanningPage() {
|
|||||||
setOrchestrationMode(snapshot.orchestrationMode ?? "recommend");
|
setOrchestrationMode(snapshot.orchestrationMode ?? "recommend");
|
||||||
setChainMeta(snapshot.chain ?? null);
|
setChainMeta(snapshot.chain ?? null);
|
||||||
setOrchestrationPlan(snapshot.orchestrationPlan ?? null);
|
setOrchestrationPlan(snapshot.orchestrationPlan ?? null);
|
||||||
setDagExecMode(snapshot.dagExecMode ?? "fast");
|
setDagExecMode(snapshot.dagExecMode ?? "sandbox");
|
||||||
step2.hydrateFromDraft(snapshot as unknown as Step2Snapshot);
|
step2.hydrateFromDraft(snapshot as unknown as Step2Snapshot);
|
||||||
markRestoredConsensusAsEmitted(Boolean(snapshot.hasConsensus));
|
markRestoredConsensusAsEmitted(Boolean(snapshot.hasConsensus));
|
||||||
},
|
},
|
||||||
@@ -945,7 +945,7 @@ export default function RoundtablePlanningPage() {
|
|||||||
// chain / dag 都来自业务链条,保留来源链名;recommend 无。
|
// chain / dag 都来自业务链条,保留来源链名;recommend 无。
|
||||||
chain: result.mode === "recommend" ? null : result.chain ?? null,
|
chain: result.mode === "recommend" ? null : result.chain ?? null,
|
||||||
orchestrationPlan: result.mode === "dag" ? result.plan ?? null : null,
|
orchestrationPlan: result.mode === "dag" ? result.plan ?? null : null,
|
||||||
dagExecMode: result.mode === "dag" ? result.dagExecMode ?? "fast" : undefined,
|
dagExecMode: result.mode === "dag" ? result.dagExecMode ?? "sandbox" : undefined,
|
||||||
step3: null,
|
step3: null,
|
||||||
};
|
};
|
||||||
setStep2Runs([...syncedRuns, newRun]);
|
setStep2Runs([...syncedRuns, newRun]);
|
||||||
@@ -998,7 +998,7 @@ export default function RoundtablePlanningPage() {
|
|||||||
setGatherFirst(result.gatherFirst ?? false);
|
setGatherFirst(result.gatherFirst ?? false);
|
||||||
setChainMeta(result.mode === "recommend" ? null : result.chain ?? null);
|
setChainMeta(result.mode === "recommend" ? null : result.chain ?? null);
|
||||||
setOrchestrationPlan(result.mode === "dag" ? result.plan ?? null : null);
|
setOrchestrationPlan(result.mode === "dag" ? result.plan ?? null : null);
|
||||||
setDagExecMode(result.mode === "dag" ? result.dagExecMode ?? "fast" : "fast");
|
setDagExecMode(result.mode === "dag" ? result.dagExecMode ?? "sandbox" : "sandbox");
|
||||||
setIsRecommendDialogOpen(false);
|
setIsRecommendDialogOpen(false);
|
||||||
setCurrentStep(2);
|
setCurrentStep(2);
|
||||||
triggerToast(
|
triggerToast(
|
||||||
@@ -1527,7 +1527,7 @@ export default function RoundtablePlanningPage() {
|
|||||||
setOrchestrationMode("recommend");
|
setOrchestrationMode("recommend");
|
||||||
setChainMeta(null);
|
setChainMeta(null);
|
||||||
setOrchestrationPlan(null);
|
setOrchestrationPlan(null);
|
||||||
setDagExecMode("fast");
|
setDagExecMode("sandbox");
|
||||||
setStep3Report(null);
|
setStep3Report(null);
|
||||||
// 停掉页面层的 Step3 三个生成流(新任务不该带着旧任务的流跑)+ 复位进入闸门,
|
// 停掉页面层的 Step3 三个生成流(新任务不该带着旧任务的流跑)+ 复位进入闸门,
|
||||||
// 并停止后台作业实时跟随(作业本身继续在后台跑)。
|
// 并停止后台作业实时跟随(作业本身继续在后台跑)。
|
||||||
|
|||||||
+142
@@ -41,6 +41,148 @@ def use_skill_search_agent() -> bool:
|
|||||||
return bool(cfg.get("researcher_skill_agent", False)) if isinstance(cfg, dict) else False
|
return bool(cfg.get("researcher_skill_agent", False)) if isinstance(cfg, dict) else False
|
||||||
|
|
||||||
|
|
||||||
|
# ── 用真实智能体运行时跑技能(和「别的智能体调用技能」完全一致)────────────────────
|
||||||
|
#
|
||||||
|
# 旧的 run_skill_search_agent 只给模型绑了一个写死路由到 DDG 的通用 search 工具,技能
|
||||||
|
# SKILL.md 里要用的真实工具(read_file / bash / 内网检索工具 / MCP)根本不存在,所以
|
||||||
|
# 内网技能跑不出东西。本函数改为复用 **SubagentExecutor**(与 lead agent / 子智能体同一套
|
||||||
|
# 运行时):加载技能 SKILL.md(skills 白名单)+ 全套工具 + 懒加载沙箱,让技能按其说明真正
|
||||||
|
# 检索,再让模型把检索到的真实素材按 JSON 契约输出,我们解析成结构化素材。
|
||||||
|
#
|
||||||
|
# 单次工具调用循环上限(防跑飞)。
|
||||||
|
_REAL_AGENT_MAX_TURNS = 16
|
||||||
|
|
||||||
|
_MATERIAL_OUTPUT_CONTRACT = (
|
||||||
|
"你的任务:严格按上面挂载的检索技能(SKILL.md)的说明,用相应工具检索与「写作意图」相关的**真实素材**。\n"
|
||||||
|
"- 必须按技能说明实际调用工具完成检索(读取知识库文件 / 调用内网检索接口 / MCP 等),**不得凭记忆编造**。\n"
|
||||||
|
"- 检索完成后,**只输出一个 JSON 对象**(不要解释、不要 markdown 代码块、用英文双引号),把检索到的真实素材列出:\n"
|
||||||
|
'{"materials": [{"title": "标题", "content": "素材正文摘录(尽量保留原文, <=500字)", "source": "来源", "url": "原文链接(可空)", "published_at": "日期(可空)"}]}\n'
|
||||||
|
"- content 必须来自检索到的真实内容;若确实没检索到任何素材,输出 {\"materials\": []}。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_skill_via_real_agent(
|
||||||
|
*,
|
||||||
|
skill,
|
||||||
|
intent: str,
|
||||||
|
keywords: list[str],
|
||||||
|
user_query: str,
|
||||||
|
config: RunnableConfig,
|
||||||
|
state: dict,
|
||||||
|
model_name_fallback: str | None,
|
||||||
|
max_results: int,
|
||||||
|
timeout_s: int,
|
||||||
|
emit_query=None,
|
||||||
|
) -> tuple[list, str]:
|
||||||
|
"""用真实智能体运行时(SubagentExecutor)跑一个检索技能,收割结构化素材。
|
||||||
|
|
||||||
|
与「别的智能体调用技能」一致:技能 SKILL.md + 全套工具 + 沙箱都齐全,技能的真实检索
|
||||||
|
逻辑(读文件 / 调内网接口 / MCP)能真正执行。模型最终按 JSON 契约吐出素材,解析成
|
||||||
|
SearchResult。返回 ``(results, error_detail)``:results 为空且 error 非空 = 该技能失败
|
||||||
|
(researcher 据此回退直连检索)。
|
||||||
|
"""
|
||||||
|
from deerflow.config import get_app_config
|
||||||
|
from deerflow.subagents.config import SubagentConfig
|
||||||
|
from deerflow.subagents.executor import SubagentExecutor, SubagentStatus
|
||||||
|
from deerflow.tools import get_available_tools
|
||||||
|
|
||||||
|
from ..search.base import SearchResult
|
||||||
|
from ._agent_runner import RESEARCHER_AGENT_ID, WritingAgentUnavailable, load_writing_agent
|
||||||
|
from ._utils import parse_json
|
||||||
|
|
||||||
|
skill_name = getattr(skill, "name", "") or "技能"
|
||||||
|
|
||||||
|
try:
|
||||||
|
agent_cfg, soul = load_writing_agent(RESEARCHER_AGENT_ID)
|
||||||
|
except WritingAgentUnavailable:
|
||||||
|
agent_cfg, soul = None, ""
|
||||||
|
model_name = resolve_agent_model_name(
|
||||||
|
agent_cfg, state, config, explicit_fallback=model_name_fallback, strict=False
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_config = get_app_config()
|
||||||
|
tools = get_available_tools(
|
||||||
|
model_name=model_name, subagent_enabled=False, include_mcp=True, app_config=app_config
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("skill real-agent[%s]: build tools failed: %s", skill_name, e)
|
||||||
|
return [], f"工具初始化失败: {e}"
|
||||||
|
|
||||||
|
system_prompt = (soul + "\n\n" if soul else "") + _MATERIAL_OUTPUT_CONTRACT
|
||||||
|
|
||||||
|
task_lines = [f"写作意图:{intent or '(无)'}"]
|
||||||
|
if user_query:
|
||||||
|
task_lines.append(f"用户补充检索意图:{user_query}")
|
||||||
|
if keywords:
|
||||||
|
task_lines.append(f"可参考的检索方向:{', '.join(keywords[:8])}")
|
||||||
|
task_lines.append(f"目标素材数量约 {max_results} 条。")
|
||||||
|
task = "\n".join(task_lines)
|
||||||
|
|
||||||
|
sub_cfg = SubagentConfig(
|
||||||
|
name=f"ai-writing-skill-{skill_name}",
|
||||||
|
description="AI 写作素材检索",
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
tools=None, # 继承全套工具,技能按 SKILL.md 自行取用
|
||||||
|
disallowed_tools=["task"], # 素材检索不需要再派生子任务
|
||||||
|
skills=[skill_name], # 仅加载这个技能的 SKILL.md
|
||||||
|
model=model_name or "inherit",
|
||||||
|
max_turns=_REAL_AGENT_MAX_TURNS,
|
||||||
|
timeout_seconds=max(30, timeout_s),
|
||||||
|
)
|
||||||
|
|
||||||
|
thread_id = (config.get("configurable") or {}).get("thread_id")
|
||||||
|
try:
|
||||||
|
executor = SubagentExecutor(
|
||||||
|
config=sub_cfg, tools=tools, app_config=app_config,
|
||||||
|
parent_model=model_name, thread_id=thread_id,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("skill real-agent[%s]: executor init failed: %s", skill_name, e)
|
||||||
|
return [], f"运行时初始化失败: {e}"
|
||||||
|
|
||||||
|
# 步骤条「轻量对齐」别的智能体:读取 SKILL.md → 执行检索 两段(结果段由 researcher
|
||||||
|
# 的 _emit_skill_outcome 以 skill_result 收尾)。这两步在图的事件循环里同步发出,
|
||||||
|
# 写到时间线没问题;技能真正执行在下面的线程里跑。
|
||||||
|
if emit_query:
|
||||||
|
emit_query(f"读取技能「{skill_name}」的 SKILL.md…")
|
||||||
|
emit_query(f"技能「{skill_name}」按 SKILL.md 说明执行检索…")
|
||||||
|
|
||||||
|
# execute() 内部用「沙箱/MCP 友好」的隔离事件循环跑;放进线程,避免阻塞图的事件循环。
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(executor.execute, task)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("skill real-agent[%s]: execute failed: %s", skill_name, e, exc_info=True)
|
||||||
|
return [], f"技能检索失败: {e}"
|
||||||
|
|
||||||
|
if result.status != SubagentStatus.COMPLETED:
|
||||||
|
return [], result.error or f"技能检索未完成({result.status.value})"
|
||||||
|
|
||||||
|
parsed = parse_json(result.result or "", {"materials": []})
|
||||||
|
raw_materials = parsed.get("materials")
|
||||||
|
if not isinstance(raw_materials, list):
|
||||||
|
return [], ""
|
||||||
|
|
||||||
|
results: list = []
|
||||||
|
for m in raw_materials[:max_results]:
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
continue
|
||||||
|
content = str(m.get("content") or "").strip()
|
||||||
|
title = str(m.get("title") or "").strip()
|
||||||
|
if not content and not title:
|
||||||
|
continue
|
||||||
|
results.append(SearchResult(
|
||||||
|
title=title or "(无标题)",
|
||||||
|
content=content[:500],
|
||||||
|
source=str(m.get("source") or skill_name).strip() or skill_name,
|
||||||
|
published_at=str(m.get("published_at") or "").strip(),
|
||||||
|
url=str(m.get("url") or "").strip(),
|
||||||
|
relevance_score=0.8,
|
||||||
|
source_type="skill",
|
||||||
|
))
|
||||||
|
return results, ""
|
||||||
|
|
||||||
|
|
||||||
def _read_skill_body(skill) -> str:
|
def _read_skill_body(skill) -> str:
|
||||||
"""读技能 SKILL.md 全文;失败回退 name+description。"""
|
"""读技能 SKILL.md 全文;失败回退 name+description。"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
+13
-8
@@ -40,7 +40,7 @@ from ._agent_runner import (
|
|||||||
run_writing_agent,
|
run_writing_agent,
|
||||||
use_builtin_agent,
|
use_builtin_agent,
|
||||||
)
|
)
|
||||||
from ._skill_agent import run_skill_search_agent, use_skill_search_agent
|
from ._skill_agent import run_skill_via_real_agent, use_skill_search_agent
|
||||||
from ._utils import get_ai_writing_config, keyword_outline_section, llm_json, safe_get_stream_writer
|
from ._utils import get_ai_writing_config, keyword_outline_section, llm_json, safe_get_stream_writer
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -715,17 +715,20 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
|||||||
error_detail = ";".join(dict.fromkeys(errs))[:200]
|
error_detail = ";".join(dict.fromkeys(errs))[:200]
|
||||||
return added, error_detail
|
return added, error_detail
|
||||||
else:
|
else:
|
||||||
# ── 智能体式技能调用(config ai_writing.researcher_skill_agent 打开时)──
|
# ── 用真实智能体运行时跑技能(config ai_writing.researcher_skill_agent 打开时)──
|
||||||
# 把技能 SKILL.md 注入 + 绑定 search 工具,让模型按技能说明驱动检索,
|
# 复用 SubagentExecutor:技能 SKILL.md + 全套工具 + 沙箱齐全,技能真实检索逻辑
|
||||||
# 收割工具返回的 SearchResult 作为素材(与「智能体调用技能」逻辑一致)。
|
# (读文件 / 调内网接口 / MCP)能真正执行,再按 JSON 契约收割结构化素材——和
|
||||||
|
# 「别的智能体调用技能」完全一致,解决内网技能检不出东西的问题。
|
||||||
if use_skill_search_agent():
|
if use_skill_search_agent():
|
||||||
def _emit_q(q: str, _name=sk.name) -> None:
|
def _emit_q(msg: str, _name=sk.name) -> None:
|
||||||
|
# run_skill_via_real_agent 传入的是完整步骤文案(读取 SKILL.md /
|
||||||
|
# 执行检索…),原样透传到步骤条,与别的智能体的技能调用步骤对齐。
|
||||||
_emit_skill_step({
|
_emit_skill_step({
|
||||||
"type": "invoke_skill",
|
"type": "invoke_skill",
|
||||||
"message": f"素材收集专家用技能「{_name}」检索:{q[:60]}",
|
"message": msg,
|
||||||
"skill_name": _name,
|
"skill_name": _name,
|
||||||
})
|
})
|
||||||
agent_results, agent_err = await run_skill_search_agent(
|
agent_results, agent_err = await run_skill_via_real_agent(
|
||||||
skill=sk,
|
skill=sk,
|
||||||
intent=intent,
|
intent=intent,
|
||||||
keywords=keywords,
|
keywords=keywords,
|
||||||
@@ -739,7 +742,9 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
|||||||
)
|
)
|
||||||
if agent_results:
|
if agent_results:
|
||||||
return _collect(agent_results), ""
|
return _collect(agent_results), ""
|
||||||
# 弱模型不会工具调用 / 智能体检索失败 → 回退直连检索,保证素材不空。
|
# 技能运行时失败 / 没检索到 → 回退直连检索,保证素材不空。
|
||||||
|
if agent_err:
|
||||||
|
logger.info("skill real-agent[%s] returned no materials: %s", sk.name, agent_err)
|
||||||
|
|
||||||
# 定向技能检索(默认路径 / 智能体模式回退):user_query 模式优先用用户原话;
|
# 定向技能检索(默认路径 / 智能体模式回退):user_query 模式优先用用户原话;
|
||||||
# 非知识库技能再附上 description 让 query 贴合该技能的领域。
|
# 非知识库技能再附上 description 让 query 贴合该技能的领域。
|
||||||
|
|||||||
Reference in new issue
Block a user