Files

1290 lines
64 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
)