deerflow-code/offline-backend-20260512/backend/packages/harness/deerflow/agents/deep_research/config.py
2026-09-07 18:24:55 +08:00

231 lines
9.9 KiB
Python

"""Deep Research configuration — frozen per-task snapshot.
Every deep-research job captures the *full* ``DeepResearchConfig`` as an immutable
``config_snapshot`` at creation time; the running job reads **only** that snapshot
and never consults mutable global config mid-run (prevents mid-flight setting
changes from corrupting a long research session).
Server-side clamps (``clamp()``) bound every numeric field regardless of what the
client sent — client-side limits are convenience only.
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator
# ── enums (kept as plain Literal unions so the model is JSON-serialisable) ──
ResearchMode = Literal["quick", "basic", "detailed", "deep", "multi_agent"]
ResearchTone = Literal["objective", "formal", "analytical", "persuasive"]
ReportFormat = Literal["markdown", "apa", "mla", "chicago"]
ModelRole = Literal["fast", "smart", "strategic"]
MaterialChannel = Literal["web_search", "knowledge", "skill", "mcp", "file", "other"]
SourceType = Literal["web", "knowledge", "skill", "mcp", "file", "other"]
# How the collecting stage runs. ``chat`` (the default) means the frontend
# drives a real collector-agent conversation on the session's runtime thread
# and the writing job harvests materials from that thread; ``legacy`` keeps the
# runner-driven skill/web tiers searching on their own.
CollectionMode = Literal["chat", "legacy"]
# How the writer treats ``custom_outline``.
# ``fixed`` (固定大纲) reproduces the stored headings and hierarchy.
# ``adaptive`` (弹性大纲) keeps the current material-driven layout.
StructureMode = Literal["fixed", "adaptive"]
# ── server-side bounds (§4.3 / §19.4) ────────────────────────────────────────
# Centralised so the router, the executor, and tests all share one truth source.
_MAX_SEARCH_RESULTS_PER_QUERY = 20
_MAX_ITERATIONS = 10
_MAX_SUBTOPICS = 20
_MAX_DEEP_BREADTH = 8
_MAX_DEEP_DEPTH = 4
_MAX_CONCURRENCY = 8
_MAX_CONTEXT_WORDS = 120_000
_MAX_REVISION_LOOPS = 5
MAX_GENERATED_IMAGES = 4
_MAX_CUSTOM_OUTLINE_CHARS = 4_000
_MAX_REPORT_INSTRUCTION_CHARS = 4_000
_MAX_CONTROL_PLANE_TIMEOUT_SECONDS = 120.0
# Per-user active job ceiling (§19.4 / §26).
DEFAULT_MAX_ACTIVE_JOBS_PER_USER = 3
class DeepResearchConfig(BaseModel):
"""Per-task research configuration.
Field names mirror the upstream GPT Researcher knobs but use DeerFlow
conventions (``snake_case``, explicit clamps, no ``os.environ`` reads).
"""
mode: ResearchMode = "basic"
collection_mode: CollectionMode = "chat"
language: str = "zh-CN"
tone: ResearchTone = "objective"
report_format: ReportFormat = "markdown"
# Model role overrides; ``None`` → fall back to the system default model.
fast_model: str | None = None
smart_model: str | None = None
strategic_model: str | None = None
thinking_enabled: bool = False
# Research budget knobs (client may request, server clamps).
max_search_results_per_query: int = Field(default=8, ge=1)
max_iterations: int = Field(default=3, ge=1)
max_subtopics: int = Field(default=8, ge=1)
deep_breadth: int = Field(default=4, ge=1)
deep_depth: int = Field(default=2, ge=1)
max_concurrency: int = Field(default=3, ge=1)
max_context_words: int = Field(default=24_000, ge=500)
# A provider may retain a hung HTTP request for several minutes. Planning,
# query generation, and review calls must fail in a bounded time so the
# durable job can either fall back to the topic or finish with a real error.
# Report prose deliberately uses ``stream_complete`` and is not subject to
# this total-duration cap.
control_plane_timeout_seconds: float = Field(default=45.0, ge=0.1)
# Editorial / quality knobs.
curate_sources: bool = True
generate_images: bool = False
max_generated_images: int = Field(default=2, ge=1)
include_human_feedback: bool = False
max_revision_loops: int = Field(default=2, ge=0)
# Material channels — only admin-configured tool groups are accepted
# (router validates against capabilities; ``other`` reserved for future).
allowed_material_channels: list[MaterialChannel] = Field(default_factory=lambda: ["web_search"])
allowed_domains: list[str] = Field(default_factory=list)
# Optional user-supplied outline (sanitised; never overrides system safety
# prompts in the vendored writer).
custom_outline: str | None = Field(default=None, max_length=_MAX_CUSTOM_OUTLINE_CHARS)
# Optional write-time requirements confirmed by the user. This is kept
# separate from ``custom_outline`` so prose/style requirements can never be
# misread as extra chapter headings by detailed runners.
report_instruction: str | None = Field(default=None, max_length=_MAX_REPORT_INSTRUCTION_CHARS)
# Writing constraint copied from the selected report structure (结构约束).
# Default ``adaptive`` preserves today's material-driven layout.
structure_mode: StructureMode = "adaptive"
# Optional search facets copied from the selected report structure (检索方向).
# Empty → unconstrained query-planning. Non-empty → split retrievable terms
# along those facets; never treat the labels themselves as search queries.
retrieval_directions: list[str] = Field(default_factory=list)
# Optional skill names copied from the selected report structure (检索来源).
# Empty → collector / material provider keep today's default sources.
retrieval_skills: list[str] = Field(default_factory=list)
model_config = {"extra": "forbid"}
# ── server-side clamping ────────────────────────────────────────────────
def clamp(self) -> DeepResearchConfig:
"""Return a copy with every numeric field bounded to its server ceiling.
Call this at job-creation time so the persisted snapshot is already safe;
the executor can then trust ``config_snapshot`` unconditionally.
"""
return self.model_copy(
update={
"max_search_results_per_query": min(
self.max_search_results_per_query, _MAX_SEARCH_RESULTS_PER_QUERY
),
"max_iterations": min(self.max_iterations, _MAX_ITERATIONS),
"max_subtopics": min(self.max_subtopics, _MAX_SUBTOPICS),
"deep_breadth": min(self.deep_breadth, _MAX_DEEP_BREADTH),
"deep_depth": min(self.deep_depth, _MAX_DEEP_DEPTH),
"max_concurrency": min(self.max_concurrency, _MAX_CONCURRENCY),
"max_context_words": min(self.max_context_words, _MAX_CONTEXT_WORDS),
"control_plane_timeout_seconds": min(
self.control_plane_timeout_seconds, _MAX_CONTROL_PLANE_TIMEOUT_SECONDS
),
"max_revision_loops": min(self.max_revision_loops, _MAX_REVISION_LOOPS),
"max_generated_images": min(self.max_generated_images, MAX_GENERATED_IMAGES),
}
)
@field_validator("allowed_material_channels")
@classmethod
def _dedupe_channels(cls, v: list[MaterialChannel]) -> list[MaterialChannel]:
# Preserve order, drop duplicates.
seen: set[str] = set()
out: list[MaterialChannel] = []
for ch in v:
if ch not in seen:
seen.add(ch)
out.append(ch)
return out
@field_validator("custom_outline", mode="before")
@classmethod
def _sanitize_custom_outline(cls, value: Any) -> str | None:
"""Keep a bounded user outline as content, never as a system prompt.
Newlines are retained because Markdown/numbered headings are useful to
report planning; NUL/control separators are removed so the snapshot is
portable across the database, events and artifact JSON.
"""
if value is None:
return None
cleaned = str(value).replace("\x00", "").replace("\r", "").strip()
return cleaned or None
@field_validator("report_instruction", mode="before")
@classmethod
def _sanitize_report_instruction(cls, value: Any) -> str | None:
if value is None:
return None
cleaned = str(value).replace("\x00", "").replace("\r", "").strip()
return cleaned or None
@field_validator("structure_mode", mode="before")
@classmethod
def _normalize_structure_mode(cls, value: Any) -> StructureMode:
raw = str(value or "").strip().lower()
if raw in {"fixed", "canonical", "prescriptive", "rigid"}:
return "fixed"
return "adaptive"
@field_validator("retrieval_directions", mode="before")
@classmethod
def _normalize_retrieval_directions(cls, value: Any) -> list[str]:
from deerflow.persistence.report_structures.directions import normalize_retrieval_directions
return normalize_retrieval_directions(value)
@field_validator("retrieval_skills", mode="before")
@classmethod
def _normalize_retrieval_skills(cls, value: Any) -> list[str]:
from deerflow.persistence.report_structures.skills import normalize_retrieval_skills
return normalize_retrieval_skills(value)
def resolve_model_name(self, role: ModelRole) -> str | None:
"""Return the configured model name for a role, or ``None`` for system default."""
return {
"fast": self.fast_model,
"smart": self.smart_model,
"strategic": self.strategic_model,
}[role]
# Convenience: the full set of supported modes, for capability discovery.
ALL_MODES: tuple[ResearchMode, ...] = (
"quick",
"basic",
"detailed",
"deep",
"multi_agent",
)
def config_from_dict(data: dict[str, Any]) -> DeepResearchConfig:
"""Build + clamp a config from a raw dict (e.g. JSON column / request body)."""
return DeepResearchConfig.model_validate(data).clamp()