1290 lines
64 KiB
Python
1290 lines
64 KiB
Python
import hashlib
|
||
import logging
|
||
|
||
from langchain.agents import create_agent
|
||
from langchain.agents.middleware import AgentMiddleware
|
||
from langchain_core.runnables import RunnableConfig
|
||
|
||
from deerflow.agents.lead_agent.prompt import apply_prompt_template
|
||
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||
from deerflow.agents.middlewares.context_window_guard_middleware import ContextWindowGuardMiddleware
|
||
from deerflow.agents.middlewares.forced_research_middleware import (
|
||
FORCED_RESEARCH_AGENT_ID,
|
||
ForcedResearchMiddleware,
|
||
filter_forced_research_tools,
|
||
)
|
||
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
||
from deerflow.agents.middlewares.markdown_file_deliverable_middleware import (
|
||
MarkdownFileDeliverableMiddleware,
|
||
)
|
||
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
||
from deerflow.agents.middlewares.multi_page_html_middleware import MultiPageHtmlMiddleware
|
||
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
|
||
from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware
|
||
from deerflow.agents.middlewares.recent_turns_middleware import RecentTurnsMiddleware
|
||
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
|
||
from deerflow.agents.middlewares.setup_report_structure_approval_middleware import SetupReportStructureApprovalMiddleware
|
||
from deerflow.agents.middlewares.setup_writing_approval_middleware import SetupWritingApprovalMiddleware
|
||
from deerflow.agents.middlewares.smart_reasoning_retrieval_middleware import (
|
||
SMART_REASONING_TOOL_NAMES,
|
||
SmartReasoningRetrievalMiddleware,
|
||
)
|
||
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
||
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
|
||
from deerflow.agents.middlewares.task_external_context_middleware import TaskExternalContextMiddleware
|
||
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
||
from deerflow.agents.middlewares.todo_middleware import TodoMiddleware
|
||
from deerflow.agents.middlewares.token_usage_middleware import TokenUsageMiddleware
|
||
from deerflow.agents.middlewares.tool_error_handling_middleware import build_lead_runtime_middlewares
|
||
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
|
||
from deerflow.agents.thread_state import ThreadState
|
||
from deerflow.config.agents_config import load_agent_config, load_agent_soul, validate_agent_id
|
||
from deerflow.config.app_config import AppConfig, get_app_config
|
||
from deerflow.config.system_settings import get_conversation_context_settings, get_memory_runtime_settings
|
||
from deerflow.models import create_chat_model
|
||
from deerflow.tools.builtins.llmwiki_reasoning_tools import (
|
||
CLASSIC_LLMWIKI_TOOL_NAMES,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 写作配置 Q&A:意图澄清最多几轮(一条用户消息 = 一轮),到顶强制提交配置卡。
|
||
# 写作配置 Q&A 现在「先生成大纲并与用户多轮确认 → 再提交配置」,需要更多来回,故上限放宽。
|
||
# 仍是兜底安全阀:到顶才用 tool_choice 强制提交,正常流程由用户确认大纲后自然提交。
|
||
WRITING_SETUP_MAX_ROUNDS = 8
|
||
REPORT_STRUCTURE_SETUP_MAX_ROUNDS = 8
|
||
|
||
_ES_QUERY_ROUTING_OPEN_TAG = '<skill_routing skill="es_query">'
|
||
|
||
_SKILLS_BLOCKED_BY_EXCLUDED_TOOL: dict[str, frozenset[str]] = {
|
||
# 内置 tool 与历史 custom skill 目录存在两种拼写;禁用编排工具时两者都必须从系统提示和
|
||
# runtime skill allowlist 中消失,防止误配的席位仍看到「加载编排技能」。
|
||
"agent_orchestration": frozenset({"agent_orchestration", "agent-orchestration"}),
|
||
}
|
||
|
||
_ROUNDTABLE_COORDINATOR_AGENT_ID = "roundtable-coordinator"
|
||
# 任务分析定时的专用总控(无人值守分解任务并派活)与圆桌总控同权:仅这两个
|
||
# agent 允许编排/派活,其余席位继续被护栏隔离。
|
||
_COORDINATOR_AGENT_IDS = frozenset(
|
||
{_ROUNDTABLE_COORDINATOR_AGENT_ID, "task-analysis-coordinator"}
|
||
)
|
||
|
||
_NON_COORDINATOR_ORCHESTRATION_BOUNDARY = """
|
||
<orchestration_capability_boundary>
|
||
你不是多智能体圆桌总控,因此没有编排、派活或调度其它智能体的权限。
|
||
不得加载、读取、检索或调用 agent_orchestration / agent-orchestration,
|
||
不得声称准备使用编排技能;请直接完成当前问答或你的本职任务。
|
||
</orchestration_capability_boundary>
|
||
""".strip()
|
||
|
||
|
||
def _enforce_coordinator_only_tool_exclusions(
|
||
agent_id: str | None,
|
||
excluded_tools: list[str] | None,
|
||
) -> list[str] | None:
|
||
"""Make orchestration unavailable to every agent except the fixed roundtable coordinator."""
|
||
if agent_id in _COORDINATOR_AGENT_IDS:
|
||
return excluded_tools
|
||
effective = list(excluded_tools or [])
|
||
if "agent_orchestration" not in effective:
|
||
effective.append("agent_orchestration")
|
||
return effective
|
||
|
||
|
||
def _append_orchestration_capability_boundary(system_prompt: str, agent_id: str | None) -> str:
|
||
"""Reinforce the physical tool/skill gate in every non-coordinator system prompt."""
|
||
if agent_id in _COORDINATOR_AGENT_IDS:
|
||
return system_prompt
|
||
return f"{system_prompt}\n\n{_NON_COORDINATOR_ORCHESTRATION_BOUNDARY}"
|
||
|
||
|
||
def _filter_available_skills_for_excluded_tools(
|
||
available_skills: list[str] | None,
|
||
excluded_tools: list[str] | None,
|
||
) -> list[str] | None:
|
||
"""让工具级禁用同步收紧 skill allowlist,必要时把「全部」展开为安全列表。"""
|
||
if not excluded_tools:
|
||
return available_skills
|
||
excluded = {name.strip().lower() for name in excluded_tools if name}
|
||
blocked = {
|
||
skill_name
|
||
for tool_name, skill_names in _SKILLS_BLOCKED_BY_EXCLUDED_TOOL.items()
|
||
if tool_name in excluded
|
||
for skill_name in skill_names
|
||
}
|
||
if not blocked:
|
||
return available_skills
|
||
if available_skills is None:
|
||
# ``None`` 原本表示「全部启用技能可见」,无法表达「除了总控专属技能之外的全部」。
|
||
# 此时显式展开存储中的启用技能;扫描失败则 fail closed,避免角色隔离失效。
|
||
try:
|
||
from deerflow.skills.storage import get_or_new_skill_storage
|
||
|
||
available_skills = [
|
||
skill.name for skill in get_or_new_skill_storage().load_skills(enabled_only=True)
|
||
]
|
||
except Exception: # noqa: BLE001
|
||
logger.warning("Failed to expand skills while applying excluded-tool boundary", exc_info=True)
|
||
available_skills = []
|
||
return [name for name in available_skills if name.strip().lower() not in blocked]
|
||
|
||
|
||
_KNOWLEDGE_SPACE_RETRIEVAL_EXCLUSIONS = {
|
||
"web_search",
|
||
"web_fetch",
|
||
"image_search",
|
||
"browser_fetch_page",
|
||
"browser_act",
|
||
"knowledge_search",
|
||
"notebook_search",
|
||
"tool_search",
|
||
"invoke_acp_agent",
|
||
"agent_orchestration",
|
||
"task",
|
||
}
|
||
|
||
_SMART_REASONING_TOOL_NAMES = SMART_REASONING_TOOL_NAMES
|
||
_CLASSIC_LLMWIKI_TOOL_NAMES = CLASSIC_LLMWIKI_TOOL_NAMES
|
||
|
||
_SMART_REASONING_KNOWLEDGE_SCOPE_PROMPT = """
|
||
|
||
<knowledge_space_scope>
|
||
本轮已启用普通知识库“智能推理检索”。这表示所选知识库作为普通问答中的按需资料工具可用,不表示每个问题都必须检索,也不把当前对话切换成独立的检索流程。正常问答、技能、Web、MCP、子智能体及其它工具仍按原有权限和意图规则使用。
|
||
|
||
先判断当前问题是否真的需要知识库中的事实:
|
||
- 问候、闲聊、自我介绍、“你有哪些技能/会做什么/有什么工具”、界面或系统设置说明、对用户已提供文本的改写,以及不依赖库内专属事实的一般推理,直接回答,不得为了展示流程而调用知识库工具。
|
||
- 用户询问所选资料、库内人物/事件/制度/数据,或回答需要从库内核验具体事实时,才调用下面的知识库工具。用户明确要求只依据知识库时,执行更严格的仅库内回答。
|
||
- 知识库工具与其它正常工具并列。按问题选择最合适的来源;不要仅因为用户选中了知识库就屏蔽、忽略或替代普通技能和工具。
|
||
|
||
需要检索时按证据缺口选择工具,不存在强制顺序,也不要求同时覆盖原文和 Wiki:
|
||
1. ordinary_knowledge_search 适合用 1 至 5 个聚焦改写检索原始文档分片;ordinary_document_chunks 用于读取命中分片的邻近上下文或文档后续分页。
|
||
2. ordinary_wiki_search 适合用安全 POSIX 正则发现相关 Wiki 页面、概念关系和时间线;搜索结果只是页面入口,确实需要正文时再调用 ordinary_wiki_read_page,并按 next_cursor 续读长页面。
|
||
若返回 native_regex_supported=false,本轮不得再次尝试 Wiki 正则,也不得改用目录枚举或逐页读取模拟搜索;继续使用已取得的原文证据、其它正常工具和模型原有能力完成回答。
|
||
3. ordinary_chunk_regex_search 仅在需要核验精确数字、编号、日期、原句或固定词形时使用。没有实际证据缺口就停止检索并回答。
|
||
4. 同一用户回合不得重复相同 query、相同正则、相同 page_ref+cursor 或相同文档分页。某个表达式无结果时最多做一次有实质变化的改写。新问题若需要库内事实,应重新取得相关证据,不把旧轮工具正文当作当前证据。
|
||
5. 检索后用连贯自然语言直接回答。Wiki 与原始文档冲突时,以能定位到原始文档切片的证据为准并说明差异。本模式不套用经典知识库编号参考文献模式:
|
||
- 专名、作品/文件/活动标题、日期、时间、数字、单位和数量关系必须逐项回到原始文档切片核对,并按证据原样保留;不得近义改写专名或混用相似名称。证据未明确给出的细节不要补写。
|
||
- 最终回答不要求使用 [1]、[2] 等编号,也不要求生成文末参考文献列表。
|
||
- 除非用户本轮明确要求引用格式,否则不要为知识库检索结果额外编排编号或文献列表;若实际使用 Web 等外部来源,仍遵守对应工具的引用规则。
|
||
- 不要向用户暴露工具名、内部 ID 或检索协议。具体事实证据不足时明确说明缺少什么;通用背景补充应使用“一般而言”等清晰措辞,不能伪装成库内结论。
|
||
</knowledge_space_scope>"""
|
||
|
||
|
||
def _without_classic_citation_contracts(system_prompt: str) -> str:
|
||
"""Remove only the classic numbered-RAG citation contract.
|
||
|
||
Smart knowledge results have their own structured trace, but normal Web
|
||
tools remain available and must retain the base prompt's URL citation
|
||
contract.
|
||
"""
|
||
|
||
cleaned = system_prompt
|
||
for tag in ("rag_citation_mode",):
|
||
opening = f"<{tag}>"
|
||
closing = f"</{tag}>"
|
||
while opening in cleaned:
|
||
start = cleaned.find(opening)
|
||
end = cleaned.find(closing, start + len(opening))
|
||
if end < 0:
|
||
break
|
||
cleaned = cleaned[:start] + cleaned[end + len(closing) :]
|
||
return cleaned
|
||
|
||
|
||
def _knowledge_space_strict_exclusions(excluded_tools: list[str] | None) -> list[str]:
|
||
return list(dict.fromkeys([*(excluded_tools or []), *sorted(_KNOWLEDGE_SPACE_RETRIEVAL_EXCLUSIONS)]))
|
||
|
||
|
||
def _log_es_query_routing_registration(
|
||
*,
|
||
system_prompt: str,
|
||
app_config: AppConfig,
|
||
agent_id: str | None,
|
||
available_skills: set[str] | None,
|
||
) -> None:
|
||
"""Log whether the exact configured ES routing block reached this Q&A prompt."""
|
||
routing = app_config.skills.es_query_routing
|
||
configured_prompt = (routing.prompt or "").strip()
|
||
expected_block = (
|
||
f"{_ES_QUERY_ROUTING_OPEN_TAG}\n{configured_prompt}\n</skill_routing>"
|
||
if configured_prompt
|
||
else ""
|
||
)
|
||
eligible = available_skills is None or "es_query" in available_skills
|
||
registered = bool(expected_block) and expected_block in system_prompt
|
||
fingerprint = (
|
||
hashlib.sha256(configured_prompt.encode("utf-8")).hexdigest()[:12]
|
||
if configured_prompt
|
||
else "-"
|
||
)
|
||
|
||
log = logger.info if registered or not routing.enabled or not eligible else logger.warning
|
||
log(
|
||
"ES query routing prompt registration: agent_id=%s enabled=%s eligible=%s "
|
||
"registered=%s prompt_chars=%d prompt_sha256=%s",
|
||
agent_id or "default",
|
||
routing.enabled,
|
||
eligible,
|
||
registered,
|
||
len(configured_prompt),
|
||
fingerprint,
|
||
)
|
||
|
||
|
||
def _get_runtime_config(config: RunnableConfig) -> dict:
|
||
"""Merge legacy configurable options with LangGraph runtime context."""
|
||
cfg = dict(config.get("configurable", {}) or {})
|
||
context = config.get("context", {}) or {}
|
||
if isinstance(context, dict):
|
||
cfg.update(context)
|
||
return cfg
|
||
|
||
|
||
def _runtime_tool_is_enabled(cfg: dict, tool_name: str) -> bool:
|
||
"""Return whether a middleware may rely on a runtime-filtered tool.
|
||
|
||
Tool-driving middleware must follow the same allow/exclude contract as the
|
||
tool loader. Otherwise a middleware can synthesize a call to a tool that
|
||
was deliberately removed from the graph. That happened in roundtable
|
||
synthesis: seat deliverables containing ``.md`` made the Markdown delivery
|
||
middleware inject ``write_file`` into the text-only coordinator, replacing
|
||
its final summary with an impossible tool call.
|
||
"""
|
||
if bool(cfg.get("disable_tools", False)):
|
||
return False
|
||
|
||
normalized_name = str(tool_name or "").strip()
|
||
raw_allowed = cfg.get("allowed_tools")
|
||
if raw_allowed is not None:
|
||
allowed = (
|
||
{raw_allowed.strip()}
|
||
if isinstance(raw_allowed, str) and raw_allowed.strip()
|
||
else {
|
||
str(name).strip()
|
||
for name in raw_allowed
|
||
if str(name).strip()
|
||
}
|
||
if isinstance(raw_allowed, (list, tuple, set))
|
||
else set()
|
||
)
|
||
if normalized_name not in allowed:
|
||
return False
|
||
|
||
raw_excluded = cfg.get("excluded_tools")
|
||
excluded = (
|
||
{raw_excluded.strip()}
|
||
if isinstance(raw_excluded, str) and raw_excluded.strip()
|
||
else {
|
||
str(name).strip()
|
||
for name in raw_excluded
|
||
if str(name).strip()
|
||
}
|
||
if isinstance(raw_excluded, (list, tuple, set))
|
||
else set()
|
||
)
|
||
return normalized_name not in excluded
|
||
|
||
|
||
def _resolve_model_name(requested_model_name: str | None = None, *, app_config: AppConfig | None = None) -> str:
|
||
"""Resolve a runtime model name safely, falling back to default if invalid. Returns None if no models are configured."""
|
||
app_config = app_config or get_app_config()
|
||
default_model_name = app_config.models[0].name if app_config.models else None
|
||
if default_model_name is None:
|
||
raise ValueError("No chat models are configured. Please configure at least one model in config.yaml.")
|
||
|
||
if requested_model_name and app_config.get_model_config(requested_model_name):
|
||
return requested_model_name
|
||
|
||
if requested_model_name and requested_model_name != default_model_name:
|
||
logger.warning(f"Model '{requested_model_name}' not found in config; fallback to default model '{default_model_name}'.")
|
||
return default_model_name
|
||
|
||
|
||
def _create_summarization_middleware(*, app_config: AppConfig | None = None, force_enabled: bool = False) -> DeerFlowSummarizationMiddleware | None:
|
||
"""Create and configure the summarization middleware from config.
|
||
|
||
Args:
|
||
force_enabled: When True, build the middleware regardless of the config
|
||
``summarization.enabled`` flag. The per-request 上下文压缩 toggle
|
||
(chat input box, default off) drives this — the config file no longer
|
||
owns the on/off switch, only the tuning (trigger/keep/...).
|
||
"""
|
||
resolved_app_config = app_config or get_app_config()
|
||
config = resolved_app_config.summarization
|
||
|
||
if not (config.enabled or force_enabled):
|
||
return None
|
||
|
||
# Prepare trigger parameter
|
||
trigger = None
|
||
if config.trigger is not None:
|
||
if isinstance(config.trigger, list):
|
||
trigger = [t.to_tuple() for t in config.trigger]
|
||
else:
|
||
trigger = config.trigger.to_tuple()
|
||
|
||
# Prepare keep parameter
|
||
keep = config.keep.to_tuple()
|
||
|
||
# Prepare model parameter.
|
||
# Bind "middleware:summarize" tag so RunJournal identifies these LLM calls
|
||
# as middleware rather than lead_agent (SummarizationMiddleware is a
|
||
# LangChain built-in, so we tag the model at creation time).
|
||
if config.model_name:
|
||
model = create_chat_model(name=config.model_name, thinking_enabled=False, force_disable_thinking=True, app_config=resolved_app_config)
|
||
else:
|
||
model = create_chat_model(thinking_enabled=False, force_disable_thinking=True, app_config=resolved_app_config)
|
||
model = model.with_config(tags=["middleware:summarize"])
|
||
|
||
# Prepare kwargs
|
||
kwargs = {
|
||
"model": model,
|
||
"trigger": trigger,
|
||
"keep": keep,
|
||
"trim_tokens_to_summarize": config.trim_tokens_to_summarize,
|
||
}
|
||
|
||
if config.summary_prompt is not None:
|
||
kwargs["summary_prompt"] = config.summary_prompt
|
||
|
||
# Memory V2:不再有后台抽取队列,无需 summarization 前的 flush hook。
|
||
hooks: list[BeforeSummarizationHook] = []
|
||
|
||
# The logic below relies on two assumptions holding true: this factory is
|
||
# the sole entry point for DeerFlowSummarizationMiddleware, and the runtime
|
||
# config is not expected to change after startup.
|
||
skills_container_path = resolved_app_config.skills.container_path or "/mnt/skills"
|
||
|
||
return DeerFlowSummarizationMiddleware(
|
||
**kwargs,
|
||
summary_max_input_tokens=(
|
||
resolved_app_config.get_model_config(config.model_name).max_input_tokens
|
||
if config.model_name else resolved_app_config.models[0].max_input_tokens
|
||
),
|
||
skills_container_path=skills_container_path,
|
||
skill_file_read_tool_names=config.skill_file_read_tool_names,
|
||
before_summarization=hooks,
|
||
preserve_recent_skill_count=config.preserve_recent_skill_count,
|
||
preserve_recent_skill_tokens=config.preserve_recent_skill_tokens,
|
||
preserve_recent_skill_tokens_per_skill=config.preserve_recent_skill_tokens_per_skill,
|
||
)
|
||
|
||
|
||
def _create_context_reduction_middleware(
|
||
*,
|
||
app_config: AppConfig | None = None,
|
||
summarization_requested: bool = False,
|
||
) -> AgentMiddleware | None:
|
||
"""Resolve the active context-reduction strategy for this model run.
|
||
|
||
The administrator-managed recent-turn window has priority over summary
|
||
compression and is global once enabled. Its request-only trimming avoids
|
||
the summarization LLM call while leaving checkpoint history untouched.
|
||
With the setting off, the existing per-request/config summarization
|
||
behavior is preserved exactly.
|
||
"""
|
||
context_settings = get_conversation_context_settings()
|
||
if context_settings.recent_turn_window_enabled:
|
||
return RecentTurnsMiddleware()
|
||
return _create_summarization_middleware(
|
||
app_config=app_config,
|
||
force_enabled=summarization_requested,
|
||
)
|
||
|
||
|
||
def _create_todo_list_middleware(is_plan_mode: bool) -> TodoMiddleware | None:
|
||
"""Create and configure the TodoList middleware.
|
||
|
||
Args:
|
||
is_plan_mode: Whether to enable plan mode with TodoList middleware.
|
||
|
||
Returns:
|
||
TodoMiddleware instance if plan mode is enabled, None otherwise.
|
||
"""
|
||
if not is_plan_mode:
|
||
return None
|
||
|
||
# Custom prompts matching DeerFlow's style
|
||
system_prompt = """
|
||
<todo_list_system>
|
||
You have access to the `write_todos` tool to help you manage and track complex multi-step objectives.
|
||
|
||
**CRITICAL RULES:**
|
||
- Mark todos as completed IMMEDIATELY after finishing each step - do NOT batch completions
|
||
- Keep EXACTLY ONE task as `in_progress` at any time (unless tasks can run in parallel)
|
||
- Update the todo list in REAL-TIME as you work - this gives users visibility into your progress
|
||
- DO NOT use this tool for simple tasks (< 3 steps) - just complete them directly
|
||
|
||
**When to Use:**
|
||
This tool is designed for complex objectives that require systematic tracking:
|
||
- Complex multi-step tasks requiring 3+ distinct steps
|
||
- Non-trivial tasks needing careful planning and execution
|
||
- User explicitly requests a todo list
|
||
- User provides multiple tasks (numbered or comma-separated list)
|
||
- The plan may need revisions based on intermediate results
|
||
|
||
**When NOT to Use:**
|
||
- Single, straightforward tasks
|
||
- Trivial tasks (< 3 steps)
|
||
- Purely conversational or informational requests
|
||
- Simple tool calls where the approach is obvious
|
||
|
||
**Best Practices:**
|
||
- Break down complex tasks into smaller, actionable steps
|
||
- Use clear, descriptive task names
|
||
- Remove tasks that become irrelevant
|
||
- Add new tasks discovered during implementation
|
||
- Don't be afraid to revise the todo list as you learn more
|
||
|
||
**Task Management:**
|
||
Writing todos takes time and tokens - use it when helpful for managing complex problems, not for simple requests.
|
||
</todo_list_system>
|
||
"""
|
||
|
||
tool_description = """Use this tool to create and manage a structured task list for complex work sessions.
|
||
|
||
**IMPORTANT: Only use this tool for complex tasks (3+ steps). For simple requests, just do the work directly.**
|
||
|
||
## When to Use
|
||
|
||
Use this tool in these scenarios:
|
||
1. **Complex multi-step tasks**: When a task requires 3 or more distinct steps or actions
|
||
2. **Non-trivial tasks**: Tasks requiring careful planning or multiple operations
|
||
3. **User explicitly requests todo list**: When the user directly asks you to track tasks
|
||
4. **Multiple tasks**: When users provide a list of things to be done
|
||
5. **Dynamic planning**: When the plan may need updates based on intermediate results
|
||
|
||
## When NOT to Use
|
||
|
||
Skip this tool when:
|
||
1. The task is straightforward and takes less than 3 steps
|
||
2. The task is trivial and tracking provides no benefit
|
||
3. The task is purely conversational or informational
|
||
4. It's clear what needs to be done and you can just do it
|
||
|
||
## How to Use
|
||
|
||
1. **Starting a task**: Mark it as `in_progress` BEFORE beginning work
|
||
2. **Completing a task**: Mark it as `completed` IMMEDIATELY after finishing
|
||
3. **Updating the list**: Add new tasks, remove irrelevant ones, or update descriptions as needed
|
||
4. **Multiple updates**: You can make several updates at once (e.g., complete one task and start the next)
|
||
|
||
## Task States
|
||
|
||
- `pending`: Task not yet started
|
||
- `in_progress`: Currently working on (can have multiple if tasks run in parallel)
|
||
- `completed`: Task finished successfully
|
||
|
||
## Task Completion Requirements
|
||
|
||
**CRITICAL: Only mark a task as completed when you have FULLY accomplished it.**
|
||
|
||
Never mark a task as completed if:
|
||
- There are unresolved issues or errors
|
||
- Work is partial or incomplete
|
||
- You encountered blockers preventing completion
|
||
- You couldn't find necessary resources or dependencies
|
||
- Quality standards haven't been met
|
||
|
||
If blocked, keep the task as `in_progress` and create a new task describing what needs to be resolved.
|
||
|
||
## Best Practices
|
||
|
||
- Create specific, actionable items
|
||
- Break complex tasks into smaller, manageable steps
|
||
- Use clear, descriptive task names
|
||
- Update task status in real-time as you work
|
||
- Mark tasks complete IMMEDIATELY after finishing (don't batch completions)
|
||
- Remove tasks that are no longer relevant
|
||
- **IMPORTANT**: When you write the todo list, mark your first task(s) as `in_progress` immediately
|
||
- **IMPORTANT**: Unless all tasks are completed, always have at least one task `in_progress` to show progress
|
||
|
||
Being proactive with task management demonstrates thoroughness and ensures all requirements are completed successfully.
|
||
|
||
**Remember**: If you only need a few tool calls to complete a task and it's clear what to do, it's better to just do the task directly and NOT use this tool at all.
|
||
"""
|
||
|
||
return TodoMiddleware(system_prompt=system_prompt, tool_description=tool_description)
|
||
|
||
|
||
# ThreadDataMiddleware must be before SandboxMiddleware to ensure thread_id is available
|
||
# UploadsMiddleware should be after ThreadDataMiddleware to access thread_id
|
||
# DanglingToolCallMiddleware patches missing ToolMessages before model sees the history
|
||
# SummarizationMiddleware should be early to reduce context before other processing
|
||
# TodoListMiddleware should be before ClarificationMiddleware to allow todo management
|
||
# TitleMiddleware generates title after first exchange
|
||
# MemoryMiddleware queues conversation for memory update (after TitleMiddleware)
|
||
# ViewImageMiddleware should be before ClarificationMiddleware to inject image details before LLM
|
||
# ToolErrorHandlingMiddleware should be before ClarificationMiddleware to convert tool exceptions to ToolMessages
|
||
# ClarificationMiddleware should be last to intercept clarification requests after model calls
|
||
def _build_middlewares(
|
||
config: RunnableConfig,
|
||
model_name: str | None,
|
||
agent_name: str | None = None,
|
||
custom_middlewares: list[AgentMiddleware] | None = None,
|
||
*,
|
||
app_config: AppConfig | None = None,
|
||
):
|
||
"""Build middleware chain based on runtime configuration.
|
||
|
||
Args:
|
||
config: Runtime configuration containing configurable options like is_plan_mode.
|
||
agent_name: If provided, MemoryMiddleware will use per-agent memory storage.
|
||
custom_middlewares: Optional list of custom middlewares to inject into the chain.
|
||
|
||
Returns:
|
||
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())
|
||
smart_knowledge_reasoning = (
|
||
str(cfg.get("knowledge_retrieval_mode") or "classic").strip().lower()
|
||
== "smart_reasoning"
|
||
)
|
||
if smart_knowledge_reasoning:
|
||
# Request-only cleanup keeps checkpoint/UI history intact while each
|
||
# new question starts with fresh evidence. It also owns the hard
|
||
# forty-call synthesis budget for this mode.
|
||
middlewares.append(SmartReasoningRetrievalMiddleware())
|
||
if cfg.get("rag_mode_enabled", False) and not smart_knowledge_reasoning:
|
||
# Smart reasoning deliberately has no numbered-reference contract.
|
||
# Keep this fail-safe in the harness even though the Gateway also
|
||
# clears rag_mode_enabled for validated smart runs.
|
||
middlewares.append(RagCitationMiddleware())
|
||
|
||
# Add the active context-reduction middleware. The admin-controlled
|
||
# recent-three-turn window is global and takes priority; when it is off,
|
||
# the existing chat-level summarization toggle/config fallback is unchanged.
|
||
summarization_requested = cfg.get("summarization_enabled", False)
|
||
context_reduction_middleware = _create_context_reduction_middleware(
|
||
app_config=resolved_app_config,
|
||
summarization_requested=summarization_requested,
|
||
)
|
||
if context_reduction_middleware is not None:
|
||
middlewares.append(context_reduction_middleware)
|
||
|
||
# Add TodoList middleware if plan mode is enabled
|
||
is_plan_mode = cfg.get("is_plan_mode", False)
|
||
is_bootstrap = cfg.get("is_bootstrap", False)
|
||
is_writing_setup = cfg.get("is_writing_setup", False)
|
||
is_report_structure_setup = cfg.get("is_report_structure_setup", False)
|
||
is_gongwen_template_setup = cfg.get("is_gongwen_template_setup", False)
|
||
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
|
||
if todo_list_middleware is not None:
|
||
middlewares.append(todo_list_middleware)
|
||
|
||
# Add TokenUsageMiddleware when token_usage tracking is enabled
|
||
if resolved_app_config.token_usage.enabled:
|
||
middlewares.append(TokenUsageMiddleware())
|
||
|
||
# Add TitleMiddleware — but skip it for scheduled runs. Each scheduled run
|
||
# executes on a throwaway thread whose title is never shown to a user, and
|
||
# generating one would fire an extra LLM call on the *default* model
|
||
# (config.title.model_name is usually unset → models[0]), which is not the
|
||
# model the task selected. Skipping it keeps scheduled/HTML runs on exactly
|
||
# the chosen model.
|
||
if not cfg.get("is_scheduled_run", False):
|
||
middlewares.append(TitleMiddleware(app_config=resolved_app_config))
|
||
|
||
# Ephemeral runs (e.g. the open Q&A API /api/open/chat) must NOT touch any
|
||
# server-side per-user state: no memory recall/retain, no knowledge-base
|
||
# recall/auto-ingest. Those runs use the shared "default" user bucket, so
|
||
# persisting their conversations there would both store records the caller
|
||
# explicitly asked NOT to keep and leak content across unrelated callers.
|
||
# Skip both write-capable middlewares entirely.
|
||
is_ephemeral = cfg.get("ephemeral", False)
|
||
|
||
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode
|
||
# (agent creation flow) and ephemeral runs. A server-validated analysis
|
||
# project memory scope is a separate authority domain: mounting the generic
|
||
# middleware there would still run Hindsight ``after_agent`` retention even
|
||
# when prompt injection is disabled, leaking project turns into the
|
||
# user/agent bank. Skip the middleware altogether for that scope.
|
||
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
|
||
project_memory_scope = cfg.get("project_memory_scope")
|
||
if (
|
||
get_memory_runtime_settings().enabled
|
||
and not isinstance(project_memory_scope, dict)
|
||
and not is_bootstrap
|
||
and not is_writing_setup
|
||
and not is_report_structure_setup
|
||
and not is_gongwen_template_setup
|
||
and not is_ephemeral
|
||
):
|
||
middlewares.append(MemoryMiddleware(agent_name=agent_name, injection_enabled=memory_injection_enabled))
|
||
|
||
# KnowledgeRagMiddleware — inject knowledge-base recall (phase 2). Gated
|
||
# per-conversation by the ``knowledge_rag_enabled`` runtime flag, defaulting
|
||
# to the global ``knowledge.rag_enabled`` config. No-op when knowledge is
|
||
# disabled. Skipped in bootstrap/writing-setup/ephemeral like MemoryMiddleware.
|
||
knowledge_cfg = getattr(resolved_app_config, "knowledge", None)
|
||
if (
|
||
not smart_knowledge_reasoning
|
||
and not is_bootstrap
|
||
and not is_writing_setup
|
||
and not is_report_structure_setup
|
||
and not is_gongwen_template_setup
|
||
and not is_ephemeral
|
||
and knowledge_cfg is not None
|
||
and knowledge_cfg.enabled
|
||
):
|
||
from deerflow.agents.middlewares.knowledge_rag_middleware import KnowledgeRagMiddleware
|
||
|
||
middlewares.append(KnowledgeRagMiddleware(limit=knowledge_cfg.rag_limit, default_enabled=knowledge_cfg.rag_enabled))
|
||
|
||
# TaskExternalContextMiddleware — inject hidden per-run business context
|
||
# (for example rwfx's completed 3q report) into the model request only.
|
||
middlewares.append(TaskExternalContextMiddleware())
|
||
|
||
# Add ViewImageMiddleware only for the NATIVE-vision path (main model
|
||
# ingests raw image data). In delegated mode the view_image tool already
|
||
# returns a text description and stores nothing in `viewed_images`, so the
|
||
# middleware would otherwise inject a spurious "No images have been viewed."
|
||
# message. Use the resolved runtime model_name to avoid stale config values.
|
||
from deerflow.models.vision import is_delegated_vision, vision_enabled
|
||
|
||
if vision_enabled(model_name, app_config=resolved_app_config) and not is_delegated_vision(app_config=resolved_app_config):
|
||
middlewares.append(ViewImageMiddleware())
|
||
|
||
# Add DeferredToolFilterMiddleware to hide deferred tool schemas from model binding
|
||
if resolved_app_config.tool_search.enabled:
|
||
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
||
|
||
middlewares.append(DeferredToolFilterMiddleware())
|
||
|
||
# Add SubagentLimitMiddleware to truncate excess parallel task calls
|
||
subagent_enabled = cfg.get("subagent_enabled", False)
|
||
if subagent_enabled:
|
||
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
||
middlewares.append(SubagentLimitMiddleware(max_concurrent=max_concurrent_subagents))
|
||
|
||
# SkillReviewMiddleware — trigger background skill review after each turn.
|
||
# Gated by skill_evolution.enabled + skill_evolution.review_worker.enabled.
|
||
if resolved_app_config.skill_evolution.enabled and resolved_app_config.skill_evolution.review_worker.enabled:
|
||
from deerflow.agents.middlewares.skill_review_middleware import SkillReviewMiddleware
|
||
|
||
middlewares.append(SkillReviewMiddleware())
|
||
|
||
# PositionArtifactCapMiddleware — position-roundtable seats (and summary)
|
||
# may only emit N outputs write_file calls per user turn. Action-plan skips
|
||
# the context flag so it can still write report + JSON.
|
||
from deerflow.agents.middlewares.position_artifact_cap_middleware import PositionArtifactCapMiddleware
|
||
|
||
middlewares.append(PositionArtifactCapMiddleware())
|
||
|
||
# Explicit multi-page HTML is a single lightweight portal artifact, not an
|
||
# arbitrary collection of self-written tabs. The prompt explains the
|
||
# contract; this middleware is the final server-side delivery gate.
|
||
middlewares.append(MultiPageHtmlMiddleware())
|
||
|
||
# The built-in forced-research responder has a hard per-turn collection
|
||
# gate plus temporary-Python-only sandbox IO. This is runtime enforcement,
|
||
# not merely SOUL prompt steering, so weak models cannot answer before a
|
||
# successful retrieval/skill-script result exists.
|
||
if agent_name == FORCED_RESEARCH_AGENT_ID:
|
||
middlewares.append(ForcedResearchMiddleware())
|
||
|
||
# 写作模式首轮:允许先聊/检索;只有模型开始写 md 时才改走 deep_research_report。
|
||
# 不设 tool_choice——thinking 模式的兼容接口会 400。
|
||
writing_mode = bool(cfg.get("writing_mode", False))
|
||
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
|
||
workbench_write_now = bool(cfg.get("workbench_write_now", False))
|
||
if writing_mode or workbench_write_now:
|
||
from deerflow.tools.builtins.deep_research_report_tool import is_writing_mode_report_path
|
||
|
||
if workbench_write_now or not is_writing_mode_report_path(
|
||
writing_artifact_path if isinstance(writing_artifact_path, str) else None
|
||
):
|
||
from deerflow.agents.middlewares.writing_mode_report_middleware import WritingModeReportMiddleware
|
||
|
||
middlewares.append(
|
||
WritingModeReportMiddleware(
|
||
require_pipeline=bool(cfg.get("solution_requirements_confirmed", False)),
|
||
solution_intent=(
|
||
cfg.get("solution_intent")
|
||
if isinstance(cfg.get("solution_intent"), dict)
|
||
else None
|
||
),
|
||
)
|
||
)
|
||
if workbench_write_now:
|
||
from deerflow.agents.middlewares.workbench_write_now_middleware import WorkbenchWriteNowMiddleware
|
||
|
||
middlewares.append(WorkbenchWriteNowMiddleware())
|
||
|
||
# LoopDetectionMiddleware — detect and break repetitive tool call loops
|
||
middlewares.append(LoopDetectionMiddleware())
|
||
# This middleware creates a synthetic ``write_file`` call after the model
|
||
# responds. Mount it only when that tool is actually present under the
|
||
# request policy. In particular the Step-2 coordinator is text-only; its
|
||
# synthesis input can legitimately quote seat instructions mentioning
|
||
# ``.md`` and must still finish with a normal textual summary.
|
||
if (
|
||
not writing_mode
|
||
and not workbench_write_now
|
||
and _runtime_tool_is_enabled(cfg, "write_file")
|
||
):
|
||
middlewares.append(MarkdownFileDeliverableMiddleware())
|
||
|
||
# Inject custom middlewares before ClarificationMiddleware
|
||
if custom_middlewares:
|
||
middlewares.extend(custom_middlewares)
|
||
|
||
middlewares.append(SetupAgentApprovalMiddleware())
|
||
middlewares.append(SetupWritingApprovalMiddleware())
|
||
middlewares.append(SetupReportStructureApprovalMiddleware())
|
||
|
||
# SkillStopMiddleware — intercepts skill invocations and halts execution
|
||
# when a configured skill is about to run. Request-level options
|
||
# (skill_stop_names / skill_stop_message) take precedence; static
|
||
# config in ``app_config.skill_stop`` is the fallback.
|
||
skill_stop_names = cfg.get("skill_stop_names")
|
||
skill_stop_message = cfg.get("skill_stop_message")
|
||
if skill_stop_names:
|
||
from deerflow.agents.middlewares.skill_stop_middleware import SkillStopMiddleware
|
||
|
||
middlewares.append(
|
||
SkillStopMiddleware(
|
||
skill_names=skill_stop_names,
|
||
skills_root=resolved_app_config.skills.container_path or "/mnt/skills",
|
||
stop_message=skill_stop_message,
|
||
stop_on_match=True,
|
||
)
|
||
)
|
||
logger.info("SkillStopMiddleware enabled (from request): %s", skill_stop_names)
|
||
elif resolved_app_config.skill_stop.enabled and resolved_app_config.skill_stop.skill_names:
|
||
from deerflow.agents.middlewares.skill_stop_middleware import SkillStopMiddleware
|
||
|
||
middlewares.append(
|
||
SkillStopMiddleware(
|
||
skill_names=resolved_app_config.skill_stop.skill_names,
|
||
skills_root=resolved_app_config.skills.container_path or "/mnt/skills",
|
||
stop_message=resolved_app_config.skill_stop.stop_message,
|
||
stop_on_match=resolved_app_config.skill_stop.stop_on_match,
|
||
)
|
||
)
|
||
logger.info("SkillStopMiddleware enabled (from config): %s", resolved_app_config.skill_stop.skill_names)
|
||
|
||
# Final, non-destructive safety net. Keep it after every middleware that can
|
||
# inject system/context/tool data so the request that actually reaches the
|
||
# provider never exceeds this model's configured input ceiling.
|
||
model_config = resolved_app_config.get_model_config(model_name) if model_name else None
|
||
middlewares.append(
|
||
ContextWindowGuardMiddleware(
|
||
max_input_tokens=model_config.max_input_tokens if model_config is not None else None,
|
||
)
|
||
)
|
||
|
||
# ClarificationMiddleware should always be last
|
||
middlewares.append(ClarificationMiddleware())
|
||
return middlewares
|
||
|
||
|
||
def _build_setup_middlewares(
|
||
config: RunnableConfig,
|
||
model_name: str | None,
|
||
round_cap_middleware: AgentMiddleware,
|
||
*,
|
||
app_config: AppConfig,
|
||
) -> list[AgentMiddleware]:
|
||
"""Build a setup-agent chain while preserving clarification as the final gate."""
|
||
middlewares = _build_middlewares(
|
||
config,
|
||
model_name=model_name,
|
||
app_config=app_config,
|
||
)
|
||
clarification_middleware = middlewares.pop()
|
||
if not isinstance(clarification_middleware, ClarificationMiddleware):
|
||
raise RuntimeError("ClarificationMiddleware must be the final middleware")
|
||
middlewares.extend([round_cap_middleware, clarification_middleware])
|
||
return middlewares
|
||
|
||
|
||
def make_lead_agent(config: RunnableConfig):
|
||
"""LangGraph graph factory; keep the signature compatible with LangGraph Server."""
|
||
runtime_config = _get_runtime_config(config)
|
||
runtime_app_config = runtime_config.get("app_config")
|
||
return _make_lead_agent(config, app_config=runtime_app_config or get_app_config())
|
||
|
||
|
||
# The Gateway may build this synchronous graph in its dedicated initializer.
|
||
# It restores ContextVar changes and keeps MCP discovery on the request loop;
|
||
# graph execution/checkpoint operations still run in the original async task.
|
||
make_lead_agent._deerflow_background_build = True
|
||
|
||
|
||
def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||
# Lazy import to avoid circular dependency
|
||
from deerflow.tools import get_available_tools
|
||
from deerflow.tools.builtins import ask_clarification_tool, setup_agent, setup_report_structure, setup_writing
|
||
|
||
cfg = _get_runtime_config(config)
|
||
resolved_app_config = app_config
|
||
|
||
thinking_enabled = cfg.get("thinking_enabled", True)
|
||
# Hard "thinking off" switch for internal vLLM/Qwen models whose config only
|
||
# declares `supports_thinking: true` (no disable shape) — passes through to
|
||
# create_chat_model(force_disable_thinking=...) so enable_thinking=false is
|
||
# actually injected. Default off → no behavior change for normal chats.
|
||
thinking_force_disabled = bool(cfg.get("thinking_force_disabled", False))
|
||
# Roundtable runs opt in explicitly. Missing/false keeps the global switch.
|
||
write_file_compatibility = True if cfg.get("roundtable_write_file_compatibility") else None
|
||
reasoning_effort = cfg.get("reasoning_effort", None)
|
||
requested_model_name: str | None = cfg.get("model_name") or cfg.get("model")
|
||
is_plan_mode = cfg.get("is_plan_mode", False)
|
||
disable_tools = bool(cfg.get("disable_tools", False))
|
||
subagent_enabled = cfg.get("subagent_enabled", False)
|
||
knowledge_space_strict = bool(cfg.get("knowledge_space_strict", False))
|
||
knowledge_retrieval_mode = str(
|
||
cfg.get("knowledge_retrieval_mode") or "classic"
|
||
).strip().lower()
|
||
smart_knowledge_reasoning = knowledge_retrieval_mode == "smart_reasoning"
|
||
classic_knowledge_space_strict = knowledge_space_strict and not smart_knowledge_reasoning
|
||
if smart_knowledge_reasoning:
|
||
# Gateway validation owns this mode. Keep a harness-side fail-safe so
|
||
# internal callers cannot accidentally construct a smart run without
|
||
# its required read-only retrieval tools.
|
||
disable_tools = False
|
||
if classic_knowledge_space_strict:
|
||
subagent_enabled = False
|
||
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
||
is_bootstrap = cfg.get("is_bootstrap", False)
|
||
# 写作配置 Q&A:与 is_bootstrap 同构的另一种「最小化 bootstrap」——挂 setup_writing 工具、
|
||
# 走写作配置专用 prompt,只负责帮用户把写作表单聊清并提交,不真正开始写作。
|
||
is_writing_setup = cfg.get("is_writing_setup", False)
|
||
writing_setup_article_types = cfg.get("valid_article_types") if is_writing_setup else None
|
||
# 快捷模式:跳过逐项选项澄清,给标题直接出大纲(见 build_writing_setup_prompt)。
|
||
writing_setup_quick = bool(cfg.get("writing_setup_quick", False)) if is_writing_setup else False
|
||
writing_sample_context = cfg.get("writing_sample_context") if is_writing_setup else None
|
||
# 报告结构智能新增:与 is_writing_setup 同构的最小化 bootstrap。
|
||
is_report_structure_setup = cfg.get("is_report_structure_setup", False)
|
||
is_gongwen_template_setup = cfg.get("is_gongwen_template_setup", False)
|
||
report_structure_setup_skills = cfg.get("valid_retrieval_skills") if is_report_structure_setup else None
|
||
report_structure_setup_quick = bool(cfg.get("writing_setup_quick", False)) if (is_report_structure_setup or is_gongwen_template_setup) else False
|
||
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
|
||
# The Gateway owns this flag, but internal harness callers can still pass
|
||
# inconsistent config. Smart mode must never inherit the classic prompt
|
||
# that requires [1]/[2] references or a reference list.
|
||
rag_mode_enabled = bool(cfg.get("rag_mode_enabled", False)) and not smart_knowledge_reasoning
|
||
writing_mode = bool(cfg.get("writing_mode", False))
|
||
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
|
||
workbench_write_now = bool(cfg.get("workbench_write_now", False))
|
||
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
|
||
writing_artifact_path = None
|
||
# Workbench 「开始撰写」 always remounts the pipeline, even if this
|
||
# collector thread already has a leftover report.md from a prior write.
|
||
if workbench_write_now:
|
||
writing_mode = True
|
||
writing_artifact_path = None
|
||
# 只有写作模式产物 report.md 才走续写;其它 leftover .md 不得跳过管线。
|
||
if writing_artifact_path is not None:
|
||
from deerflow.tools.builtins.deep_research_report_tool import is_writing_mode_report_path
|
||
|
||
if not is_writing_mode_report_path(writing_artifact_path):
|
||
writing_artifact_path = None
|
||
agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name"))
|
||
requested_excluded_tools = _enforce_coordinator_only_tool_exclusions(
|
||
agent_id,
|
||
cfg.get("excluded_tools") or None,
|
||
)
|
||
raw_allowed_tools = cfg.get("allowed_tools")
|
||
if isinstance(raw_allowed_tools, str):
|
||
requested_allowed_tools: list[str] | None = (
|
||
[raw_allowed_tools] if raw_allowed_tools.strip() else []
|
||
)
|
||
elif isinstance(raw_allowed_tools, (list, tuple, set)):
|
||
requested_allowed_tools = [
|
||
str(name) for name in raw_allowed_tools if str(name).strip()
|
||
]
|
||
else:
|
||
requested_allowed_tools = None
|
||
if classic_knowledge_space_strict:
|
||
requested_excluded_tools = _knowledge_space_strict_exclusions(requested_excluded_tools)
|
||
if smart_knowledge_reasoning:
|
||
# Smart mode uses the WeKnora-compatible five-surface tool set and
|
||
# deliberately skips the legacy all-in-one search tool.
|
||
requested_excluded_tools = list(
|
||
dict.fromkeys(
|
||
[
|
||
*(
|
||
name
|
||
for name in (requested_excluded_tools or [])
|
||
if name not in _SMART_REASONING_TOOL_NAMES
|
||
),
|
||
*sorted(_CLASSIC_LLMWIKI_TOOL_NAMES),
|
||
]
|
||
)
|
||
)
|
||
else:
|
||
selected_ordinary = cfg.get("llmwiki_knowledge_base_ids")
|
||
selected_assistant = cfg.get("assistant_knowledge_base_ids")
|
||
classic_enabled_names: set[str] = set()
|
||
if isinstance(selected_ordinary, list) and selected_ordinary:
|
||
classic_enabled_names.update(_CLASSIC_LLMWIKI_TOOL_NAMES)
|
||
elif isinstance(selected_assistant, list) and selected_assistant:
|
||
classic_enabled_names.add("llmwiki_search")
|
||
requested_excluded_tools = list(
|
||
dict.fromkeys(
|
||
[
|
||
*(
|
||
name
|
||
for name in (requested_excluded_tools or [])
|
||
if name not in classic_enabled_names
|
||
),
|
||
*sorted(_SMART_REASONING_TOOL_NAMES),
|
||
*sorted(_CLASSIC_LLMWIKI_TOOL_NAMES - classic_enabled_names),
|
||
]
|
||
)
|
||
)
|
||
try:
|
||
agent_config = load_agent_config(agent_id) if not is_bootstrap and not is_writing_setup and not is_report_structure_setup and not is_gongwen_template_setup else None
|
||
except FileNotFoundError:
|
||
logger.warning("Agent directory not found for '%s'; running with default config", agent_id)
|
||
agent_config = None
|
||
agent_display_name = agent_config.name if agent_config else agent_id
|
||
# Custom agent model from agent config (if any), or None to let _resolve_model_name pick the default
|
||
agent_model_name = agent_config.model if agent_config and agent_config.model else None
|
||
# The global prompt suffix remains on unless this particular custom agent
|
||
# explicitly opts out. ``getattr`` preserves compatibility with callers
|
||
# that supply lightweight agent-config test doubles.
|
||
model_prompt_suffix_enabled = bool(
|
||
getattr(agent_config, "model_prompt_suffix_enabled", True)
|
||
) if agent_config else True
|
||
|
||
# Final model name resolution: request → agent config → global default, with fallback for unknown names
|
||
model_name = _resolve_model_name(requested_model_name or agent_model_name, app_config=resolved_app_config)
|
||
|
||
model_config = resolved_app_config.get_model_config(model_name)
|
||
|
||
if model_config is None:
|
||
raise ValueError("No chat model could be resolved. Please configure at least one model in config.yaml or provide a valid 'model_name'/'model' in the request.")
|
||
if thinking_enabled and not model_config.supports_thinking:
|
||
logger.warning(f"Thinking mode is enabled but model '{model_name}' does not support it; fallback to non-thinking mode.")
|
||
thinking_enabled = False
|
||
|
||
logger.info(
|
||
"Create Agent(%s) -> thinking_enabled: %s, reasoning_effort: %s, model_name: %s, is_plan_mode: %s, subagent_enabled: %s, max_concurrent_subagents: %s, writing_mode: %s",
|
||
agent_id or "default",
|
||
thinking_enabled,
|
||
reasoning_effort,
|
||
model_name,
|
||
is_plan_mode,
|
||
subagent_enabled,
|
||
max_concurrent_subagents,
|
||
writing_mode,
|
||
)
|
||
|
||
# Inject run metadata for LangSmith trace tagging
|
||
if "metadata" not in config:
|
||
config["metadata"] = {}
|
||
|
||
if is_bootstrap:
|
||
runtime_available_skills: list[str] | None = None # all enabled skills visible in bootstrap
|
||
elif agent_config:
|
||
# Custom agents must use an explicit allowlist. If omitted, default to none.
|
||
runtime_available_skills = list(agent_config.skills or [])
|
||
else:
|
||
# Default lead agent keeps existing behavior (all enabled skills).
|
||
runtime_available_skills = None
|
||
|
||
from deerflow.agents.deep_research.retrieval import (
|
||
resolve_collector_skill_visibility,
|
||
runtime_extra_skills,
|
||
)
|
||
|
||
# 深度研究收集员不吃 config.yaml 技能白名单——像普通问答(默认 lead
|
||
# agent)一样全量可见、想用什么技能就用什么;报告结构「检索来源」
|
||
# (extra_skills)是用户当场显式指定的,仍会在解析中收窄。
|
||
runtime_available_skills = resolve_collector_skill_visibility(
|
||
agent_id, runtime_available_skills, runtime_extra_skills(cfg)
|
||
)
|
||
runtime_available_skills = _filter_available_skills_for_excluded_tools(
|
||
runtime_available_skills,
|
||
requested_excluded_tools,
|
||
)
|
||
prompt_available_skills = set(runtime_available_skills) if runtime_available_skills is not None else None
|
||
|
||
config["metadata"].update(
|
||
{
|
||
"agent_id": agent_id,
|
||
"agent_name": agent_display_name or "default",
|
||
"model_name": model_name or "default",
|
||
"model_prompt_suffix_enabled": model_prompt_suffix_enabled,
|
||
"thinking_enabled": thinking_enabled,
|
||
"reasoning_effort": reasoning_effort,
|
||
"is_plan_mode": is_plan_mode,
|
||
"subagent_enabled": subagent_enabled,
|
||
"writing_mode": writing_mode,
|
||
"writing_artifact_path": writing_artifact_path,
|
||
"knowledge_space_strict": classic_knowledge_space_strict,
|
||
"knowledge_retrieval_mode": knowledge_retrieval_mode,
|
||
"tool_groups": agent_config.tool_groups if agent_config else None,
|
||
"available_skills": runtime_available_skills,
|
||
}
|
||
)
|
||
|
||
if is_writing_setup:
|
||
# 写作配置 Q&A:最小化 agent —— 只挂 setup_writing(提交配置)+ ask_clarification(弹澄清卡)。
|
||
# 关键:绝不给全套工具、绝不复用 lead 超级体提示,否则模型会把「新能源汽车市场分析」之类
|
||
# 长得像任务的输入直接做掉,而不是转成写作配置提交。
|
||
# 在 ClarificationMiddleware 前挂 WritingSetupRoundCapMiddleware:到顶用 tool_choice 强制提交配置卡。
|
||
# 快捷模式:用默认选项、不逐项澄清提问(但仍生成并确认大纲)——直接**不挂** ask_clarification,
|
||
# 让模型物理上问不了澄清问题,比只靠提示词约束更可靠。
|
||
from deerflow.agents.lead_agent.prompt import build_writing_setup_prompt
|
||
from deerflow.agents.middlewares.writing_setup_round_cap_middleware import WritingSetupRoundCapMiddleware
|
||
|
||
setup_tools = [setup_writing] if writing_setup_quick else [setup_writing, ask_clarification_tool]
|
||
return create_agent(
|
||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, write_file_compatibility=write_file_compatibility, app_config=resolved_app_config),
|
||
tools=setup_tools,
|
||
middleware=_build_setup_middlewares(
|
||
config,
|
||
model_name,
|
||
WritingSetupRoundCapMiddleware(max_rounds=WRITING_SETUP_MAX_ROUNDS),
|
||
app_config=resolved_app_config,
|
||
),
|
||
system_prompt=build_writing_setup_prompt(
|
||
writing_setup_article_types,
|
||
quick_mode=writing_setup_quick,
|
||
sample_context=writing_sample_context,
|
||
),
|
||
state_schema=ThreadState,
|
||
)
|
||
|
||
if is_report_structure_setup or is_gongwen_template_setup:
|
||
from deerflow.agents.lead_agent.prompt import (
|
||
build_gongwen_template_setup_prompt,
|
||
build_report_structure_setup_prompt,
|
||
)
|
||
from deerflow.agents.middlewares.writing_setup_round_cap_middleware import WritingSetupRoundCapMiddleware
|
||
|
||
setup_tools = (
|
||
[setup_report_structure]
|
||
if report_structure_setup_quick
|
||
else [setup_report_structure, ask_clarification_tool]
|
||
)
|
||
setup_prompt = (
|
||
build_gongwen_template_setup_prompt(quick_mode=report_structure_setup_quick)
|
||
if is_gongwen_template_setup
|
||
else build_report_structure_setup_prompt(
|
||
report_structure_setup_skills,
|
||
quick_mode=report_structure_setup_quick,
|
||
)
|
||
)
|
||
return create_agent(
|
||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, write_file_compatibility=write_file_compatibility, app_config=resolved_app_config),
|
||
tools=setup_tools,
|
||
middleware=_build_setup_middlewares(
|
||
config,
|
||
model_name,
|
||
WritingSetupRoundCapMiddleware(
|
||
max_rounds=REPORT_STRUCTURE_SETUP_MAX_ROUNDS,
|
||
tool_name="setup_report_structure",
|
||
force_instruction=(
|
||
f"【系统强制】意图澄清已达 {REPORT_STRUCTURE_SETUP_MAX_ROUNDS} 轮上限。"
|
||
"现在必须立即调用 setup_report_structure 提交报告结构配置,禁止再向用户提任何问题。"
|
||
"缺失或不确定的字段一律用合理默认值:"
|
||
"title 根据对话主题拟一个短标题、name 用一句话描述、structure_mode 默认 adaptive、"
|
||
"retrieval_directions / retrieval_skills 不确定就留空、content 必须给出一份 Markdown 大纲。"
|
||
),
|
||
),
|
||
app_config=resolved_app_config,
|
||
),
|
||
system_prompt=setup_prompt,
|
||
state_schema=ThreadState,
|
||
)
|
||
|
||
if is_bootstrap:
|
||
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
|
||
return create_agent(
|
||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, write_file_compatibility=write_file_compatibility, app_config=resolved_app_config),
|
||
tools=get_available_tools(
|
||
include_mcp=not classic_knowledge_space_strict,
|
||
model_name=model_name,
|
||
subagent_enabled=subagent_enabled,
|
||
is_scheduled_run=cfg.get("is_scheduled_run", False),
|
||
excluded_tools=requested_excluded_tools,
|
||
allowed_tools=requested_allowed_tools,
|
||
allow_agent_orchestration=agent_id in _COORDINATOR_AGENT_IDS,
|
||
app_config=resolved_app_config,
|
||
write_file_compatibility=write_file_compatibility,
|
||
)
|
||
+ [setup_agent],
|
||
middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
|
||
system_prompt=apply_prompt_template(
|
||
subagent_enabled=subagent_enabled,
|
||
max_concurrent_subagents=max_concurrent_subagents,
|
||
writing_mode=writing_mode,
|
||
writing_artifact_path=writing_artifact_path,
|
||
available_skills=None,
|
||
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)
|
||
is_notebook_mode = bool(cfg.get("notebook_search_space_id"))
|
||
notebook_source_ids: list[int] = cfg.get("notebook_source_ids") or []
|
||
default_tools = (
|
||
[]
|
||
if disable_tools
|
||
else get_available_tools(
|
||
include_mcp=not classic_knowledge_space_strict,
|
||
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,
|
||
allowed_tools=requested_allowed_tools,
|
||
allow_agent_orchestration=agent_id in _COORDINATOR_AGENT_IDS,
|
||
app_config=resolved_app_config,
|
||
write_file_compatibility=write_file_compatibility,
|
||
)
|
||
)
|
||
if agent_id == FORCED_RESEARCH_AGENT_ID:
|
||
default_tools = filter_forced_research_tools(default_tools)
|
||
if is_notebook_mode:
|
||
from deerflow.tools.builtins.notebook_search_tool import notebook_search_tool
|
||
|
||
default_tools = [notebook_search_tool] + default_tools
|
||
# 写作模式首轮(尚无 report.md):挂深度研究报告工具,报告由管线生成而非
|
||
# 模型手工 write_file。续写轮(已有 report.md)不挂,保持原有「直接编辑
|
||
# 该文件」行为。不受 tool_groups/excluded_tools 过滤——它是写作模式合约
|
||
# 的核心交付手段。
|
||
# Workbench delivery writes only from collected materials; it does not
|
||
# expand the selected knowledge scope or enable external search tools.
|
||
workbench_report_delivery = workbench_write_now and bool(cfg.get("workbench_session_id"))
|
||
if writing_mode and not writing_artifact_path and (
|
||
not classic_knowledge_space_strict or workbench_report_delivery
|
||
):
|
||
from deerflow.tools.builtins.deep_research_report_tool import deep_research_report_tool
|
||
|
||
default_tools = [*default_tools, deep_research_report_tool]
|
||
logger.info(
|
||
"Writing mode first report turn: deep_research_report tool mounted for agent=%s",
|
||
agent_id,
|
||
)
|
||
|
||
# Custom agents are steered ONLY by their own SOUL.md — no generic
|
||
# super-agent system template and no personal memory injection. We branch to
|
||
# the agent-only prompt builder whenever a custom agent config carries a
|
||
# non-empty SOUL.md. The plain default lead agent (no agent_config) and
|
||
# notebook mode keep the full template. See apply_agent_only_prompt_template.
|
||
has_agent_soul = bool(agent_config) and bool(load_agent_soul(agent_id))
|
||
use_agent_only_prompt = has_agent_soul and not is_notebook_mode
|
||
|
||
if use_agent_only_prompt:
|
||
from deerflow.agents.lead_agent.prompt import apply_agent_only_prompt_template
|
||
|
||
system_prompt = apply_agent_only_prompt_template(
|
||
agent_id=agent_id,
|
||
agent_name=agent_display_name,
|
||
subagent_enabled=subagent_enabled,
|
||
max_concurrent_subagents=max_concurrent_subagents,
|
||
available_skills=prompt_available_skills,
|
||
app_config=resolved_app_config,
|
||
rag_mode_enabled=rag_mode_enabled,
|
||
writing_mode=writing_mode,
|
||
writing_artifact_path=writing_artifact_path,
|
||
)
|
||
else:
|
||
system_prompt = apply_prompt_template(
|
||
subagent_enabled=subagent_enabled,
|
||
max_concurrent_subagents=max_concurrent_subagents,
|
||
agent_id=agent_id,
|
||
agent_name=agent_display_name,
|
||
writing_mode=writing_mode,
|
||
writing_artifact_path=writing_artifact_path,
|
||
available_skills=prompt_available_skills,
|
||
app_config=resolved_app_config,
|
||
memory_injection_enabled=memory_injection_enabled,
|
||
# Smart ordinary-KB mode has its own authorized database scope.
|
||
# The legacy /mnt/knowledge/routing.md provider points at an
|
||
# unrelated mounted file tree and can misresolve “这个库”. Suppress
|
||
# only that static block; personal memory and Hindsight remain on.
|
||
include_legacy_knowledge_routing=not smart_knowledge_reasoning,
|
||
rag_mode_enabled=rag_mode_enabled,
|
||
is_notebook_mode=is_notebook_mode,
|
||
notebook_source_ids=notebook_source_ids,
|
||
is_scheduled_run=bool(cfg.get("is_scheduled_run", False)),
|
||
)
|
||
|
||
if smart_knowledge_reasoning:
|
||
# The base lead prompt contains a web-research citation/Sources
|
||
# contract and an optional numbered RAG block. Neither contract
|
||
# applies to the WeKnora-style smart knowledge loop.
|
||
system_prompt = _without_classic_citation_contracts(system_prompt)
|
||
|
||
# 前端可提供当前视觉明暗模式给页面生成类任务。把值放入系统提示而不是
|
||
# 指望模型从 ToolRuntime.context 猜测,确保自定义 SOUL 也能稳定使用。
|
||
presentation_theme = str(cfg.get("presentation_theme") or "").strip().lower()
|
||
if presentation_theme in {"dark", "light"}:
|
||
system_prompt += (
|
||
"\n\n【当前界面主题】\n"
|
||
f"当前页面呈现主题为 `{presentation_theme}`。若本轮生成汇报页面,请优先选择同色系 HTML 模板;"
|
||
"除非用户明确要求覆盖该主题。"
|
||
)
|
||
|
||
system_prompt = _append_orchestration_capability_boundary(system_prompt, agent_id)
|
||
|
||
if knowledge_space_strict and smart_knowledge_reasoning:
|
||
system_prompt += _SMART_REASONING_KNOWLEDGE_SCOPE_PROMPT
|
||
elif knowledge_space_strict:
|
||
system_prompt += (
|
||
"\n\n<knowledge_space_scope>\n"
|
||
"本轮用户已选择知识空间。知识库选择只限定具体事实的检索来源,不关闭当前智能体已配置的技能,"
|
||
"也不关闭计算、文件处理、制图和内容生成等非外部检索能力。先检查系统已注入的预检索证据;证据不足时"
|
||
"再根据缺口调用 llmwiki_search。选择多个普通知识库时优先按 knowledge_base_id 分库检索,避免一个库的结果掩盖另一个库。用户询问库的范围、"
|
||
"目录或主题概览时,可调用 llmwiki_browse_wiki_index;搜索命中只给出片段而回答需要全文、关系或上下文时,"
|
||
"再调用 llmwiki_read_wiki_page 读取相关页面。只读取能填补当前证据缺口的不同页面,不得重复相同页面或"
|
||
"游标。原始分片与 Wiki 冲突时以可定位的原始材料为准。\n"
|
||
"技能与知识库应协同使用:知识库先提供专属事实和背景,技能负责分析、计算、转换、制图或生成交付物;"
|
||
"写文件和展示成品应在必要证据收集完成后进行。可以使用稳定的通用知识解释背景、术语和因果关系,但要"
|
||
"明确区分库内事实、基于材料的推断和一般性补充,不得用模型记忆编造知识库专属日期、数字、专名或内部事实。"
|
||
"禁止使用 Web、MCP、外部检索技能或子智能体替代所选知识库检索。若所选空间证据不足,必须明确说明。\n"
|
||
"</knowledge_space_scope>"
|
||
)
|
||
|
||
from deerflow.agents.analysis_project import project_system_prompt
|
||
|
||
system_prompt += project_system_prompt(cfg)
|
||
|
||
if cfg.get("knowledge_research_session_id") and disable_tools:
|
||
from deerflow.agents.lead_agent.knowledge_research_prompt import KNOWLEDGE_RESEARCH_PROMPT
|
||
|
||
# Research is an inline document editor. Generic file/tool contracts
|
||
# contradict this server-enforced tool-free mode.
|
||
system_prompt = KNOWLEDGE_RESEARCH_PROMPT
|
||
|
||
|
||
_log_es_query_routing_registration(
|
||
system_prompt=system_prompt,
|
||
app_config=resolved_app_config,
|
||
agent_id=agent_id,
|
||
available_skills=prompt_available_skills,
|
||
)
|
||
|
||
return create_agent(
|
||
model=create_chat_model(
|
||
name=model_name,
|
||
thinking_enabled=thinking_enabled,
|
||
reasoning_effort=reasoning_effort,
|
||
force_disable_thinking=thinking_force_disabled,
|
||
write_file_compatibility=write_file_compatibility,
|
||
prompt_suffix_enabled=model_prompt_suffix_enabled,
|
||
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=system_prompt,
|
||
state_schema=ThreadState,
|
||
)
|