871 lines
43 KiB
Python
871 lines
43 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.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.memory_middleware import MemoryMiddleware
|
||
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
|
||
from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware
|
||
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
|
||
from deerflow.agents.middlewares.setup_report_structure_approval_middleware import SetupReportStructureApprovalMiddleware
|
||
from deerflow.agents.middlewares.setup_writing_approval_middleware import SetupWritingApprovalMiddleware
|
||
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.models import create_chat_model
|
||
|
||
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"
|
||
|
||
_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 == _ROUNDTABLE_COORDINATOR_AGENT_ID:
|
||
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 == _ROUNDTABLE_COORDINATOR_AGENT_ID:
|
||
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]
|
||
|
||
|
||
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 _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, app_config=resolved_app_config)
|
||
else:
|
||
model = create_chat_model(thinking_enabled=False, app_config=resolved_app_config)
|
||
model = model.with_config(tags=["middleware:summarize"])
|
||
|
||
# Prepare kwargs
|
||
kwargs = {
|
||
"model": model,
|
||
"trigger": trigger,
|
||
"keep": keep,
|
||
}
|
||
|
||
if config.trim_tokens_to_summarize is not None:
|
||
kwargs["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,
|
||
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_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())
|
||
if cfg.get("rag_mode_enabled", False):
|
||
middlewares.append(RagCitationMiddleware())
|
||
|
||
# Add summarization middleware. The on/off switch now lives in the chat UI
|
||
# (上下文压缩 toggle, default off) and arrives as ``summarization_enabled`` in
|
||
# the runtime config; the config file only supplies tuning. force_enabled
|
||
# lets the per-request toggle turn it on even when config.enabled is false,
|
||
# while non-UI paths (channels/scheduled) still fall back to config.enabled.
|
||
summarization_requested = cfg.get("summarization_enabled", False)
|
||
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config, force_enabled=summarization_requested)
|
||
if summarization_middleware is not None:
|
||
middlewares.append(summarization_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)
|
||
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
|
||
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
|
||
if not is_bootstrap and not is_writing_setup and not is_report_structure_setup and not is_ephemeral:
|
||
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, 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 is_bootstrap and not is_writing_setup and not is_report_structure_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())
|
||
|
||
# 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
|
||
if writing_mode:
|
||
from deerflow.tools.builtins.deep_research_report_tool import is_writing_mode_report_path
|
||
|
||
if 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())
|
||
|
||
# LoopDetectionMiddleware — detect and break repetitive tool call loops
|
||
middlewares.append(LoopDetectionMiddleware())
|
||
|
||
# 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)
|
||
|
||
# ClarificationMiddleware should always be last
|
||
middlewares.append(ClarificationMiddleware())
|
||
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())
|
||
|
||
|
||
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))
|
||
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)
|
||
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)
|
||
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 else False
|
||
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
|
||
rag_mode_enabled = bool(cfg.get("rag_mode_enabled", False))
|
||
writing_mode = bool(cfg.get("writing_mode", False))
|
||
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
|
||
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
|
||
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,
|
||
)
|
||
|
||
try:
|
||
agent_config = load_agent_config(agent_id) if not is_bootstrap and not is_writing_setup and not is_report_structure_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
|
||
|
||
# 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 merge_collector_skill_allowlist, runtime_extra_skills
|
||
|
||
runtime_available_skills = merge_collector_skill_allowlist(
|
||
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",
|
||
"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,
|
||
"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 超级体提示,否则模型会把「新能源汽车市场分析」之类
|
||
# 长得像任务的输入直接做掉,而不是转成写作配置提交。
|
||
# 末尾追加 WritingSetupRoundCapMiddleware:澄清最多 3 轮,到顶用 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, app_config=resolved_app_config),
|
||
tools=setup_tools,
|
||
middleware=[
|
||
*_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
|
||
WritingSetupRoundCapMiddleware(max_rounds=WRITING_SETUP_MAX_ROUNDS),
|
||
],
|
||
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:
|
||
from deerflow.agents.lead_agent.prompt import 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]
|
||
)
|
||
return create_agent(
|
||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
|
||
tools=setup_tools,
|
||
middleware=[
|
||
*_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
|
||
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 大纲。"
|
||
),
|
||
),
|
||
],
|
||
system_prompt=build_report_structure_setup_prompt(
|
||
report_structure_setup_skills,
|
||
quick_mode=report_structure_setup_quick,
|
||
),
|
||
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, app_config=resolved_app_config),
|
||
tools=get_available_tools(
|
||
model_name=model_name,
|
||
subagent_enabled=subagent_enabled,
|
||
is_scheduled_run=cfg.get("is_scheduled_run", False),
|
||
excluded_tools=requested_excluded_tools,
|
||
allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID,
|
||
app_config=resolved_app_config,
|
||
)
|
||
+ [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(
|
||
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,
|
||
allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID,
|
||
app_config=resolved_app_config,
|
||
)
|
||
)
|
||
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 过滤——它是写作模式合约
|
||
# 的核心交付手段。
|
||
if writing_mode and not writing_artifact_path:
|
||
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,
|
||
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)),
|
||
)
|
||
|
||
# 前端可提供当前视觉明暗模式给页面生成类任务。把值放入系统提示而不是
|
||
# 指望模型从 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)
|
||
|
||
_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, 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,
|
||
)
|