Merge branch 'master-nj' into 'master'

增强 Deep Research 可靠性,并接入 8BF 任务卡片与会商上下文。

See merge request knowledge-base-bj/deerflow-code!25
This commit is contained in:
董宏凯 committed 2026-08-18 09:55:17 +00:00
commit 33b0af333c
81 files changed
+8436 -1076

No files matched your search

@@ -0,0 +1,609 @@
# Offline backend template for DeerFlow
config_version: 8
log_level: info
# Gateway FastAPI 监听端口。圆桌三路由(intent / recommend / multi_agent)的同进程
# loopback 调用会拼 http://127.0.0.1:<port> 打到本机其它 endpoint,所以必须与
# 实际监听端口一致(scripts/start-all.sh、Makefile 默认 8001)。
# 部署机改了端口请同步改这里;也可设环境变量 DEER_FLOW_GATEWAY_PORT 临时覆盖。
gateway:
port: 8001
# Optional Redis-backed global concurrency coordination.
# Leave disabled to keep the existing in-process behavior.
# When enabled, Redis operations are short-timeout and fail-open by default:
# if Redis is slow or unavailable, user Q&A continues with local-only limits.
concurrency:
redis:
enabled: false
url: redis://127.0.0.1:6379/0
password: null
# Prefix with the deployment/system id because Redis may be shared by
# multiple projects. Keep this unique per system to avoid cross-project
# leases affecting each other.
namespace: cmzs_deerflow:concurrency
socket_connect_timeout_ms: 80
socket_timeout_ms: 80
acquire_timeout_ms: 120
operation_timeout_ms: 80
circuit_breaker_failures: 3
circuit_breaker_cooldown_seconds: 30
lease_ttl_seconds: 90
heartbeat_interval_seconds: 20
fail_open: true
models:
- name: deepseek-chat
display_name: DeepSeek Chat
use: langchain_openai:ChatOpenAI
model: deepseek-chat
api_key: $DEEPSEEK_CHAT_API_KEY
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: false
supports_thinking: true
- name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI
model: zai-org/GLM-5-FP8
api_key: $GLM5_FP8_API_KEY
base_url: https://api.us-west-2.modal.direct/v2
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
supports_reasoning_effort: false
- name: deepseek-ai/DeepSeek-V4-Flash
display_name: deepseek-ai/DeepSeek-V4-Flash
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-V4-Flash
api_key: $SILICONFLOW_API_KEY
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
when_thinking_disabled:
extra_body:
enable_thinking: false
- name: inclusionAI/Ling-flash-2.0
display_name: inclusionAI/Ling-flash-2.0
use: langchain_openai:ChatOpenAI
model: inclusionAI/Ling-flash-2.0
api_key: $SILICONFLOW_API_KEY
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-ai/DeepSeek-V4-Pro
display_name: deepseek-ai/DeepSeek-V4-Pro
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-V4-Pro
api_key: $SILICONFLOW_API_KEY
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: qwen3.6-flash
display_name: qwen3.6-flash
use: langchain_openai:ChatOpenAI
model: qwen3.6-flash
api_key: sk-sp-H.XDIPL.Zgvs.MEQCIBasaS2RPHwgNUpGVfFJeRaTp5UgmZ4Y2rCVGNtpwfD1AiBTFg3S-uE_gqVDnHgdyEhDwcfaVRLi92xviye80AZnkg
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
request_timeout: 30.0
max_retries: 2
stream_usage: true
supports_thinking: true
supports_reasoning_effort: false
supports_vision: false
- name: qwen3.6
display_name: qwen3.6
use: langchain_openai:ChatOpenAI
model: qwen3.6
api_key: sk-6AqP_UYpPYJKdclYWR-FSA
base_url: http://119.254.86.251:8001/v1
request_timeout: 30.0
max_retries: 2
stream_usage: true
supports_thinking: false
supports_reasoning_effort: false
supports_vision: false
# - name: deepseek-v4-flash
# display_name: DeepSeek V4 Flash
# use: langchain_openai:ChatOpenAI
# model: deepseek-v4-flash
# api_key: sk-3fee562f834a4c08bb0d471ef44cb445
# base_url: https://api.deepseek.com
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: false
# supports_thinking: true
# - name: deepseek-v4-pro
# display_name: DeepSeek V4 Pro
# use: langchain_openai:ChatOpenAI
# model: deepseek-v4-pro
# api_key: sk-e1c22b7931dc446f8b67e57878edd105
# base_url: https://api.deepseek.com
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: false
# supports_thinking: true
# - name: MiniMax-M2.7
# display_name: MiniMax-M2.7
# use: langchain_openai:ChatOpenAI
# model: MiniMax-M2.7
# api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
# base_url: https://api.minimaxi.com/v1
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# - name: glm-5.1
# display_name: glm-5.1
# use: langchain_openai:ChatOpenAI
# model: glm-5.1
# api_key: 75121e4bed7640d5b71ac95cf8b4310a.RA3GyRBAR3kv7VrR
# base_url: https://open.bigmodel.cn/api/paas/v4/
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# - name: deepseek-ai/DeepSeek-OCR
# display_name: deepseek-ai/DeepSeek-OCR
# use: langchain_openai:ChatOpenAI
# model: deepseek-ai/DeepSeek-OCR
# api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
# base_url: https://api.siliconflow.cn/v1/
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# - name: zai-org/GLM-5-FP8
# display_name: zai-org/GLM-5-FP8
# use: langchain_openai:ChatOpenAI
# model: zai-org/GLM-5-FP8
# api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
# base_url: https://api.us-west-2.modal.direct/v1
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# - name: Qwen/Qwen3.6-27B
# display_name: Qwen/Qwen3.6-27B
# use: langchain_openai:ChatOpenAI
# model: Qwen/Qwen3.6-27B
# api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
# base_url: https://api.siliconflow.cn/v1/
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# SiliconFlow 混合推理模型关思考用「请求体顶层 enable_thinking: false」,
# 不认 chat_template_kwargs / thinking.type 那两种嵌套形状。声明这个后,
# 网页端「关闭思考」对本模型才生效(开放接口另由 force_disable_thinking 兜底)。
# 识图(图片识别)配置。
# - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
# 视觉,也能“看图”。请改成你实际用于识图的模型名。
# - 若留空 / 注释掉 model_name,则回退到主对话模型自身的视觉能力(需该模型 supports_vision: true)。
# - prompt:不带具体问题时使用的默认识图指令(可选)。
# - max_tokens:识图模型输出上限(可选,默认用该模型自身的配置)。
vision:
model_name: deepseek-ai/DeepSeek-OCR
# prompt: 请详细描述这张图片的内容。
# max_tokens: 2048
# 深度研究可选的报告配图。默认关闭;仅在部署方配置文生图模型后,前端才会展示开关。
# 使用 OpenAI 兼容的 POST {base_url}/images/generations 协议,并强制要求 b64_json
# 返回,以便将图片保存为当前研究会话的受鉴权产物,而不是引用会过期的公网 URL。
deep_research:
image_generation:
enabled: true
provider: openai_compatible
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation # 例如 https://api.openai.com/v1
api_key: sk-sp-H.XDIPL.Zgvs.MEQCIBasaS2RPHwgNUpGVfFJeRaTp5UgmZ4Y2rCVGNtpwfD1AiBTFg3S-uE_gqVDnHgdyEhDwcfaVRLi92xviye80AZnkg # 生产环境建议填 $DEEP_RESEARCH_IMAGE_API_KEY
model: wan2.7-image # 例如 gpt-image-1
size: 1024x1024
# quality: standard
timeout_seconds: 120
# browser_act 相关配置
browser_context:
enabled: true
actions_enabled: true
# 上传文件识别配置。
# - auto_convert_documents: true 时,上传的 PDF / Word / Excel / PPT 会在服务端自动转换为
# Markdown(.md),agent 通过 read_file / grep 读取其内容 —— 即“识别”文档内容。
# 纯文本类(txt / csv / md / json / 代码等)无需转换,agent 用 read_file 直接读取。
# 图片(png/jpg/webp)由 view_image + 上面的 vision 识图模型处理。
# - pdf_converter: auto(优先 pymupdf4llm,缺失时回退 markitdown)| pymupdf4llm | markitdown
uploads:
auto_convert_documents: true
pdf_converter: auto
title:
enabled: true
max_words: 6
max_chars: 60
prompt_template: |
请为下面这段对话生成一个简洁的标题,概括对话的核心主题。
要求:
- 标题必须使用简体中文
- 尽量精炼,不超过 {max_words} 个词
- 只返回标题本身,不要加引号、不要解释、结尾不要标点
User: {user_msg}
Assistant: {assistant_msg}
# 上下文压缩(对话摘要):长对话接近上下文上限时,把较早的消息总结成一段摘要,
# 只保留最近若干条原始消息,避免撑爆模型上下文窗口。
# - trigger:触发条件,可配多个,任意一个先满足就压缩(OR 关系)。真正决定何时
# 压缩的是 trigger(不是 keep)。type=tokens 按 token 总数;type=messages 按条数。
# - keep:压缩后保留多少最近的上下文(tokens / messages)。
# - trim_tokens_to_summarize:喂给摘要模型的消息最多裁到多少 token。
# 当前为「测试值(易触发)」:发到服务器后稍微聊几句就会触发,可在 Gateway 日志里
# grep「上下文压缩」/「context compaction」确认生效;确认后再调回生产值
# (trigger tokens 60000 / messages 80,keep tokens 16000)。
summarization:
# 开关已移到聊天框下方(参考文献下面)的「上下文压缩」按钮,默认关闭。
# 这里 enabled 仅作为非 Web 路径(渠道/定时任务)的后备开关;下面的 trigger/keep
# 等仅是调参,Web 端是否压缩由前端按钮(summarization_enabled)决定。
enabled: false
# model_name: deepseek-chat # 留空则用主对话模型
trigger:
- type: tokens
value: 120000
- type: messages
value: 160
keep:
type: tokens
value: 40000
trim_tokens_to_summarize: 8000
tool_groups:
- name: web
- name: file:read
- name: file:write
- name: bash
tools:
- name: web_search
group: web
# DuckDuckGo search — no API key required. To switch to the deployment's
# internal search service later, restore the configurable_search block
# preserved in the comments below (fill in the real values) and point
# `use` back to deerflow.community.configurable_search.tools:web_search_tool.
use: deerflow.community.ddg_search.tools:web_search_tool
enabled: true
max_results: 5
# ── configurable_search alternative (deployment-internal service) ──
# endpoint: "https://xxx.xxx.xxx/searchName"
# verify_ssl: false
# timeout: 30
# result_url_template: "https://xxx.xxx.xxx/mindId/xxx?recUuid={recUuid}"
# The runtime query always overwrites payload.query; all other fields are
# sent unchanged so deployment-specific request parameters stay in config.
# payload:
# query: ""
# xxx: "xxxx"
- name: web_fetch
group: web
use: deerflow.community.jina_ai.tools:web_fetch_tool
timeout: 15
- name: ls
group: file:read
use: deerflow.sandbox.tools:ls_tool
- name: read_file
group: file:read
use: deerflow.sandbox.tools:read_file_tool
- name: glob
group: file:read
use: deerflow.sandbox.tools:glob_tool
max_results: 200
- name: grep
group: file:read
use: deerflow.sandbox.tools:grep_tool
max_results: 100
- name: write_file
group: file:write
use: deerflow.sandbox.tools:write_file_tool
- name: str_replace
group: file:write
use: deerflow.sandbox.tools:str_replace_tool
- name: bash
group: bash
use: deerflow.sandbox.tools:bash_tool
# browser_act 相关配置
- name: browser_fetch_page
group: web
use: deerflow.tools.builtins.browser_context_tool:browser_fetch_page_tool
# browser_act 相关配置
- name: browser_act
group: web
use: deerflow.tools.builtins.browser_act_tool:browser_act_tool
sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider
allow_host_bash: true
mounts:
- host_path: C:\Users\iie\Desktop\deerflow-new\offline-backend-20260512\backend\knowledge\knowledge_base
container_path: /mnt/knowledge
read_only: true
skills:
path: skills
security_scan:
enabled: false
skill_evolution:
enabled: true
curator:
enabled: true # 每 24 小时自动清理 + 合并 skill
interval_hours: 24
review_worker:
enabled: true # 被动复盘开关
min_tool_calls: 5 # 触发阈值:本轮至少 5 次工具调用,与 prompt 中"≥5 步"硬条件一致
max_messages: 60 # 传给 LLM 的最大消息条数
timeout_seconds: 120 # 单次 review 超时(秒)
model_name: # 留空则使用主模型
agents_api:
enabled: true
# 登录鉴权扩展:在基础登录之上叠加两种自定义模式。
auth_login:
# 用户名直登口令门:开关打开后,/login/<用户名> 直登必须再带 ?password=口令,
# 口令不对直接 401。password 留空则默认口令为 123ewq。仅作用于用户名直登。
username_login:
require_password: false
password: ''
# token 换登录:前端用 ?authToken=上游token 进入,后端调用 token_info_url
# (把该 token 直接拼成 Authorization: Bearer <token> 头)换出用户名,再登录;
# 用户名不在库里时自动注册。
token_login:
enabled: true
token_info_url: 'https://ch1.b.uat.4.cn/consumer/login/getTokenInfo'
# 已弃用:鉴权头现由前端传入的 token 拼成 "Bearer <token>",此项不再生效,保留仅兼容。
service_authorization: 'Bearer admin'
timeout_seconds: 10
# 上游是 HTTPS。UAT/内网常用自签或私有 CA 证书,默认 verify=true 会握手失败导致
# 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。
verify_ssl: false
ca_cert_path: '' # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验
# 离线测试映射:token -> 用户名。命中的 token 跳过 token_info_url 调用,直接当成
# 该用户登录——外网调不通 getTokenInfo 时用来本地全链路联调。生产留空即可(不生效)。
# 例:用 /login/任意?authToken=123ewq 进入,即以 admin 身份登录。
mock_tokens:
'123ewq': 'admin'
# 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口,
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
database:
backend: sqlite
sqlite_dir: .deer-flow/data
pool_size: 20 # 基础连接池(postgres/mysql 生效;sqlite 忽略)
max_overflow: 20 # 高峰期可临时超出 pool_size 的额外连接数,真实上限 = pool_size + max_overflow
pool_timeout: 30 # 池满时等待空闲连接的秒数,超时报错
pool_recycle: 1800 # 连接回收周期(秒);防火墙/pgbouncer/MySQL wait_timeout 静默断连兜底。多 worker 部署:每 worker 上限 = pool_size + max_overflow,总数 × worker 数 < DB max_connections
echo_sql: false
checkpointer:
type: sqlite
connection_string: .deer-flow/data/deerflow.db
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
# 三层覆盖优先级(高->低):
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
memory:
enabled: true
version:
v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
# 'v2'(USER.md/MEMORY.md + 可选 Hindsight)
provider:
hindsight # 'builtin' | 'hindsight'(外部 provider 名)
# builtin 始终启用,这里只决定 external 选哪个
# (version=v1 时此字段忽略)
# V1 专属配置 —— version=v1 时生效
v1:
max_facts: 100 # 最多保留的事实条数,超出时驱逐最低置信度的事实
fact_confidence_threshold: 0.7 # 事实置信度阈值,低于此值不保存
debounce_seconds: 30 # 对话结束后延迟多少秒才触发 LLM 提取
model_name: '' # 用于事实提取的 LLM 模型名;空则继承全局默认模型
token_budget: 2000 # 注入系统提示词的 token 预算
builtin:
enabled: true
memory_char_limit: 2200
user_char_limit: 1375
deduplicate_on_load: true
hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务
mode: local_external # 'cloud' | 'local_embedded' | 'local_external'
api_url: http://47.88.25.99:18888
api_key:
'' # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量
# 注意:写成 $VAR 但环境变量未设置会导致配置加载失败
bank_id_template: 'deerflow-user-{user_id}'
work_bank_id_template: 'deerflow-work-{user_id}-{agent_id}'
memory_mode: context # 'context' | 'tools' | 'hybrid'
bank_mission: '你是一个中文记忆助手,负责从对话中提炼和存储重要信息。所有记忆内容必须使用中文记录。'
bank_retain_mission: '请从以下对话中提炼重要信息,包括用户的偏好、需求、背景知识和关键事实。所有提炼的内容必须用中文表达。'
recall_budget: mid # 'low' | 'mid' | 'high'
recall_max_tokens: 4096
recall_max_input_chars: 800
auto_recall: true
auto_retain: true
retain_async: true
retain_every_n_turns: 1
retain_user_prefix: 'User'
retain_assistant_prefix: 'Assistant'
# background_extraction:预留功能,代码尚未实现 —— 仅设计文档存在。
# 待对应版本支持后,取消下面整段注释即可启用(漏记兜底)。
# background_extraction:
# enabled: false # 默认关闭,作为漏记兜底
# debounce_seconds: 30
# model_name: ~ # null = 用默认模型
# fact_confidence_threshold: 0.7
# max_facts_per_extraction: 10
# correction_detection: true
# reinforcement_detection: true
injection:
enabled: true
max_tokens: 2000
include_builtin: true
include_hindsight: true
context_tag: 'memory-context' # 包裹 Hindsight recall 的标签名
security:
scan_content: true # 注入/外渗模式扫描
block_invisible_unicode: true
# streaming_scrubber:预留字段,代码尚未实现 —— 待版本支持后取消注释。
# streaming_scrubber: true # SSE 流前清洗 <memory-context>
# AI 写作子系统 —— 素材收集专家专用配置。
# 注意:这三项必须嵌套在 ai_writing: 段下;写到顶层会被 _ai_writing_config()
# (app/gateway/routers/ai_writing.py:_ai_writing_config) 直接忽略。
ai_writing:
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
# 用「真实智能体运行时」跑配置技能(read SKILL.md + 全套工具 + 沙箱),让技能的真实
# 检索逻辑(调内网接口/MCP/读文件)真正执行,而不是塞进通用 web/ES 检索 provider。
# 内网技能必须开 true,否则技能会走通用 provider、连不上内网而报 ConnectError。
# 仅对非「知识库检索(knowledge-base-search)」的配置技能生效;失败回退直连检索。
researcher_skill_agent: true
# 最终兜底:当所有技能都没检索到素材时,直连内网知识库 ES 接口兜底检索(避免素材全空)。
# 配上你内网 ES 接口地址即启用;留空则不启用该兜底。
# 接口契约:POST {url} 体 {"query","knowledge_base_name","ts":"yes"} → [{"page_content","metadata":{m_title,content,recUuid,m_publish,source1}}]
intranet_search_url: "" # 例如 http://10.106.57.180:7863/knowledge_base/search_docs_es
intranet_knowledge_base: "samples" # 兜底检索用的知识库名
intranet_search_timeout: 300 # 内网检索超时时间(秒),代码层强制最低 300;需要更长可调大
# 内网接口诊断开关:开启后即使技能已检索成功,也照样把内网接口跑完并把执行结果
# (端点/每个检索词命中数/报错/原始响应片段)记到 material_package.intranet_debug 里,
# 便于在接口返回(devtools)排查内网接口到底通不通。生产建议关掉(会多跑一次内网)。
intranet_search_debug: false
# 四阶段「内置功能型智能体」开关(按阶段灰度)。true = 该阶段节点改为加载
# .deer-flow/agents/ai-writing-<stage>/ 的 SOUL.md + config(admin 可在智能体
# 管理页编辑人设/模型),false / 缺失 = 走旧的硬编码 prompt 路径。
# 改完保存即生效(config 按 mtime 热加载),新写作会话立即按新开关运行。
use_builtin_agents:
researcher: false # 素材收集专家(检索词生成 / 素材排序两个子任务)
outliner: false # 大纲规划师(大纲生成 / 按反馈调整)
writer: false # 作家(逐章正文写作,逐章流式与严格模式求助逻辑不变)
editor: false # 编辑(事实/逻辑/语言三维度审查)
# Optional independently deployed WeKnora integration. DeerFlow owns only
# these connection values; database/Redis/storage/parser/model settings stay
# in WeKnora's own docker-compose/.env/config files. A non-empty api_base_url
# enables WeKnora mode; leaving it empty preserves the existing LLMWiki iframe.
# admin_email/admin_password are used only by DeerFlow's server-side WeKnora
# detail-page iframe proxy. Prefer environment variables in production, e.g.
# admin_email: "$WEKNORA_ADMIN_EMAIL"
# admin_password: "$WEKNORA_ADMIN_PASSWORD"
llmwiki:
weknora:
api_base_url: "http://119.254.86.251:8088/deerflow-api"
web_base_url: "http://119.254.86.251:8088"
admin_email: "lqq@qq.com"
admin_password: "1qaz@WSX"
log_system:
enabled: true
url: 'https://your-log-system.example.com'
label: '日志系统'
task_deeplink:
# 线上接口未通时用假数据;三个开关互相独立。
test: true # 目的树查询走 mock.purpose_detail 假数据
delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完)
action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id)
# 开放接口 POST /api/open/3qfx/ask 按 taskId 拉取任务详情拼开场白;上游不可达时打开下面开关走 mock.cop_task_detail。
cop_task_detail_test: true
cop_task_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/cop-task-detail"
purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail"
# rwfx 与 xdfx 共用同一删除接口:rwfx 追加 ?taskId=(不传 actionId);xdfx 追加 ?actionId=(不传 taskId,直接不出现)。
delete_url: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData"
treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit"
treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next"
# xdfx 深链传入的是「行动id」,先调该接口(追加 /{actionId})拿 data.taskId 解析出真正「任务id」。
action_detail_url: "https://ch1.b.uat.4.cn/consumer/plan/action"
# xdfx 删除前「行动历史是否存在」预检接口(追加 ?actionId=);地址待提供,留空则前端视作无历史、不阻断删除。
action_history_url: ""
# rwfx 目的树面板「3q详情」链接的目标页(地址待定,留空则前端提示未配置);跳转追加 ?id=任务id。
detail_3q_url: ""
mock:
# test=true 时原样返回给前端(结构同真实 getTaskPurposeDetail 返回)。
purpose_detail:
state: "200"
msg: "操作成功!"
data:
- purposeDto: { id: 5837, zzPurpose: "目的01", selectedFlag: 1, taskId: 3324, recDetailId: 4271 }
childrenList:
- actorDto: { id: 9101, taskId: 3324, purposeId: 5837, actorName: "行为体01", actorOrgan: "行为体01", recReason: "行为体01理由", selectedFlag: 1 }
childrenList:
- expectedBehaviorDto: { id: 6910, taskId: 3324, purposeId: 5837, behavior: "预期01", recReason: "预期01理由", selectedFlag: "1" }
childrenList:
- drivingFactorDto: { id: 7964, taskId: 3324, purposeId: 5837, drivingFactor: "预期01因素", selectedFlag: 1 }
childrenList:
- narrationDto: { id: 6500, purposeId: 5837, taskId: 3324, narration: "叙事01", recReason: "叙事01理由", selectedFlag: 1 }
childrenList: []
- actorDto: { id: 9102, taskId: 3324, purposeId: 5837, actorName: "行为体02", actorOrgan: "行为体02", recReason: "行为体02理由", selectedFlag: 1 }
childrenList:
- expectedBehaviorDto: { id: 6911, taskId: 3324, purposeId: 5837, behavior: "预期02", recReason: "预期02理由", selectedFlag: "1" }
childrenList:
- drivingFactorDto: { id: 7965, taskId: 3324, purposeId: 5837, drivingFactor: "预期02因素", selectedFlag: 1 }
childrenList:
- narrationDto: { id: 6501, purposeId: 5837, taskId: 3324, narration: "叙事02", recReason: "叙事02理由", selectedFlag: 1 }
childrenList: []
# delete_test=true 时原样返回给前端。改 success 测「成功覆盖 / 不可覆盖」两条分支。
delete:
message: "行动已经开始策划,不可覆盖"
success: true
# action_test=true 时原样返回给前端(结构同真实 /consumer/plan/action/{actionId} 返回)。
# 前端取 data.taskId 作为真正的「任务id」(xdfx 深链 ?taskId= 传入的是 data.id 这个行动id)。
action_detail:
state: "200"
msg: "操作成功!"
data:
id: 27354
purposeId: 2850
taskId: 2055
actionName: ""
actionStatus: 25
narration: ""
# cop_task_detail_test=true 时原样返回给开放接口 /api/open/3qfx/ask(结构同真实 cop-task-detail 返回)。
cop_task_detail:
data:
id: 2055
overview: "暑期文旅消费高峰期间的舆情态势综合研判与引导任务"
taskDirection: "正向引导 + 风险预警"
taskName: "任务01:暑期文旅舆情综合研判"
taskContent: "围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。"
+36 -2
View File
@@ -1147,6 +1147,10 @@ Automatic conversation summarization when approaching token limits:
the config flag. `config.summarization.enabled` now defaults to **false**.
- `config.yaml` `summarization` still supplies the **tuning**: trigger types
(tokens, messages, or fraction of max input) and the keep policy.
- A compressed request must retain the latest non-empty `HumanMessage`, even
when it has already been folded into the hidden summary. This keeps strict
OpenAI-compatible gateways from rejecting a tool-continuation call with
`No user query found in message`.
- Keeps recent messages while summarizing older ones.
Tests: `tests/test_summarization_toggle.py` (gating + whitelist),
@@ -1265,8 +1269,13 @@ the terminal `lastJobId` in `usage_snapshot` so a refreshed conversation page
can replay its trace from the event log. Detailed mode uses stable targets (`introduction`,
`section:N`, `conclusion`) because section writers are concurrent;
multi-agent revisions must emit `report_reset` before their next report
version. Do not emit raw model reasoning or one persisted event per provider
token. The Vite page owns Word export through
version. Do not persist raw model reasoning or one persisted event per provider
token. Models that expose thinking inside ordinary `<think>...</think>` text
must be split statefully before a report delta, checkpoint, or Markdown
projection is created; an optional live-only `report_thinking` frame can drive
the message-list thought trace, while the sandbox receives answer Markdown
only. The Vite
page owns Word export through
`frontend-web/src/core/scheduled-tasks/markdown-docx.ts`; do not add Python
document-conversion dependencies merely to export text.
All non-streaming Deep Research control-plane calls (`complete()` for query /
@@ -1284,6 +1293,31 @@ The per-user Deep Research concurrency guard counts only session statuses
consume a slot; otherwise saved drafts make `POST /sessions/{id}/jobs` return
429 permanently. Regression: `test_saved_draft_sessions_do_not_consume_research_concurrency`.
`POST /api/deep-research/sessions/{id}/jobs` is retry-safe under MySQL
read/write splitting. Deep Research session/job/event/source/message writes
must serialise their changed row while the write transaction is still open
(`flush` for inserts, select-after-update **before** commit), never through a
post-commit `session.refresh()` or a fresh pooled read. Replica lag otherwise
turns a committed job into a misleading 500, can make dispatcher `claim_job()`
lose its snapshot, or can fail report/follow-up persistence after the model has
already completed. Always nudge the dispatcher for both a newly created job and
an idempotently reused queued job; otherwise a retry after such an interrupted
response returns the old job but leaves it waiting for the periodic scan. The
SSE replay generator must catch temporary event/job-store reads, emit a
recoverable warning, and keep the connection open so the next poll can repair
the cursor instead of surfacing an ASGI `ExceptionGroup`. Regressions:
`test_deep_research_writes_never_depend_on_post_commit_refresh`,
`test_job_create_never_reads_back_after_commit`,
`test_retry_of_existing_queued_job_nudges_dispatcher`, and
`test_stream_stays_open_when_durable_replay_has_a_transient_failure`.
At ordinary report completion, persist and confirm the session's
`report_markdown` projection **before** marking the Job `completed`. Retry a
short transient projection failure; if it cannot be confirmed, let the job
become failed rather than reporting a report that vanishes on refresh.
`test_report_is_persisted_before_job_is_marked_completed` and
`test_report_projection_failure_never_marks_job_completed` cover the ordering.
The chat-collection frontend must keep report-writing progress in the existing
workspace `MessageList` / `MessageGroup` tool-step renderer: inject only
virtual `deep_research_progress` tool calls for the durable phases, keep the
+2 -2
View File
@@ -1,8 +1,8 @@
install:
uv sync
dev:
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload
dev:
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload --reload-exclude ".deer-flow/*" --reload-exclude ".runtime/*"
gateway:
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
+16 -7
View File
@@ -151,7 +151,7 @@ FastAPI application providing REST endpoints for frontend integration:
| `GET /api/threads/{id}/uploads/list` | List uploaded files |
| `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. |
| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Deep Research session/job/event/source/message writes serialise their rows in the writer transaction, rather than doing a post-commit ORM refresh or readback; this prevents MySQL read/write-splitting replica lag from failing “start writing”, dispatcher claim, report persistence, or follow-up history. A report is written to the session and confirmed before its Job becomes `completed`; an unconfirmed write is retried and never exposed as a false completion. Retrying an already-created queued job re-nudges the dispatcher, recovering an interrupted HTTP response without creating a second report. If progress-event persistence, durable replay, or live fan-out is briefly unavailable, the worker/stream logs the degradation and continues or reconnects rather than failing the whole job. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. |
| `POST /api/ai-writing/sample/extract` | Extract text from Word/PDF/Markdown/TXT samples for AI-writing imitation mode |
| `POST /cop/saveSuperiorTask` | TaskCOP compatibility API (DeerFlow Bearer/session authentication required): create a situation-overview task with a sequential four-digit id (`0001`, `0002`, ...) and return the legacy `{state, msg, data}` envelope |
| `POST /api/taskcop/import/tasks` | TaskCOP compatibility API (authenticated): import tasks from a JSON array or `tasks`/`records` envelope; every row's supplied `id`/`taskId` is authoritative and a duplicate id completely replaces the stored task |
@@ -180,6 +180,11 @@ the right-side `report.md` sandbox can write smoothly, while bounded,
persisted `report_chunk` events remain reconnect checkpoints. The durable log
is still authoritative: a browser reconnect, slow consumer, or another worker
falls back to `?after=<seq>` replay rather than starting a second model call.
Reasoning exposed by providers either through structured reasoning fields or
inline `<think>...</think>` text is separated from report deltas, checkpoints,
and the persisted Markdown projection. It may be forwarded as an ephemeral
live `report_thinking` frame for the message-list thought trace, but is never
persisted or written to the sandbox; only answer Markdown reaches the sandbox.
The short non-streaming control-plane calls used to plan queries, sections, and
reviews are independently capped at 45 seconds (maximum 120 seconds). If query
or section planning times out, the runner emits a visible recoverable warning
@@ -231,12 +236,16 @@ only applies to sessions with a `running` or `awaiting_input` durable job.
DeerFlow's stock configuration includes image recognition but no image-generation
provider. Deep Research therefore keeps illustrations disabled until an operator
fills `config.yaml -> deep_research.image_generation` with an
OpenAI-compatible image endpoint, API key, and model. When configured, the
capabilities endpoint enables the page's “生成报告配图” switch. A selected job
requests at most four base64 image results, writes them as protected
`research-image-*.png|jpg|webp` artifacts, injects `deep-research://` links into
the Markdown report, and emits image SSE events. A failed/unconfigured image
provider only emits a recoverable warning; the text report still completes.
image endpoint, API key, and model. `provider: openai_compatible` uses the
standard `/images/generations` protocol and requires `b64_json`; `provider:
dashscope_native` supports Qwen Image 3.0 and Wan 2.7 on DashScope / Token Plan
with the native multimodal-generation protocol. Native provider image URLs are
downloaded immediately, so both providers produce protected local artifacts.
When configured, the capabilities endpoint enables the page's “生成报告配图” switch.
A selected job writes at most four `research-image-*.png|jpg|webp` artifacts,
injects `deep-research://` links into the Markdown report, and emits image SSE
events. A failed/unconfigured image provider only emits a recoverable warning;
the text report still completes.
Completed reports also expose `GET /api/deep-research/sessions/{id}/messages`
and `POST /api/deep-research/sessions/{id}/chat`. Follow-up answers are grounded
@@ -44,6 +44,7 @@ from deerflow.agents.deep_research.types import (
DeepResearchResult,
)
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.utils.stream_text import InlineThinkTagFilter
if TYPE_CHECKING:
from deerflow.config.app_config import AppConfig
@@ -61,6 +62,8 @@ WORKER_ID = f"w-{os.getpid()}-{_uuid.uuid4().hex[:8]}"
_LEASE_TTL_SECONDS = 60
_HEARTBEAT_INTERVAL = 20.0
_CANCEL_POLL_INTERVAL = 5.0
_REPORT_PROJECTION_RETRY_ATTEMPTS = 3
_REPORT_PROJECTION_RETRY_DELAY_SECONDS = 0.5
# Phase → progress% mapping for the job timeline.
_PHASE_PROGRESS: dict[str, int] = {
@@ -155,9 +158,7 @@ class RecordingEventSink:
elif type == "sources_curated" and self._source_store is not None:
for sid in (payload or {}).get("selectedIds", []):
try:
await self._source_store.set_selected(
sid, selected=True, reason="curated", user_id=self._user_id
)
await self._source_store.set_selected(sid, selected=True, reason="curated", user_id=self._user_id)
except Exception: # noqa: BLE001
pass
@@ -263,14 +264,8 @@ class DeepResearchJobExecutor:
cancel_token = CancellationToken()
main_task = asyncio.current_task()
heartbeat = (
asyncio.create_task(self._heartbeat(job_id, lease_owner, main_task))
if lease_owner
else None
)
watcher = asyncio.create_task(
self._watch_cancellation(job_id, main_task, lease_owner, cancel_token)
)
heartbeat = asyncio.create_task(self._heartbeat(job_id, lease_owner, main_task)) if lease_owner else None
watcher = asyncio.create_task(self._watch_cancellation(job_id, main_task, lease_owner, cancel_token))
try:
# Normal sandbox Markdown rewrites share the same durable lease,
@@ -388,9 +383,7 @@ class DeepResearchJobExecutor:
session_for_model.setdefault("config_snapshot", snapshot.get("config") or {})
if not report.strip() or not instruction:
await self._mark_document_rewrite_failed(
job_id, session_id, lease_owner, "INVALID_SNAPSHOT", "改写任务快照不完整,无法继续执行。"
)
await self._mark_document_rewrite_failed(job_id, session_id, lease_owner, "INVALID_SNAPSHOT", "改写任务快照不完整,无法继续执行。")
return
events = PersistingEventSink(
@@ -419,9 +412,7 @@ class DeepResearchJobExecutor:
phase="planning",
payload={"type": "requirements_started"},
)
await self._job_store.update_progress(
job_id, lease_owner=lease_owner, phase="planning", progress=20
)
await self._job_store.update_progress(job_id, lease_owner=lease_owner, phase="planning", progress=20)
requirement_parts: list[str] = []
# Persist the first fragment too: the HTTP client opens its SSE stream
# just after queuing the job, so this closes the tiny subscribe race
@@ -447,6 +438,14 @@ class DeepResearchJobExecutor:
)
last_requirements_checkpoint = time.monotonic()
async def on_requirement_thinking(delta: str) -> None:
if delta:
await events.publish_live(
"document_rewrite",
phase="planning",
payload={"type": "thinking", "chunk": delta},
)
requirements_fallback = False
try:
requirements_plan = await stream_full_report_rewrite_requirements(
@@ -456,6 +455,7 @@ class DeepResearchJobExecutor:
style=style_instruction,
model_name=model_name,
on_delta=on_requirement_delta,
on_reasoning=on_requirement_thinking,
)
except asyncio.CancelledError:
raise
@@ -490,9 +490,7 @@ class DeepResearchJobExecutor:
"plan": requirements_plan,
},
)
await self._job_store.update_progress(
job_id, lease_owner=lease_owner, phase="writing", progress=50
)
await self._job_store.update_progress(job_id, lease_owner=lease_owner, phase="writing", progress=50)
draft_parts: list[str] = []
last_checkpoint = time.monotonic()
@@ -527,6 +525,14 @@ class DeepResearchJobExecutor:
)
last_checkpoint = time.monotonic()
async def on_thinking(delta: str) -> None:
if delta:
await events.publish_live(
"document_rewrite",
phase="writing",
payload={"type": "thinking", "chunk": delta},
)
try:
rewritten, citation_ids, _usage = await stream_full_report_rewrite(
session=session_for_model,
@@ -536,6 +542,7 @@ class DeepResearchJobExecutor:
style=style,
model_name=model_name,
on_delta=on_delta,
on_reasoning=on_thinking,
requirements_plan=requirements_plan,
)
except asyncio.CancelledError:
@@ -558,9 +565,7 @@ class DeepResearchJobExecutor:
await events.emit("document_rewrite", phase="reviewing", payload={"type": "validation_started"})
validation_error = validate_markdown(rewritten)
if validation_error:
await self._mark_document_rewrite_failed(
job_id, session_id, lease_owner, "VALIDATION_FAILED", validation_error, events=events, stage="validation"
)
await self._mark_document_rewrite_failed(job_id, session_id, lease_owner, "VALIDATION_FAILED", validation_error, events=events, stage="validation")
return
before_headings = heading_count(report)
@@ -580,13 +585,9 @@ class DeepResearchJobExecutor:
validation_warnings=warnings,
)
summary["citationCount"] = len(citation_ids)
await events.emit(
"document_rewrite", phase="reviewing", payload={"type": "comparison_ready", **summary}
)
await events.emit("document_rewrite", phase="reviewing", payload={"type": "comparison_ready", **summary})
await events.emit("document_rewrite", phase="exporting", payload={"type": "commit_started"})
await self._job_store.update_progress(
job_id, lease_owner=lease_owner, phase="exporting", progress=90
)
await self._job_store.update_progress(job_id, lease_owner=lease_owner, phase="exporting", progress=90)
committed_hash = __import__("hashlib").sha256(rewritten.encode("utf-8")).hexdigest()
updated = await self._session_store.replace_report_if_unchanged(
@@ -641,9 +642,7 @@ class DeepResearchJobExecutor:
expected_report=rewritten,
report_markdown=report,
)
message = "保存改写版本失败,报告已恢复原文。" if rolled_back is not None else (
"保存改写版本失败,且报告随后被修改,未再覆盖。"
)
message = "保存改写版本失败,报告已恢复原文。" if rolled_back is not None else ("保存改写版本失败,且报告随后被修改,未再覆盖。")
await self._mark_document_rewrite_failed(
job_id,
session_id,
@@ -747,6 +746,11 @@ class DeepResearchJobExecutor:
instruction = str(snapshot.get("instruction") or "").strip()
style = str(snapshot.get("style") or "professional").strip()
model_name = str(snapshot.get("model_name") or "").strip() or None
generate_images = bool(snapshot.get("generate_images"))
try:
max_generated_images = min(max(int(snapshot.get("max_generated_images") or 2), 1), 4)
except (TypeError, ValueError):
max_generated_images = 2
async def fail(code: str, message: str, *, stage: str = "generation") -> None:
await events.emit(
@@ -806,13 +810,20 @@ class DeepResearchJobExecutor:
)
return
rewrite_source = original
if generate_images:
# Do not ask the writer to preserve a previous run's managed image
# blocks. A new illustration pass will create a fresh, local set;
# user-authored image links remain untouched.
from app.gateway.document_rewrite_illustrations import strip_managed_document_illustrations
rewrite_source = strip_managed_document_illustrations(original)
style_instruction = DOCUMENT_STYLE_INSTRUCTIONS.get(
style,
DOCUMENT_STYLE_INSTRUCTIONS["professional"],
)
resolved_name = model_name or (
self._app_config.models[0].name if self._app_config.models else None
)
resolved_name = model_name or (self._app_config.models[0].name if self._app_config.models else None)
model_cfg = self._app_config.get_model_config(resolved_name) if resolved_name else None
thinking_enabled = bool(model_cfg and getattr(model_cfg, "supports_thinking", False))
try:
@@ -847,12 +858,21 @@ class DeepResearchJobExecutor:
last_requirements_checkpoint = 0.0
requirements_fallback = False
try:
async def on_requirement_thinking(delta: str) -> None:
if delta:
await events.publish_live(
"document_rewrite",
phase="planning",
payload={"type": "thinking", "chunk": delta},
)
async for delta in astream_rewrite_requirements(
model,
file_name=actual_path.name,
instruction=instruction,
style_instruction=style_instruction,
original=original,
original=rewrite_source,
on_thinking=on_requirement_thinking,
):
requirement_parts.append(delta)
requirement_draft = "".join(requirement_parts)
@@ -907,7 +927,7 @@ class DeepResearchJobExecutor:
f"{requirements_plan}\n"
"【已确认的改写执行计划结束】\n\n"
"【原始 Markdown 开始】\n"
f"{original}\n"
f"{rewrite_source}\n"
"【原始 Markdown 结束】"
)
),
@@ -932,6 +952,7 @@ class DeepResearchJobExecutor:
draft_parts: list[str] = []
last_checkpoint = time.monotonic()
inline_think_filter = InlineThinkTagFilter()
try:
async for chunk in model.astream(messages, config={"run_name": "document_rewrite"}):
thinking_delta, text_delta = extract_stream_chunk_parts(chunk)
@@ -941,6 +962,13 @@ class DeepResearchJobExecutor:
phase="writing",
payload={"type": "thinking", "chunk": thinking_delta},
)
text_delta, inline_thinking = inline_think_filter.push_parts(text_delta)
if inline_thinking:
await events.publish_live(
"document_rewrite",
phase="writing",
payload={"type": "thinking", "chunk": inline_thinking},
)
if not text_delta:
continue
draft_parts.append(text_delta)
@@ -972,6 +1000,26 @@ class DeepResearchJobExecutor:
await fail(type(exc).__name__, str(exc))
return
tail, inline_thinking_tail = inline_think_filter.finish_parts()
if inline_thinking_tail:
await events.publish_live(
"document_rewrite",
phase="writing",
payload={"type": "thinking", "chunk": inline_thinking_tail},
)
if tail:
draft_parts.append(tail)
draft = "".join(draft_parts)
await events.publish_live(
"document_rewrite",
phase="writing",
payload={
"type": "rewrite_delta",
"delta": tail,
"charCount": visible_document_char_count(draft),
},
)
rewritten = "".join(draft_parts).strip()
await events.emit(
"document_rewrite",
@@ -982,6 +1030,148 @@ class DeepResearchJobExecutor:
"charCount": visible_document_char_count(rewritten),
},
)
# Reject an invalid text draft before spending image-generation quota.
validation_error = validate_markdown_document(rewritten)
if validation_error:
await fail("VALIDATION_FAILED", validation_error, stage="validation")
return
generated_illustrations = []
persisted_illustrations = []
illustration_warnings: list[str] = []
if generate_images:
from app.gateway.document_rewrite_illustrations import generate_document_illustrations
from deerflow.agents.deep_research.adapters.image import ImageGenerationSettings, build_image_provider
await self._job_store.update_progress(
job_id,
lease_owner=lease_owner,
phase="illustrating",
progress=88,
)
# Make the phase visible even when the configured provider is
# unavailable or every provider request fails before a result.
await events.emit(
"document_rewrite",
phase="illustrating",
payload={
"type": "illustration_started",
"section": "准备生成文档配图",
"position": 0,
"total": max_generated_images,
},
)
image_settings = ImageGenerationSettings.from_app_config()
if not image_settings.is_configured:
illustration_warnings.append("当前部署未配置图片生成服务,已跳过文档配图。")
else:
provider = build_image_provider()
async def on_image_started(section: str, position: int, total: int) -> None:
await events.emit(
"document_rewrite",
phase="illustrating",
payload={
"type": "illustration_started",
"section": section,
"position": position,
"total": total,
},
)
async def on_image_generated(image, total: int, generated_count: int) -> None:
await events.emit(
"document_rewrite",
phase="illustrating",
payload={
"type": "illustration_generated",
"section": image.section,
"position": image.position,
"total": total,
"generatedCount": generated_count,
},
)
generated_illustrations, generated_warnings = await generate_document_illustrations(
provider,
markdown=rewritten,
document_name=actual_path.name,
maximum=max_generated_images,
on_started=on_image_started,
on_generated=on_image_generated,
)
illustration_warnings.extend(generated_warnings)
# Do not save images if a manual edit has invalidated the frozen
# source version while image calls were in flight.
current_before_images = await asyncio.to_thread(actual_path.read_text, encoding="utf-8")
if content_hash(current_before_images) != base_hash:
message = "文件在生成配图期间已被修改,已保留原文件。"
await events.emit(
"document_rewrite",
phase="done",
payload={"type": "conflict", "message": message},
)
await self._job_store.update_progress(
job_id,
lease_owner=lease_owner,
status="completed",
phase="done",
progress=100,
result_snapshot={
"kind": "artifact_document_rewrite",
"status": "conflict",
"content": rewritten,
"originalContent": original,
"requirementsPlan": requirements_plan,
},
)
return
if generated_illustrations:
from app.gateway.document_rewrite_illustrations import (
insert_document_illustrations,
persist_document_illustrations,
)
try:
persisted_illustrations, persistence_warnings = await asyncio.to_thread(
persist_document_illustrations,
generated_illustrations,
markdown_path=actual_path,
virtual_markdown_path=virtual_path,
job_id=job_id,
)
illustration_warnings.extend(persistence_warnings)
rewritten = insert_document_illustrations(rewritten, persisted_illustrations)
await events.emit(
"document_rewrite",
phase="illustrating",
payload={
"type": "rewrite_checkpoint",
"content": rewritten,
"charCount": visible_document_char_count(rewritten),
},
)
except Exception: # noqa: BLE001
logger.exception("could not save artifact rewrite illustrations for %s", job_id)
illustration_warnings.append("配图保存失败,已完成文字改写但未插入图片。")
persisted_illustrations = []
else:
persisted_illustrations = []
await events.emit(
"document_rewrite",
phase="illustrating",
payload={
"type": "illustration_completed",
"generatedCount": len(persisted_illustrations),
"requestedCount": max_generated_images,
"warnings": illustration_warnings,
},
)
await events.emit("document_rewrite", phase="reviewing", payload={"type": "validation_started"})
validation_error = validate_markdown_document(rewritten)
if validation_error:
@@ -991,6 +1181,7 @@ class DeepResearchJobExecutor:
heading_count_before = sum(1 for line in original.splitlines() if line.lstrip().startswith("#"))
heading_count_after = sum(1 for line in rewritten.splitlines() if line.lstrip().startswith("#"))
warnings = ["标题数量发生变化,请在前后对比中确认结构。"] if heading_count_before != heading_count_after else []
warnings.extend(illustration_warnings)
summary = build_document_rewrite_summary(
source_display_name=actual_path.name,
instruction=instruction,
@@ -999,6 +1190,8 @@ class DeepResearchJobExecutor:
rewritten=rewritten,
validation_warnings=warnings,
)
summary["generatedImageCount"] = len(persisted_illustrations)
summary["requestedImageCount"] = max_generated_images if generate_images else 0
await events.emit(
"document_rewrite",
phase="reviewing",
@@ -1124,9 +1317,7 @@ class DeepResearchJobExecutor:
job_id=job_id,
on_event=self._live_publisher,
)
await events.emit(
"document_rewrite", phase="done", payload={"type": "error", "message": message, "stage": stage}
)
await events.emit("document_rewrite", phase="done", payload={"type": "error", "message": message, "stage": stage})
await self._job_store.update_progress(
job_id,
lease_owner=lease_owner,
@@ -1332,11 +1523,7 @@ class DeepResearchJobExecutor:
},
{
"role": "user",
"content": (
"请为以下研究报告写一段 150-250 字的摘要,使用"
f"{lang_note},一段纯文本,不要标题、不要列表、不要 Markdown 格式:\n\n"
f"{report_markdown[:24_000]}"
),
"content": (f"请为以下研究报告写一段 150-250 字的摘要,使用{lang_note},一段纯文本,不要标题、不要列表、不要 Markdown 格式:\n\n{report_markdown[:24_000]}"),
},
],
on_delta=on_delta,
@@ -1369,10 +1556,18 @@ class DeepResearchJobExecutor:
# ── terminal-state writers ──────────────────────────────────────────────
async def _mark_completed(
self, job_id: str, session_id: str, lease_owner: str | None, result: DeepResearchResult
) -> None:
# Job row → completed.
async def _mark_completed(self, job_id: str, session_id: str, lease_owner: str | None, result: DeepResearchResult) -> None:
# Persist the report projection before exposing a terminal completed
# job. The session row is the durable report itself; marking the job
# complete first and suppressing a session-write failure can otherwise
# make a successfully generated report disappear after refresh.
await self._persist_completed_report_projection(
job_id=job_id,
session_id=session_id,
result=result,
)
# Job row → completed only after the report is durable.
await self._job_store.update_progress(
job_id,
lease_owner=lease_owner,
@@ -1385,29 +1580,61 @@ class DeepResearchJobExecutor:
"plan": result.plan,
},
)
# Session row → completed + report.
source_count = len(result.source_ids)
with suppress(Exception): # noqa: BLE001
await self._session_store.update(
session_id,
status="completed",
report_markdown=result.report_markdown,
report_html=result.report_html,
plan_snapshot=result.plan,
# Keep the terminal job id alongside model usage. The
# frontend uses it only to replay the persisted research trace
# after a refresh; it does not affect billing or job state.
usage_snapshot={**(result.usage or {}), "lastJobId": job_id},
source_count=source_count,
active_job_id=None,
)
# Emit a terminal event for SSE consumers.
with suppress(Exception): # noqa: BLE001
await self._emit_terminal(job_id, session_id, "job_completed")
async def _mark_awaiting(
self, job_id: str, session_id: str, pause: ResearchAwaitingInput
async def _persist_completed_report_projection(
self,
*,
job_id: str,
session_id: str,
result: DeepResearchResult,
) -> None:
"""Durably save the report before publishing job completion.
A write can succeed while a short-lived MySQL proxy/readback hiccup
makes its caller fail. Retry the full owner-scoped update a few times;
if it still cannot be confirmed, propagate the error so ``_run`` marks
the job failed instead of advertising a report that cannot be reopened.
"""
last_error: Exception | None = None
for attempt in range(1, _REPORT_PROJECTION_RETRY_ATTEMPTS + 1):
try:
updated = await self._session_store.update(
session_id,
status="completed",
report_markdown=result.report_markdown,
report_html=result.report_html,
plan_snapshot=result.plan,
# Keep the terminal job id alongside model usage. The
# frontend uses it only to replay the persisted research
# trace after a refresh; it does not affect billing.
usage_snapshot={**(result.usage or {}), "lastJobId": job_id},
source_count=len(result.source_ids),
active_job_id=None,
)
if updated is not None:
return
last_error = RuntimeError(f"Deep Research session {session_id} no longer exists")
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
last_error = exc
if attempt < _REPORT_PROJECTION_RETRY_ATTEMPTS:
logger.warning(
"deep-research report projection failed for %s (attempt %s/%s); retrying",
job_id,
attempt,
_REPORT_PROJECTION_RETRY_ATTEMPTS,
exc_info=True,
)
await asyncio.sleep(_REPORT_PROJECTION_RETRY_DELAY_SECONDS * attempt)
raise RuntimeError(f"研究报告未能保存到会话 {session_id},任务不会标记为完成") from last_error
async def _mark_awaiting(self, job_id: str, session_id: str, pause: ResearchAwaitingInput) -> None:
"""Project a persisted plan-review interrupt onto the owning session."""
with suppress(Exception): # noqa: BLE001
await self._session_store.update(
@@ -1417,9 +1644,7 @@ class DeepResearchJobExecutor:
active_job_id=job_id,
)
async def _mark_failed(
self, job_id: str, session_id: str, lease_owner: str | None, code: str, message: str
) -> None:
async def _mark_failed(self, job_id: str, session_id: str, lease_owner: str | None, code: str, message: str) -> None:
await self._job_store.update_progress(
job_id,
lease_owner=lease_owner,
@@ -85,6 +85,7 @@ async def stream_full_report_rewrite(
style: str,
model_name: str | None,
on_delta: Callable[[str], Awaitable[None]],
on_reasoning: Callable[[str], Awaitable[None]] | None = None,
requirements_plan: str | None = None,
) -> tuple[str, list[str], dict[str, Any]]:
"""Generate a complete Markdown replacement without starting new research."""
@@ -126,6 +127,7 @@ async def stream_full_report_rewrite(
messages=messages,
operation="full_report_rewrite",
on_delta=on_delta,
on_reasoning=on_reasoning,
)
answer = result.text.strip() or report
answer, cited = _normalise_citations(answer, source_ids)
@@ -140,6 +142,7 @@ async def stream_full_report_rewrite_requirements(
style: str,
model_name: str | None,
on_delta: Callable[[str], Awaitable[None]],
on_reasoning: Callable[[str], Awaitable[None]] | None = None,
) -> str:
"""Stream a compact plan before rewriting a research report.
@@ -169,6 +172,7 @@ async def stream_full_report_rewrite_requirements(
],
operation="full_report_rewrite_requirements",
on_delta=on_delta,
on_reasoning=on_reasoning,
)
return result.text.strip()
@@ -0,0 +1,263 @@
"""Safe, optional illustrations for a sandbox Markdown full rewrite.
The writer streams prose first. When requested, this module derives a small
set of heading-scoped illustration prompts, persists the returned bytes inside
the same user-owned artifact tree, then adds only local ``/mnt`` references to
the Markdown. Provider URLs never reach the browser or the saved document.
"""
from __future__ import annotations
import os
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from deerflow.agents.deep_research.adapters.image import ImageGenerationError, ImageProvider
_HEADING_RE = re.compile(r"^##[ \t]+([^\r\n]+?)[ \t]*$", re.MULTILINE)
_TITLE_RE = re.compile(r"^#[ \t]+([^\r\n]+?)[ \t]*$", re.MULTILINE)
_START_MARKER = "<!-- deerflow:ai-rewrite-image:start -->"
_END_MARKER = "<!-- deerflow:ai-rewrite-image:end -->"
_MANAGED_IMAGE_BLOCK_RE = re.compile(
rf"\n?{re.escape(_START_MARKER)}.*?{re.escape(_END_MARKER)}\n?",
re.DOTALL,
)
_MIME_EXTENSIONS = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
}
@dataclass(frozen=True)
class DocumentIllustration:
"""A provider result that has not been exposed or persisted yet."""
section: str
position: int
data: bytes
mime_type: str
alt: str
@dataclass(frozen=True)
class PersistedDocumentIllustration:
"""A generated image with a virtual artifact path safe for Markdown."""
section: str
position: int
virtual_path: str
alt: str
def strip_managed_document_illustrations(markdown: str) -> str:
"""Remove only DeerFlow-generated image blocks before a fresh image pass."""
return _MANAGED_IMAGE_BLOCK_RE.sub("\n", markdown).strip()
def select_illustration_sections(markdown: str, maximum: int) -> list[str]:
"""Pick distinct H2 sections; fall back to the title for short documents."""
limit = max(0, min(maximum, 4))
if not limit:
return []
sections: list[str] = []
seen: set[str] = set()
for match in _HEADING_RE.finditer(markdown):
section = _clean_heading(match.group(1))
if section and section not in seen:
seen.add(section)
sections.append(section)
if len(sections) >= limit:
return sections
if sections:
return sections
title_match = _TITLE_RE.search(markdown)
title = _clean_heading(title_match.group(1)) if title_match else "文档主题"
return [title or "文档主题"]
async def generate_document_illustrations(
provider: ImageProvider,
*,
markdown: str,
document_name: str,
maximum: int,
on_started=None,
on_generated=None,
) -> tuple[list[DocumentIllustration], list[str]]:
"""Generate a bounded, sequential image set and keep failures recoverable."""
sections = select_illustration_sections(markdown, maximum)
title_match = _TITLE_RE.search(markdown)
document_title = _clean_heading(title_match.group(1)) if title_match else Path(document_name).stem
generated: list[DocumentIllustration] = []
warnings: list[str] = []
for position, section in enumerate(sections, 1):
if on_started is not None:
await on_started(section, position, len(sections))
try:
result = await provider.generate(
_illustration_prompt(document_title, section),
section_id=section,
)
data = result.get("data") if isinstance(result, dict) else None
if not isinstance(data, bytes) or not data:
raise ImageGenerationError("图片生成服务未返回可保存的图片数据")
mime_type = str(result.get("mime_type") or "image/png").lower()
if mime_type not in _MIME_EXTENSIONS:
mime_type = "image/png"
illustration = DocumentIllustration(
section=section,
position=position,
data=data,
mime_type=mime_type,
alt=_clean_alt(str(result.get("alt") or f"{section}配图")),
)
generated.append(illustration)
if on_generated is not None:
await on_generated(illustration, len(sections), len(generated))
except ImageGenerationError as exc:
warnings.append(f"“{section}”配图未生成:{exc}")
except Exception: # noqa: BLE001
# Generation remains best effort: an unavailable image provider
# must never discard an otherwise complete document rewrite.
warnings.append(f"“{section}”配图生成失败,已保留文字改写结果。")
return generated, warnings
def persist_document_illustrations(
illustrations: list[DocumentIllustration],
*,
markdown_path: Path,
virtual_markdown_path: str,
job_id: str,
) -> tuple[list[PersistedDocumentIllustration], list[str]]:
"""Best-effort persist each asset beside the rewritten Markdown file.
A single failed local write must not discard earlier successful images. In
particular, Windows development reloaders can briefly recreate an output
directory while a report is being written. The caller receives warnings
for the timeline, while any persisted image remains usable in the final
Markdown.
"""
if not illustrations:
return [], []
# Keep this name intentionally short. On Windows the local sandbox path
# already contains user/thread UUIDs; including a long report title plus a
# UUID-based temporary filename exceeded MAX_PATH (and surfaced as a
# misleading FileNotFoundError). Job ids make names unique across files,
# so a single short hidden directory per outputs folder is sufficient.
asset_directory_name = ".ai-images"
asset_directory = markdown_path.parent / asset_directory_name
virtual_parent = PurePosixPath(virtual_markdown_path).parent.as_posix().rstrip("/")
persisted: list[PersistedDocumentIllustration] = []
warnings: list[str] = []
for image in illustrations:
try:
extension = _MIME_EXTENSIONS[image.mime_type]
file_name = f"{job_id}-{image.position:02d}.{extension}"
_atomic_write_bytes(asset_directory / file_name, image.data)
persisted.append(
PersistedDocumentIllustration(
section=image.section,
position=image.position,
virtual_path=f"{virtual_parent}/{asset_directory_name}/{file_name}",
alt=image.alt,
)
)
except OSError:
warnings.append(f"“{image.section}”配图保存失败,已继续保留其他成功配图。")
return persisted, warnings
def insert_document_illustrations(
markdown: str,
illustrations: list[PersistedDocumentIllustration],
) -> str:
"""Insert fenced local-image blocks directly below their selected sections."""
amended = strip_managed_document_illustrations(markdown)
for image in illustrations:
marker = f"## {image.section}"
block = (
f"\n\n{_START_MARKER}\n"
f"![{image.alt}]({image.virtual_path})\n"
f"{_END_MARKER}\n"
)
if marker in amended:
amended = amended.replace(marker, f"{marker}{block}", 1)
else:
amended = f"{amended.rstrip()}{block}"
return amended.strip()
def _atomic_write_bytes(path: Path, content: bytes) -> None:
"""Write one image atomically with Windows-safe temporary path lengths."""
last_error: FileNotFoundError | None = None
for _attempt in range(3):
temporary: Path | None = None
try:
# A runtime cleanup can remove a freshly-created asset folder
# between image calls. Recreate it immediately before writing and
# retry this small local operation. NamedTemporaryFile deliberately
# keeps the temporary basename short: appending a report title,
# job id, and UUID can exceed Windows MAX_PATH before os.replace.
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="wb",
dir=path.parent,
prefix=".w-",
suffix=".tmp",
delete=False,
) as file:
temporary = Path(file.name)
file.write(content)
file.flush()
os.fsync(file.fileno())
os.replace(temporary, path)
return
except FileNotFoundError as exc:
last_error = exc
finally:
try:
if temporary is not None and temporary.exists():
temporary.unlink()
except OSError:
pass
if last_error is not None:
raise last_error
def _illustration_prompt(document_title: str, section: str) -> str:
return (
"为一份专业 Markdown 文档生成一张可嵌入的章节配图。"
f"文档主题:{_clean_heading(document_title)}。章节:{section}。"
"画面应信息性、简洁、与正文内容一致,可使用抽象示意、数据可视化风格或场景插画。"
"不要在图片中放置可读文字、品牌标志、水印或人物肖像;不得虚构具体数据或事实。"
)
def _clean_heading(value: str) -> str:
return " ".join(value.replace("\r", " ").replace("\n", " ").split())[:160]
def _clean_alt(value: str) -> str:
return _clean_heading(value).replace("[", "").replace("]", "") or "文档配图"
__all__ = [
"DocumentIllustration",
"PersistedDocumentIllustration",
"generate_document_illustrations",
"insert_document_illustrations",
"persist_document_illustrations",
"select_illustration_sections",
"strip_managed_document_illustrations",
]
@@ -14,10 +14,12 @@ import logging
import os
import re
import uuid
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Awaitable, Callable
from pathlib import Path
from typing import Any
from deerflow.utils.stream_text import InlineThinkTagFilter
logger = logging.getLogger(__name__)
MAX_DOCUMENT_REWRITE_CHARS = 120_000
@@ -89,9 +91,7 @@ def build_rewrite_requirements_context(content: str) -> str:
if len(headings) >= 30:
break
heading_count = sum(
1 for line in content.splitlines() if re.match(r"^\s{0,3}#{1,6}\s+", line)
)
heading_count = sum(1 for line in content.splitlines() if re.match(r"^\s{0,3}#{1,6}\s+", line))
link_count = len(re.findall(r"!?(?:\[[^\]]*\]\([^)]*\))", content))
table_rows = sum(1 for line in content.splitlines() if line.lstrip().startswith("|"))
return "\n".join(
@@ -136,16 +136,7 @@ def build_rewrite_requirements_messages(
return [
SystemMessage(content=DOCUMENT_REWRITE_REQUIREMENTS_SYSTEM_PROMPT),
HumanMessage(
content=(
f"文件名:{file_name}\n"
f"用户改写要求:{instruction}\n"
f"选定风格:{style_instruction}\n\n"
"【文档结构信息开始】\n"
f"{build_rewrite_requirements_context(original)}\n"
"【文档结构信息结束】"
)
),
HumanMessage(content=(f"文件名:{file_name}\n用户改写要求:{instruction}\n选定风格:{style_instruction}\n\n【文档结构信息开始】\n{build_rewrite_requirements_context(original)}\n【文档结构信息结束】")),
]
@@ -156,18 +147,30 @@ async def astream_rewrite_requirements(
instruction: str,
style_instruction: str,
original: str,
on_thinking: Callable[[str], Awaitable[None]] | None = None,
) -> AsyncIterator[str]:
"""Yield only visible plan text from the short requirements-planning call."""
"""Yield visible plan text and optionally forward live model reasoning."""
messages = build_rewrite_requirements_messages(
file_name=file_name,
instruction=instruction,
style_instruction=style_instruction,
original=original,
)
inline_think_filter = InlineThinkTagFilter()
async for chunk in model.astream(messages, config={"run_name": "document_rewrite_requirements"}):
_, text = extract_stream_chunk_parts(chunk)
if text:
yield text
structured_thinking, text = extract_stream_chunk_parts(chunk)
if structured_thinking and on_thinking is not None:
await on_thinking(structured_thinking)
visible_text, inline_thinking = inline_think_filter.push_parts(text)
if inline_thinking and on_thinking is not None:
await on_thinking(inline_thinking)
if visible_text:
yield visible_text
tail, inline_thinking_tail = inline_think_filter.finish_parts()
if inline_thinking_tail and on_thinking is not None:
await on_thinking(inline_thinking_tail)
if tail:
yield tail
def atomic_write_text(path: Path, content: str) -> None:
@@ -21,6 +21,7 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_agent_store
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
@@ -125,6 +126,37 @@ async def _validate_public_chain_mapping(
status_code=400,
detail="Global business mappings require a public business chain",
)
# 公共业务映射会被任意账号启动,链条中的席位也必须对任意账号可见。
# 仅把链条本身发布、却把其中某个自定义智能体保留为未发布/私有,会导致
# 前端只能解析到不足两个席位,最终被误判为“没有可用业务链”。
seats = chain.get("seats")
if isinstance(seats, list):
agent_store = get_agent_store(request)
unavailable_names: list[str] = []
for raw_seat in seats:
if not isinstance(raw_seat, dict):
continue
agent_id = str(raw_seat.get("agent_id") or "").strip()
if not agent_id:
continue
agent = await agent_store.get_any(agent_id)
is_builtin = agent is not None and agent.get("user_id") is None
is_public_agent = bool(agent and agent.get("published")) and not bool(
agent and agent.get("is_private")
)
if is_builtin or is_public_agent:
continue
name = str(raw_seat.get("name") or agent_id).strip()
unavailable_names.append(name)
if unavailable_names:
raise HTTPException(
status_code=400,
detail=(
"Global business mappings require every seat to be published and public; "
f"unavailable agents: {', '.join(unavailable_names)}"
),
)
fields["chain_id"] = chain_id
fields["chain_title"] = str(chain.get("title") or "") or None
@@ -639,17 +639,30 @@ async def start_job(session_id: str, body: StartJobRequest, request: Request) ->
input_snapshot=input_snapshot,
)
# Update session status.
await session_store.update(
session_id,
user_id=user_id,
status="running",
active_job_id=job["id"],
)
# The durable job is the source of truth once it has been committed. A
# transient failure updating the display projection must not turn that
# successful write into a misleading 500 response or leave it undispatched.
# The executor re-projects the terminal status when it completes.
try:
await session_store.update(
session_id,
user_id=user_id,
status="running",
active_job_id=job["id"],
)
except Exception: # noqa: BLE001
logger.exception(
"deep-research job %s was queued but its session running projection could not be updated",
job["id"],
)
# Nudge the dispatcher to claim immediately.
# Nudge the dispatcher even for an idempotent retry. If an earlier request
# committed the job but failed before this point (for example a transient
# MySQL read-after-write failure), the retry receives the existing queued
# job. Restricting the nudge to ``created`` would leave that durable job
# queued until the next periodic scan and make the UI appear stuck.
dispatcher = _get_dispatcher(request)
if dispatcher is not None and created:
if dispatcher is not None:
dispatcher.nudge()
return {
@@ -810,36 +823,68 @@ async def stream_job(
if loop.time() >= next_durable_poll:
# Durable replay repairs a missed live delta after
# reconnect or when another worker owned the job.
events = await event_store.list_after(job_id, after=cursor, limit=200)
for ev in events:
payload = {
"seq": ev["seq"],
"sessionId": session_id,
"jobId": job_id,
"type": ev["event_type"],
"phase": ev.get("phase", "initializing"),
"timestamp": _iso(ev.get("created_at")),
"payload": ev.get("payload") or {},
}
yield format_sse("progress", payload, event_id=str(ev["seq"]))
cursor = max(cursor, ev["seq"])
current = await job_store.get_unscoped(job_id)
if current and current.get("status") in TERMINAL_STATUSES:
try:
events = await event_store.list_after(job_id, after=cursor, limit=200)
for ev in events:
payload = {
"seq": ev["seq"],
"sessionId": session_id,
"jobId": job_id,
"type": ev["event_type"],
"phase": ev.get("phase", "initializing"),
"timestamp": _iso(ev.get("created_at")),
"payload": ev.get("payload") or {},
}
yield format_sse("progress", payload, event_id=str(ev["seq"]))
cursor = max(cursor, ev["seq"])
current = await job_store.get_unscoped(job_id)
if current and current.get("status") in TERMINAL_STATUSES:
yield format_sse(
"progress",
{
"seq": cursor + 1,
"sessionId": session_id,
"jobId": job_id,
"type": "job_status",
"phase": current.get("phase", "done"),
"timestamp": _iso(datetime.now(UTC)),
"payload": {"status": current["status"], "progress": current.get("progress", 0)},
},
event_id=str(cursor + 1),
)
return
next_durable_poll = loop.time() + _POLL_INTERVAL_S
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001
# Do not let one read-replica / transient DB failure
# abort the ASGI generator (which surfaces as an
# ExceptionGroup and leaves the page stuck). Live
# frames can still reach an attached browser, and the
# next durable poll repairs the cursor on recovery.
logger.warning(
"deep-research SSE durable replay temporarily failed for job %s; keeping stream open",
job_id,
exc_info=True,
)
yield format_sse(
"progress",
{
"seq": cursor + 1,
"seq": 0,
"liveId": f"durable-replay-retry:{job_id}:{int(loop.time() * 1000)}",
"sessionId": session_id,
"jobId": job_id,
"type": "job_status",
"phase": current.get("phase", "done"),
"type": "warning",
"phase": "initializing",
"timestamp": _iso(datetime.now(UTC)),
"payload": {"status": current["status"], "progress": current.get("progress", 0)},
"payload": {
"code": "durable_replay_temporarily_unavailable",
"message": "进度同步暂时不可用,任务仍在继续;正在自动重试。",
"recoverable": True,
},
},
event_id=str(cursor + 1),
)
return
next_durable_poll = loop.time() + _POLL_INTERVAL_S
next_durable_poll = loop.time() + max(_POLL_INTERVAL_S, 3.0)
if live_queue is None:
await asyncio.sleep(max(0.01, next_durable_poll - loop.time()))
continue
@@ -158,20 +158,19 @@ def _clarification_blocks_ready(
*,
is_fresh: bool,
) -> bool:
"""Position intent must wait for a fresh clarification answer.
"""A fresh clarification always pauses intent resolution.
The generic Step-1 route intentionally preserves READY-first behavior for
stale tool calls in historical snapshots. Position roundtable instead has
a four-part hand-off whose missing context is requested interactively, so
a newly dispatched clarification is a strict terminal pause. A historical
tool call from the preceding user-assistance turn must not block the next
user answer from reaching READY.
A tool call and a premature ``[INTENT_READY]`` block may coexist in the
same upstream run. The tool call is an explicit request for user input,
so it wins for every Step-1 workspace (including 8BF): do not consume the
assistance card before the user can answer it. Stale calls from earlier
turns remain harmless because only a fresh call blocks READY.
``intent_agent_id`` stays in the signature for route-call compatibility
and to keep the policy extensible per agent if a future flow needs one.
"""
return (
intent_agent_id == POSITION_INTENT_AGENT_ID
and bool(clarification_calls)
and is_fresh
)
del intent_agent_id
return bool(clarification_calls) and is_fresh
def _intent_thread_metadata(
@@ -195,17 +194,22 @@ def _intent_thread_metadata(
if not task_id:
return metadata
metadata.update(
{
"taskId": task_id,
"position_roundtable_task_id": task_id,
"position_task_id": f"{task_id}:intelligence",
"position_id": "intelligence",
"position_roundtable_phase": "intent",
}
)
if intent_agent_id == POSITION_INTENT_AGENT_ID:
metadata["position_roundtable_intent_agent_id"] = intent_agent_id
metadata["taskId"] = task_id
# 8BF 使用通用意图接口但有明确的上下文 kind;它不能被岗位会商的
# 会话索引误认为岗位任务,否则两个页面的历史记录会串在一起。
# 其它历史调用保持原有 metadata 形状,避免影响既有会商恢复逻辑。
is_eightbf_context = task_context.get("kind") == "eightbf_roundtable_task"
if intent_agent_id == POSITION_INTENT_AGENT_ID or not is_eightbf_context:
metadata.update(
{
"position_roundtable_task_id": task_id,
"position_task_id": f"{task_id}:intelligence",
"position_id": "intelligence",
"position_roundtable_phase": "intent",
}
)
if intent_agent_id == POSITION_INTENT_AGENT_ID:
metadata["position_roundtable_intent_agent_id"] = intent_agent_id
return metadata
@@ -691,11 +695,10 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
)
clarification_is_fresh = current_rounds > past_rounds
# A position-roundtable clarification is an explicit pause.
# Some models emit a premature [INTENT_READY] text after the
# tool call in the same upstream run. For this dedicated flow
# the fresh clarification must win so the user can answer it;
# generic roundtable keeps its historical READY-first order.
# A fresh clarification is an explicit pause. Some models emit
# a premature [INTENT_READY] block after its tool call in the
# same upstream run; the user-assistance card must win so the
# user can answer it before any summary is accepted.
dispatch_msg = _find_dispatch_message(messages)
tool_calls = dispatch_msg.get("tool_calls") or []
clarification_calls = [
@@ -705,10 +708,10 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
and (tc.get("name") or "").lower() == "ask_clarification"
]
# --- 终态分支 1:结构化意图已就绪(最高优先级)---
# --- 终态分支 1:结构化意图已就绪(无新追问时)---
# 模型可能在较早的 AIMessage 里已输出 [INTENT_READY],而最后一轮
# 仍残留 ask_clarification 的 tool_calls;必须先扫 READY,否则前端
# 会错误展示澄清卡片且 intentReady 无法解锁 Step 2。
# 仍残留 ask_clarification 的 tool_calls;若它是本轮新产生的追问,
# 必须先等待用户回答,不能让 READY 覆盖可交互的协助卡。
summary: dict[str, Any] | None = None
ready_text: str = ""
for msg in reversed(messages):
@@ -59,6 +59,7 @@ from app.gateway.document_rewrite_summary import build_document_rewrite_summary
from app.gateway.path_utils import aresolve_thread_virtual_path
from deerflow.config.app_config import AppConfig
from deerflow.models import create_chat_model
from deerflow.utils.stream_text import InlineThinkTagFilter
logger = logging.getLogger(__name__)
@@ -143,6 +144,8 @@ class DocumentRewriteRequest(BaseModel):
instruction: str = Field(..., min_length=1, max_length=4_000)
style: str | None = Field(default=None)
model_name: str | None = Field(default=None, alias="modelName")
generate_images: bool = Field(default=False, alias="generateImages")
max_generated_images: int = Field(default=2, alias="maxGeneratedImages", ge=1, le=4)
expected_hash: str | None = Field(default=None, alias="expectedHash")
model_config = {"populate_by_name": True}
@@ -193,12 +196,7 @@ def _artifact_rewrite_scope(thread_id: str, virtual_path: str) -> str:
def _artifact_job_matches(job: dict, *, thread_id: str, virtual_path: str) -> bool:
snapshot = job.get("input_snapshot")
return bool(
isinstance(snapshot, dict)
and snapshot.get("kind") == "artifact_document_rewrite"
and snapshot.get("thread_id") == thread_id
and snapshot.get("path") == virtual_path
)
return bool(isinstance(snapshot, dict) and snapshot.get("kind") == "artifact_document_rewrite" and snapshot.get("thread_id") == thread_id and snapshot.get("path") == virtual_path)
def _artifact_job_response(job: dict) -> dict:
@@ -215,6 +213,8 @@ def _artifact_job_response(job: dict) -> dict:
"instruction": str(snapshot.get("instruction") or ""),
"style": str(snapshot.get("style") or "professional"),
"modelName": snapshot.get("model_name"),
"generateImages": bool(snapshot.get("generate_images")),
"maxGeneratedImages": int(snapshot.get("max_generated_images") or 2),
"originalContent": snapshot.get("original_content") or "",
},
}
@@ -266,10 +266,7 @@ async def _stream_durable_artifact_rewrite_job(
yield _sse(
"error",
{
"message": str(
current.get("error_message")
or "全文改写任务未完成,原文件未被修改。"
),
"message": str(current.get("error_message") or "全文改写任务未完成,原文件未被修改。"),
"stage": str(current.get("phase") or "generation"),
},
)
@@ -397,6 +394,8 @@ async def start_durable_document_rewrite_job(
"instruction": body.instruction.strip(),
"style": body.style or "professional",
"model_name": body.model_name,
"generate_images": body.generate_images,
"max_generated_images": body.max_generated_images,
}
request_hash = hashlib.sha256(
json.dumps(
@@ -405,6 +404,8 @@ async def start_durable_document_rewrite_job(
"instruction": body.instruction.strip(),
"style": body.style or "professional",
"model_name": body.model_name,
"generate_images": body.generate_images,
"max_generated_images": body.max_generated_images,
},
ensure_ascii=False,
sort_keys=True,
@@ -468,13 +469,7 @@ async def list_durable_document_rewrite_jobs(
virtual_path = path.strip()
scope = _artifact_rewrite_scope(thread_id, virtual_path)
rows = await job_store.list_by_session(scope, user_id=user_id, limit=limit)
return {
"items": [
_artifact_job_response(row)
for row in rows
if _artifact_job_matches(row, thread_id=thread_id, virtual_path=virtual_path)
]
}
return {"items": [_artifact_job_response(row) for row in rows if _artifact_job_matches(row, thread_id=thread_id, virtual_path=virtual_path)]}
@router.get("/document-rewrite/jobs/{job_id}/stream", response_class=StreamingResponse)
@@ -665,10 +660,14 @@ async def rewrite_document(
"plan": requirements_plan,
},
)
inline_think_filter = InlineThinkTagFilter()
async for chunk in model.astream(messages, config={"run_name": "document_rewrite"}):
thinking_delta, text_delta = _extract_chunk_parts(chunk)
if thinking_delta:
yield _sse("thinking", {"chunk": thinking_delta})
text_delta, inline_thinking = inline_think_filter.push_parts(text_delta)
if inline_thinking:
yield _sse("thinking", {"chunk": inline_thinking})
if text_delta:
draft_parts.append(text_delta)
yield _sse(
@@ -679,6 +678,19 @@ async def rewrite_document(
},
)
tail, inline_thinking_tail = inline_think_filter.finish_parts()
if inline_thinking_tail:
yield _sse("thinking", {"chunk": inline_thinking_tail})
if tail:
draft_parts.append(tail)
yield _sse(
"rewrite_delta",
{
"delta": tail,
"charCount": _count_document_chars("".join(draft_parts)),
},
)
rewritten = "".join(draft_parts).strip()
yield _sse("validation_started", {})
validation_error = _markdown_validation_error(rewritten)
@@ -688,11 +700,7 @@ async def rewrite_document(
heading_count_before = sum(1 for line in original.splitlines() if line.lstrip().startswith("#"))
heading_count_after = sum(1 for line in rewritten.splitlines() if line.lstrip().startswith("#"))
warnings = (
["标题数量发生变化,请在前后对比中确认结构。"]
if heading_count_before != heading_count_after
else []
)
warnings = ["标题数量发生变化,请在前后对比中确认结构。"] if heading_count_before != heading_count_after else []
yield _sse(
"validation_completed",
{
@@ -891,12 +899,21 @@ async def rewrite_selection(
async def generate():
try:
model = create_chat_model(name=body.model_name, thinking_enabled=thinking_enabled, app_config=config)
inline_think_filter = InlineThinkTagFilter()
async for chunk in model.astream(messages, config={"run_name": "writing_rewrite"}):
thinking_delta, text_delta = _extract_chunk_parts(chunk)
if thinking_delta:
yield _sse("thinking", {"chunk": thinking_delta})
text_delta, inline_thinking = inline_think_filter.push_parts(text_delta)
if inline_thinking:
yield _sse("thinking", {"chunk": inline_thinking})
if text_delta:
yield _sse("text", {"chunk": text_delta})
tail, inline_thinking_tail = inline_think_filter.finish_parts()
if inline_thinking_tail:
yield _sse("thinking", {"chunk": inline_thinking_tail})
if tail:
yield _sse("text", {"chunk": tail})
yield _sse("done", {})
except Exception as e:
logger.exception("writing/rewrite failed: thread=%s action=%s", body.thread_id, action)
+8 -7
View File
@@ -101,7 +101,7 @@ models:
max_retries: 2
stream_usage: true
supports_thinking: true
supports_reasoning_effort: false
supports_reasoning_effort: true
supports_vision: false
- name: qwen3.6
display_name: qwen3.6
@@ -207,17 +207,18 @@ vision:
# max_tokens: 2048
# 深度研究可选的报告配图。默认关闭;仅在部署方配置文生图模型后,前端才会展示开关。
# 使用 OpenAI 兼容的 POST {base_url}/images/generations 协议,并强制要求 b64_json
# 返回,以便将图片保存为当前研究会话的受鉴权产物,而不是引用会过期的公网 URL。
# provider: openai_compatible 使用 POST {base_url}/images/generations 并要求 b64_json。
# provider: dashscope_native 用于千问 Token Plan 的 qwen-image-3.0-pro / wan2.7-image:
# 使用原生多模态 generation 接口,服务返回的临时图片 URL 会立即下载并保存为受鉴权产物。
deep_research:
image_generation:
enabled: true
provider: openai_compatible
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation # 例如 https://api.openai.com/v1
provider: dashscope_native
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation
api_key: sk-sp-H.XDIPL.Zgvs.MEQCIBasaS2RPHwgNUpGVfFJeRaTp5UgmZ4Y2rCVGNtpwfD1AiBTFg3S-uE_gqVDnHgdyEhDwcfaVRLi92xviye80AZnkg # 生产环境建议填 $DEEP_RESEARCH_IMAGE_API_KEY
model: wan2.7-image # 例如 gpt-image-1
model: wan2.7-image # 也可设为 qwen-image-3.0-pro
size: 1024x1024
# quality: standard
watermark: false
timeout_seconds: 120
# browser_act 相关配置
@@ -31,7 +31,7 @@ path and commit below, and keep the Apache-2.0 notice in the distribution.
| `runners/deep.py` | `gpt_researcher/skills/deep_research.py` | Breadth/depth branching, learnings and follow-up questions are preserved with an explicit global budget, semaphores and cooperative cancellation. |
| `runners/multi_agent.py` | `multi_agents/agents/{orchestrator,editor,writer,fact_checker}.py` | Editor/researcher/writer/reviewer roles become a LangGraph 1.x `StateGraph`, with the review pause persisted as a durable job state. |
| `adapters/context.py`, `adapters/material.py` | `gpt_researcher/context/compression.py`, `gpt_researcher/skills/researcher.py` | Retains context/de-duplication intent while preserving DeerFlow provenance (`rec_uuid`, source id, source type) and enforcing the existing material-provider boundary. |
| `runners/images.py`, `adapters/image.py` | `gpt_researcher/skills/image_generator.py` | Retains report-image placement intent, but image generation uses a configured DeerFlow OpenAI-compatible adapter and protected hidden-thread artifacts. |
| `runners/images.py`, `adapters/image.py` | `gpt_researcher/skills/image_generator.py` | Retains report-image placement intent, while configured OpenAI-compatible and DashScope-native adapters both persist protected hidden-thread artifacts. |
## Required adaptation invariants
@@ -48,6 +48,11 @@ class PersistingEventSink:
self._session_id = session_id
self._job_id = job_id
self._on_event = on_event
# A durable event row normally owns the sequence. When the event
# database is temporarily unavailable, use distinct negative sequence
# numbers for the live-only path so clients can render every milestone
# without advancing their durable ``after`` cursor.
self._fallback_seq = 0
async def emit(
self,
@@ -56,26 +61,66 @@ class PersistingEventSink:
phase: ResearchPhase = "initializing",
payload: dict | None = None,
) -> DeepResearchEvent:
row = await self._repo.append(
session_id=self._session_id,
job_id=self._job_id,
event_type=type,
phase=phase,
payload=payload or {},
)
# Reconstruct the envelope from the persisted row (seq is now assigned).
event = DeepResearchEvent(
seq=int(row.get("seq", 0)),
session_id=self._session_id,
job_id=self._job_id,
type=row.get("event_type", type),
phase=row.get("phase", phase),
payload=row.get("payload") or payload or {},
)
if self._on_event is not None:
await self._on_event(event.to_sse_dict())
event_payload = payload or {}
try:
row = await self._repo.append(
session_id=self._session_id,
job_id=self._job_id,
event_type=type,
phase=phase,
payload=event_payload,
)
# Reconstruct the envelope from the persisted row (seq is now assigned).
event = DeepResearchEvent(
seq=int(row.get("seq", 0)),
session_id=self._session_id,
job_id=self._job_id,
type=row.get("event_type", type),
phase=row.get("phase", phase),
payload=row.get("payload") or event_payload,
)
except Exception: # noqa: BLE001
# Progress persistence must not turn a recoverable database blip
# into a failed report. The job/status stores still record terminal
# state; this live-only frame lets an attached UI stay responsive
# until durable replay becomes available again after reconnect.
self._fallback_seq -= 1
logger.exception(
"deep-research event persistence failed; continuing job with a live-only event (job=%s, type=%s, phase=%s)",
self._job_id,
type,
phase,
)
event = DeepResearchEvent(
seq=self._fallback_seq,
session_id=self._session_id,
job_id=self._job_id,
type=type,
phase=phase,
payload={**event_payload, "eventPersistenceDegraded": True},
)
await self._publish_event(event)
return event
async def _publish_event(self, event: DeepResearchEvent) -> None:
"""Push a persisted or live-only event without risking the worker."""
if self._on_event is None:
return
wire = event.to_sse_dict()
if event.seq < 0:
wire["liveId"] = f"event-persistence-fallback:{self._job_id}:{abs(event.seq)}"
try:
await self._on_event(wire)
except Exception: # noqa: BLE001
# A browser disconnect or a live-hub issue must not abort report
# generation; persisted rows will still be replayable on reconnect.
logger.warning(
"deep-research live event publish failed; job continues (job=%s, seq=%s)",
self._job_id,
event.seq,
exc_info=True,
)
async def publish_live(
self,
type: str, # noqa: A002
@@ -85,16 +130,24 @@ class PersistingEventSink:
) -> None:
if self._on_event is None:
return
await self._on_event(
{
"seq": 0,
"sessionId": self._session_id,
"jobId": self._job_id,
"type": type,
"phase": phase,
"payload": payload or {},
}
)
try:
await self._on_event(
{
"seq": 0,
"sessionId": self._session_id,
"jobId": self._job_id,
"type": type,
"phase": phase,
"payload": payload or {},
}
)
except Exception: # noqa: BLE001
logger.warning(
"deep-research live frame publish failed; job continues (job=%s, type=%s)",
self._job_id,
type,
exc_info=True,
)
class NullEventSink:
@@ -1,14 +1,17 @@
"""Optional, DeerFlow-configured report image generation.
The upstream GPT Researcher image skill owns its provider configuration, so it
cannot be safely reused in DeerFlow. This adapter instead talks to a deployment
configured OpenAI-compatible ``/images/generations`` endpoint. It is never
configured from a browser request: model credentials stay in ``config.yaml``
and the per-session snapshot contains only the boolean ``generate_images``.
cannot be safely reused in DeerFlow. This adapter supports both a deployment
configured OpenAI-compatible ``/images/generations`` endpoint and the native
DashScope multimodal-generation endpoint used by Qwen Image 3.0 and Wan 2.7.
It is never configured from a browser request: model credentials stay in
``config.yaml`` and the per-session snapshot contains only the boolean
``generate_images``.
Images are requested as base64 rather than provider-hosted URLs. The runner can
therefore put the exact bytes into the session's protected artifact directory;
reports never depend on an expiring public image URL.
OpenAI-compatible images are requested as base64; DashScope image URLs are
downloaded immediately. The runner therefore always puts exact bytes into the
session's protected artifact directory, and reports never depend on an expiring
public image URL.
"""
from __future__ import annotations
@@ -16,11 +19,13 @@ from __future__ import annotations
import base64
import binascii
from typing import Any, Literal, Protocol, runtime_checkable
from urllib.parse import urlparse
import httpx
from pydantic import BaseModel, Field, field_validator
_MAX_IMAGE_BYTES = 20 * 1024 * 1024
_DASHSCOPE_GENERATION_PATH = "/api/v1/services/aigc/multimodal-generation/generation"
class ImageGenerationError(RuntimeError):
@@ -28,17 +33,17 @@ class ImageGenerationError(RuntimeError):
class ImageGenerationSettings(BaseModel):
"""Server-only configuration for an OpenAI-compatible image endpoint.
"""Server-only configuration for a report image endpoint.
Example ``config.yaml`` section::
deep_research:
image_generation:
enabled: true
provider: openai_compatible
base_url: https://api.openai.com/v1
provider: dashscope_native
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation
api_key: $DEEP_RESEARCH_IMAGE_API_KEY
model: gpt-image-1
model: qwen-image-3.0-pro
``api_key`` is resolved by :class:`AppConfig` while loading YAML, but this
object is deliberately not placed in ``DeepResearchConfig`` and is never
@@ -46,12 +51,13 @@ class ImageGenerationSettings(BaseModel):
"""
enabled: bool = False
provider: Literal["openai_compatible"] = "openai_compatible"
provider: Literal["openai_compatible", "dashscope_native"] = "openai_compatible"
base_url: str = ""
api_key: str = ""
model: str = ""
size: str = "1024x1024"
quality: str | None = None
watermark: bool = False
timeout_seconds: float = Field(default=120, ge=1, le=300)
model_config = {"extra": "ignore"}
@@ -215,11 +221,203 @@ class OpenAICompatibleImageProvider:
return {"data": data, "mime_type": mime_type, "alt": alt, "prompt": prompt}
class DashScopeNativeImageProvider:
"""Generate report illustrations through DashScope's native image API.
Qwen Image 3.0 and Wan 2.7 do not implement the OpenAI
``/images/generations`` request/response contract. They receive a
multimodal ``input.messages`` payload and normally return a short-lived
image URL. The URL is downloaded immediately so the research report keeps
a protected local artifact, just like the OpenAI-compatible provider.
"""
def __init__(
self,
settings: ImageGenerationSettings,
*,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
if not settings.is_configured:
raise ValueError("Image generation provider is not configured")
self._settings = settings
self._transport = transport
async def generate(
self,
prompt: str,
*,
section_id: str = "",
user_id: str = "",
) -> dict[str, Any]:
del user_id # Credentials are deployment-scoped; users never supply them.
body = _dashscope_request_body(self._settings, prompt)
headers = {
"Authorization": f"Bearer {self._settings.api_key}",
"Content-Type": "application/json",
}
try:
async with httpx.AsyncClient(
timeout=self._settings.timeout_seconds,
transport=self._transport,
follow_redirects=True,
) as client:
response = await client.post(
_dashscope_generation_endpoint(self._settings.base_url),
json=body,
headers=headers,
)
response.raise_for_status()
payload = response.json()
encoded, image_url, mime_type = _dashscope_image_result(payload)
if encoded:
data = _decode_image_base64(encoded)
elif image_url:
data, mime_type = await _download_dashscope_image(client, image_url, mime_type)
else:
raise ImageGenerationError("千问图片生成服务未返回图片地址或图片数据")
except ImageGenerationError:
raise
except httpx.TimeoutException as exc:
raise ImageGenerationError("图片生成服务响应超时") from exc
except httpx.HTTPStatusError as exc:
raise ImageGenerationError(_http_error_message(exc.response)) from exc
except httpx.HTTPError as exc:
raise ImageGenerationError("图片生成服务暂时不可用") from exc
except ValueError as exc:
raise ImageGenerationError("图片生成服务返回了无效响应") from exc
if not data or len(data) > _MAX_IMAGE_BYTES:
raise ImageGenerationError("生成图片为空或超过大小限制")
alt = f"{section_id or '研究报告'}配图"
return {"data": data, "mime_type": _normalise_mime_type(mime_type), "alt": alt, "prompt": prompt}
def _dashscope_generation_endpoint(base_url: str) -> str:
"""Resolve a native DashScope endpoint without relying on a workspace id.
Token Plan deployments commonly expose the full native endpoint, while
regular DashScope configuration often uses just an API origin. Supporting
both avoids an accidental ``.../generation/images/generations`` path.
"""
base = base_url.rstrip("/")
if base.endswith(_DASHSCOPE_GENERATION_PATH):
return base
parsed = urlparse(base)
if not parsed.scheme or not parsed.netloc:
return base
return f"{parsed.scheme}://{parsed.netloc}{_DASHSCOPE_GENERATION_PATH}"
def _dashscope_request_body(settings: ImageGenerationSettings, prompt: str) -> dict[str, Any]:
parameters: dict[str, Any] = {
"n": 1,
"size": settings.size.replace("x", "*").replace("X", "*"),
"watermark": settings.watermark,
}
# ``prompt_extend`` is a Qwen Image-only parameter. Wan 2.7 rejects
# unrelated generation options, so do not send it there.
if settings.model.strip().lower().startswith("qwen-image-"):
parameters["prompt_extend"] = True
return {
"model": settings.model,
"input": {
"messages": [
{
"role": "user",
"content": [{"text": prompt}],
}
]
},
"parameters": parameters,
}
def _dashscope_image_result(payload: object) -> tuple[str, str, str]:
"""Return optional base64, image URL and MIME type from native output."""
if not isinstance(payload, dict):
return "", "", ""
output = payload.get("output")
choices = output.get("choices") if isinstance(output, dict) else None
if not isinstance(choices, list):
return "", "", ""
for choice in choices:
message = choice.get("message") if isinstance(choice, dict) else None
content = message.get("content") if isinstance(message, dict) else None
if not isinstance(content, list):
continue
for part in content:
if not isinstance(part, dict):
continue
encoded = str(part.get("b64_json") or "")
raw_image_url = part.get("image") or part.get("image_url")
if isinstance(raw_image_url, dict):
raw_image_url = raw_image_url.get("url")
image_url = str(raw_image_url or "")
if encoded or image_url:
return encoded, image_url, str(part.get("mime_type") or "")
return "", "", ""
async def _download_dashscope_image(
client: httpx.AsyncClient,
image_url: str,
mime_type: str,
) -> tuple[bytes, str]:
"""Download only an HTTPS model result, never forwarding the API key."""
parsed = urlparse(image_url)
if parsed.scheme != "https" or not parsed.netloc:
raise ImageGenerationError("千问图片生成服务返回了不安全的图片地址")
response = await client.get(image_url, headers={"Accept": "image/png,image/jpeg,image/webp"})
response.raise_for_status()
final_url = urlparse(str(response.url))
if final_url.scheme != "https" or not final_url.netloc:
raise ImageGenerationError("千问图片下载地址不安全")
response_mime_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
return response.content, response_mime_type or mime_type
def _decode_image_base64(encoded: str) -> bytes:
if encoded.startswith("data:") and "," in encoded:
encoded = encoded.split(",", 1)[1]
try:
return base64.b64decode(encoded, validate=True)
except (ValueError, binascii.Error) as exc:
raise ImageGenerationError("图片生成服务返回的图片编码无效") from exc
def _normalise_mime_type(value: str) -> str:
mime_type = value.lower()
return mime_type if mime_type in {"image/png", "image/jpeg", "image/webp"} else "image/png"
def _http_error_message(response: httpx.Response) -> str:
"""Expose a short provider message without leaking credentials or payloads."""
detail = ""
try:
payload = response.json()
if isinstance(payload, dict):
error = payload.get("error")
source = error if isinstance(error, dict) else payload
message = source.get("message") if isinstance(source, dict) else None
if isinstance(message, str) and message.strip():
detail = f":{message.strip()[:300]}"
except ValueError:
pass
return f"图片生成服务返回 HTTP {response.status_code}{detail}"
def build_image_provider() -> ImageProvider:
"""Build the optional provider from server config, or a safe no-op."""
settings = ImageGenerationSettings.from_app_config()
if not settings.is_configured:
return NullImageProvider()
if settings.provider == "dashscope_native":
return DashScopeNativeImageProvider(settings)
return OpenAICompatibleImageProvider(settings)
@@ -229,5 +427,6 @@ __all__ = [
"ImageProvider",
"NullImageProvider",
"OpenAICompatibleImageProvider",
"DashScopeNativeImageProvider",
"build_image_provider",
]
@@ -33,6 +33,7 @@ from deerflow.agents.deep_research.cancellation import CancellationToken
from deerflow.agents.deep_research.config import DeepResearchConfig, ModelRole
from deerflow.agents.deep_research.events import EventSink
from deerflow.agents.deep_research.types import CompletionResult
from deerflow.utils.stream_text import InlineThinkTagFilter
if TYPE_CHECKING:
from langchain.chat_models import BaseChatModel
@@ -83,6 +84,16 @@ def _content_to_text(resp: Any) -> str:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict):
# Never fall through to ``content`` for structured reasoning
# blocks. Providers differ here, but those blocks are not
# report prose and must not reach ReportChunkEmitter.
if block.get("type") in {
"thinking",
"thinking_delta",
"reasoning",
"reasoning_delta",
}:
continue
parts.append(str(block.get("text") or block.get("content") or ""))
return "".join(parts)
return str(content or "")
@@ -99,7 +110,20 @@ def _reasoning_delta(chunk: Any) -> str:
"""
ak = getattr(chunk, "additional_kwargs", None) or {}
val = ak.get("reasoning_content") or ak.get("reasoning") or ""
return val if isinstance(val, str) else ""
if isinstance(val, str) and val:
return val
# Anthropic/OpenAI-compatible adapters may put the same increment in an
# explicitly typed content block instead of ``additional_kwargs``.
content = getattr(chunk, "content", None)
if isinstance(content, list):
parts = [
str(block.get("text") or block.get("content") or "")
for block in content
if isinstance(block, dict)
and block.get("type") in {"thinking", "thinking_delta", "reasoning", "reasoning_delta"}
]
return "".join(parts)
return ""
def _extract_usage(resp: Any) -> dict[str, Any]:
@@ -234,9 +258,7 @@ class DeerFlowCompletionBackend:
parsed = _try_parse_structured(text, response_schema)
if parsed is not None:
await self._record_usage(model_name, operation, usage)
return CompletionResult(
text=text, model=model_name, usage=usage, structured=parsed
)
return CompletionResult(text=text, model=model_name, usage=usage, structured=parsed)
if attempt == 0:
# One repair re-prompt (§9.5).
logger.warning("deep_research llm: JSON parse failed for %s, retrying", operation)
@@ -306,13 +328,20 @@ class DeerFlowCompletionBackend:
text_parts: list[str] = []
usage: dict[str, Any] = {}
inline_think_filter = InlineThinkTagFilter()
async for chunk in model.astream(to_langchain_messages(messages), **kwargs):
if self._cancel is not None:
self._cancel.raise_if_cancelled()
reasoning = _reasoning_delta(chunk)
if reasoning and on_reasoning is not None:
await on_reasoning(reasoning)
delta = _content_to_text(chunk)
# A few OpenAI-compatible models put their reasoning in the
# ordinary content channel instead of ``reasoning_content``.
# Split it from Markdown even when tags span tokens: callers may
# display a live thought trace, but it never joins ``text``.
delta, inline_reasoning = inline_think_filter.push_parts(_content_to_text(chunk))
if inline_reasoning and on_reasoning is not None:
await on_reasoning(inline_reasoning)
if delta:
text_parts.append(delta)
if on_delta is not None:
@@ -321,6 +350,14 @@ class DeerFlowCompletionBackend:
if any(chunk_usage.values()):
usage = chunk_usage
tail, inline_reasoning_tail = inline_think_filter.finish_parts()
if inline_reasoning_tail and on_reasoning is not None:
await on_reasoning(inline_reasoning_tail)
if tail:
text_parts.append(tail)
if on_delta is not None:
await on_delta(tail)
await self._record_usage(model_name, operation, usage)
return CompletionResult(text="".join(text_parts), model=model_name, usage=usage)
@@ -328,9 +365,7 @@ class DeerFlowCompletionBackend:
async def _record_usage(self, model_name: str, operation: str, usage: dict[str, Any]) -> None:
"""Accumulate per-model totals + emit a ``model_usage`` event."""
bucket = self._usage_totals.setdefault(
model_name, {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
)
bucket = self._usage_totals.setdefault(model_name, {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0})
for k in bucket:
bucket[k] += int(usage.get(k, 0) or 0)
@@ -390,10 +425,7 @@ def _schema_instructions(schema: type[BaseModel]) -> str:
"""Build a compact "respond as JSON matching this schema" instruction."""
props = schema.model_json_schema().get("properties", {})
fields = ", ".join(f'"{k}"' for k in props)
return (
"Respond with ONLY a valid JSON object (no markdown, no prose) "
f"containing these keys: {fields}."
)
return f"Respond with ONLY a valid JSON object (no markdown, no prose) containing these keys: {fields}."
def _inject_schema_hint(messages: list[BaseMessage], hint: str) -> list[BaseMessage]:
@@ -403,21 +435,13 @@ def _inject_schema_hint(messages: list[BaseMessage], hint: str) -> list[BaseMess
return [*messages, SystemMessage(content=hint)]
def _inject_repair_hint(
messages: list[BaseMessage], bad_text: str, schema: type[BaseModel]
) -> list[BaseMessage]:
def _inject_repair_hint(messages: list[BaseMessage], bad_text: str, schema: type[BaseModel]) -> list[BaseMessage]:
"""Re-prompt: 'your last reply was invalid JSON, fix it'."""
from langchain_core.messages import HumanMessage
return [
*messages,
HumanMessage(
content=(
"Your previous reply was not valid JSON and could not be parsed:\n"
f"{bad_text[:500]}\n\n"
"Please respond again with ONLY a valid JSON object."
)
),
HumanMessage(content=(f"Your previous reply was not valid JSON and could not be parsed:\n{bad_text[:500]}\n\nPlease respond again with ONLY a valid JSON object.")),
]
@@ -63,6 +63,11 @@ ALL_PHASES: tuple[ResearchPhase, ...] = (
EventType = Literal[
"job_status",
# Terminal events are kept separate from job_status for the historical
# event timeline. The executor persists these after the DB state has
# been committed, so a reconnecting browser receives an explicit end.
"job_completed",
"job_failed",
"phase_changed",
"plan_created",
"queries_planned",
@@ -364,6 +364,7 @@ class BasicRunner:
messages=[{"role": "system", "content": _SYSTEM}, {"role": "user", "content": prompt}],
operation="write_report",
on_delta=emitter.add,
on_reasoning=emitter.add_thinking,
)
await emitter.flush()
return result.text
@@ -338,6 +338,7 @@ class DetailedRunner(BasicRunner):
],
operation="write_detailed_introduction",
on_delta=emitter.add,
on_reasoning=emitter.add_thinking,
)
await emitter.flush()
return result.text
@@ -373,6 +374,7 @@ class DetailedRunner(BasicRunner):
],
operation="write_detailed_section",
on_delta=emitter.add,
on_reasoning=emitter.add_thinking,
)
await emitter.flush()
return result.text
@@ -389,6 +391,7 @@ class DetailedRunner(BasicRunner):
],
operation="write_detailed_conclusion",
on_delta=emitter.add,
on_reasoning=emitter.add_thinking,
)
await emitter.flush()
return result.text
@@ -64,6 +64,7 @@ async def generate_report_images(
)
image_paths: dict[str, str] = {}
amended_report = report_markdown
total = len(sections)
for position, section in enumerate(sections, 1):
if cancellation is not None:
@@ -72,7 +73,7 @@ async def generate_report_images(
await adapters.events.emit(
"image_generation_started",
phase="illustrating",
payload={"section": section, "position": position},
payload={"section": section, "position": position, "total": total},
)
try:
generated = await adapters.images.generate(
@@ -95,13 +96,26 @@ async def generate_report_images(
await adapters.events.emit(
"image_generated",
phase="illustrating",
payload={"section": section, "position": position, "name": name, "alt": alt},
payload={
"section": section,
"position": position,
"total": total,
"name": name,
"alt": alt,
},
)
except ImageGenerationError as exc:
await adapters.events.emit(
"warning",
phase="illustrating",
payload={"code": "image_generation_failed", "message": str(exc), "recoverable": True},
payload={
"code": "image_generation_failed",
"message": str(exc),
"recoverable": True,
"section": section,
"position": position,
"total": total,
},
)
except Exception: # noqa: BLE001
# Third-party image APIs and artifact storage must never turn a
@@ -114,6 +128,9 @@ async def generate_report_images(
"code": "image_generation_failed",
"message": "报告配图生成失败,已继续保留文字报告",
"recoverable": True,
"section": section,
"position": position,
"total": total,
},
)
@@ -321,6 +321,7 @@ class MultiAgentRunner(BasicRunner):
],
operation="revise_multi_agent_report",
on_delta=emitter.add,
on_reasoning=emitter.add_thinking,
)
await emitter.flush()
return {"report": result.text, "revision_count": int(state.get("revision_count") or 0) + 1}
@@ -64,6 +64,20 @@ class ReportChunkEmitter:
await self._emit(self._buffer[: self._chunk_size])
self._buffer = self._buffer[self._chunk_size :]
async def add_thinking(self, delta: str) -> None:
"""Forward reasoning to the live UI without persisting it as report text."""
if not delta:
return
await self._events.publish_live(
"report_thinking",
phase=self._phase,
payload={
"delta": delta,
"target": self._target,
"reportVersion": self._report_version,
},
)
async def flush(self) -> None:
"""Persist the final tail once the model stream has finished."""
if self._buffer:
@@ -27,14 +27,15 @@ from typing import Any, Protocol, override, runtime_checkable
from langchain.agents import AgentState
from langchain.agents.middleware import SummarizationMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.messages import (
AIMessage,
AnyMessage,
SystemMessage,
ToolMessage,
message_to_dict,
messages_from_dict,
)
from langchain_core.messages import (
AIMessage,
AnyMessage,
HumanMessage,
SystemMessage,
ToolMessage,
message_to_dict,
messages_from_dict,
)
from langgraph.config import get_config
from langgraph.runtime import Runtime
@@ -145,8 +146,8 @@ class _SummaryCacheEntry:
summarized_ids: frozenset[str]
summary_text: str
preserved_ids: frozenset[str] = frozenset()
preserved_messages: tuple[AnyMessage, ...] = ()
preserved_ids: frozenset[str] = frozenset()
preserved_messages: tuple[AnyMessage, ...] = ()
# Process-wide summary cache shared across every middleware instance.
@@ -160,51 +161,99 @@ class _SummaryCacheEntry:
# so only the first threshold crossing (or a genuine re-compaction) pays for an
# LLM call. A lock guards the non-atomic LRU eviction against concurrent
# threads (subagents / parallel conversations share this process).
_GLOBAL_SUMMARY_CACHE: OrderedDict[str, _SummaryCacheEntry] = OrderedDict()
_GLOBAL_SUMMARY_CACHE_LOCK = threading.Lock()
_STATE_CACHE_KEY = "context_summary_cache"
def _clear_global_summary_cache() -> None:
"""Drop every cached summary. Intended for tests/diagnostics only."""
with _GLOBAL_SUMMARY_CACHE_LOCK:
_GLOBAL_SUMMARY_CACHE.clear()
def _serialize_cache_entry(entry: _SummaryCacheEntry) -> dict[str, Any]:
"""Serialize a summary cache entry so it can live in LangGraph state."""
return {
"version": 1,
"summarized_ids": sorted(entry.summarized_ids),
"summary_text": entry.summary_text,
"preserved_ids": sorted(entry.preserved_ids),
"preserved_messages": [message_to_dict(message) for message in entry.preserved_messages],
}
def _deserialize_cache_entry(raw: Any) -> _SummaryCacheEntry | None:
"""Best-effort restore of a state-backed summary cache entry."""
if not isinstance(raw, dict):
return None
try:
summary_text = raw.get("summary_text")
if not isinstance(summary_text, str) or not summary_text:
return None
summarized_ids = raw.get("summarized_ids") or []
preserved_ids = raw.get("preserved_ids") or []
preserved_messages_raw = raw.get("preserved_messages") or []
if not isinstance(summarized_ids, list) or not isinstance(preserved_ids, list) or not isinstance(preserved_messages_raw, list):
return None
preserved_messages = tuple(messages_from_dict(preserved_messages_raw))
return _SummaryCacheEntry(
summarized_ids=frozenset(str(item) for item in summarized_ids if item is not None),
summary_text=summary_text,
preserved_ids=frozenset(str(item) for item in preserved_ids if item is not None),
preserved_messages=preserved_messages,
)
except Exception:
logger.debug("summarization: failed to restore state-backed summary cache", exc_info=True)
return None
_GLOBAL_SUMMARY_CACHE: OrderedDict[str, _SummaryCacheEntry] = OrderedDict()
_GLOBAL_SUMMARY_CACHE_LOCK = threading.Lock()
_STATE_CACHE_KEY = "context_summary_cache"
def _clear_global_summary_cache() -> None:
"""Drop every cached summary. Intended for tests/diagnostics only."""
with _GLOBAL_SUMMARY_CACHE_LOCK:
_GLOBAL_SUMMARY_CACHE.clear()
def _serialize_cache_entry(entry: _SummaryCacheEntry) -> dict[str, Any]:
"""Serialize a summary cache entry so it can live in LangGraph state."""
return {
"version": 1,
"summarized_ids": sorted(entry.summarized_ids),
"summary_text": entry.summary_text,
"preserved_ids": sorted(entry.preserved_ids),
"preserved_messages": [message_to_dict(message) for message in entry.preserved_messages],
}
def _deserialize_cache_entry(raw: Any) -> _SummaryCacheEntry | None:
"""Best-effort restore of a state-backed summary cache entry."""
if not isinstance(raw, dict):
return None
try:
summary_text = raw.get("summary_text")
if not isinstance(summary_text, str) or not summary_text:
return None
summarized_ids = raw.get("summarized_ids") or []
preserved_ids = raw.get("preserved_ids") or []
preserved_messages_raw = raw.get("preserved_messages") or []
if not isinstance(summarized_ids, list) or not isinstance(preserved_ids, list) or not isinstance(preserved_messages_raw, list):
return None
preserved_messages = tuple(messages_from_dict(preserved_messages_raw))
return _SummaryCacheEntry(
summarized_ids=frozenset(str(item) for item in summarized_ids if item is not None),
summary_text=summary_text,
preserved_ids=frozenset(str(item) for item in preserved_ids if item is not None),
preserved_messages=preserved_messages,
)
except Exception:
logger.debug("summarization: failed to restore state-backed summary cache", exc_info=True)
return None
def _has_nonempty_human_message(message: AnyMessage) -> bool:
"""Whether a message is a real, non-empty user turn.
Some OpenAI-compatible gateways reject a completion request that contains
only ``system`` / ``assistant`` / ``tool`` roles. That can legitimately
happen after compaction: the active model call is continuing a tool turn,
while the user request that started the turn has already moved into the
internal summary.
"""
if not isinstance(message, HumanMessage):
return False
content = message.content
if isinstance(content, str):
return bool(content.strip())
if isinstance(content, list):
return any(bool(block.strip()) if isinstance(block, str) else bool((block.get("text") or block.get("content") or "").strip()) if isinstance(block, dict) else False for block in content)
return bool(str(content).strip())
def _ensure_latest_human_message(
source_messages: list[AnyMessage],
compressed_messages: list[AnyMessage],
) -> list[AnyMessage]:
"""Keep the latest real user request visible to strict chat gateways.
The message is inserted after leading system messages, so it neither
exposes the internal summary to the UI nor splits an AI tool-call from its
following ToolMessages. When there was no user turn in the source at all,
do not fabricate one: that is an upstream caller bug and should remain
diagnosable rather than silently changing the task.
"""
if any(_has_nonempty_human_message(message) for message in compressed_messages):
return compressed_messages
latest_human = next(
(message for message in reversed(source_messages) if _has_nonempty_human_message(message)),
None,
)
if latest_human is None:
logger.warning("summarization: compressed model request has no user message and source history contains none; passing it through unchanged")
return compressed_messages
insert_at = 0
while insert_at < len(compressed_messages) and isinstance(compressed_messages[insert_at], SystemMessage):
insert_at += 1
return [*compressed_messages[:insert_at], latest_human, *compressed_messages[insert_at:]]
class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
@@ -231,8 +280,8 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
# Per-thread cache of the most recent summary: thread_id -> (summarized message ids, summary text).
# Points at the *process-wide* cache (not a fresh per-instance dict) so a
# summary survives across runs/turns — see ``_GLOBAL_SUMMARY_CACHE``.
self._summary_cache: OrderedDict[str, _SummaryCacheEntry] = _GLOBAL_SUMMARY_CACHE
self._pending_state_cache_update: dict[str, Any] | None = None
self._summary_cache: OrderedDict[str, _SummaryCacheEntry] = _GLOBAL_SUMMARY_CACHE
self._pending_state_cache_update: dict[str, Any] | None = None
@override
def before_model(self, state: AgentState, runtime: Runtime) -> dict | None:
@@ -248,21 +297,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
"""Async counterpart of :meth:`before_model` — also a no-op."""
return None
@override
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
"""Persist the latest reusable summary cache into thread state."""
if self._pending_state_cache_update is None:
return None
update = self._pending_state_cache_update
self._pending_state_cache_update = None
return {_STATE_CACHE_KEY: update}
@override
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None:
"""Async counterpart of :meth:`after_model`."""
return self.after_model(state, runtime)
# ---- wrap_model_call: transient, display-preserving compression ----------
@override
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
"""Persist the latest reusable summary cache into thread state."""
if self._pending_state_cache_update is None:
return None
update = self._pending_state_cache_update
self._pending_state_cache_update = None
return {_STATE_CACHE_KEY: update}
@override
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None:
"""Async counterpart of :meth:`after_model`."""
return self.after_model(state, runtime)
# ---- wrap_model_call: transient, display-preserving compression ----------
@override
def wrap_model_call(
@@ -376,20 +425,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
return None
thread_id = self._resolve_request_thread_id(request)
if thread_id is None:
logger.debug("summarization: thread_id unresolved; cross-turn summary cache disabled for this call")
cached = self._get_cache_entry(thread_id) if thread_id else None
if cached is None:
cached = _deserialize_cache_entry(getattr(request, "state", {}).get(_STATE_CACHE_KEY))
if cached is not None and thread_id:
self._store_cache_entry(thread_id, cached)
present_ids = {m.id for m in messages}
cache_usable = cached is not None and cached.summarized_ids <= present_ids and cached.preserved_ids <= present_ids
if cache_usable:
preserved = list(cached.preserved_messages)
excluded_ids = cached.summarized_ids | cached.preserved_ids
preserved.extend(m for m in messages if m.id not in excluded_ids)
effective = [self._summary_message(cached.summary_text), *preserved]
if thread_id is None:
logger.debug("summarization: thread_id unresolved; cross-turn summary cache disabled for this call")
cached = self._get_cache_entry(thread_id) if thread_id else None
if cached is None:
cached = _deserialize_cache_entry(getattr(request, "state", {}).get(_STATE_CACHE_KEY))
if cached is not None and thread_id:
self._store_cache_entry(thread_id, cached)
present_ids = {m.id for m in messages}
cache_usable = cached is not None and cached.summarized_ids <= present_ids and cached.preserved_ids <= present_ids
if cache_usable:
preserved = list(cached.preserved_messages)
excluded_ids = cached.summarized_ids | cached.preserved_ids
preserved.extend(m for m in messages if m.id not in excluded_ids)
effective = [self._summary_message(cached.summary_text), *preserved]
effective = _ensure_latest_human_message(messages, effective)
# Decide re-summarization on the *counted* size of the compressed
# context only — NOT ``_should_summarize`` — because that folds in the
# last AIMessage's reported ``usage_metadata`` (system prompt + tool
@@ -400,8 +450,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
# Refresh LRU recency and reuse without an LLM call.
self._touch_cache_entry(thread_id)
logger.info(
"summarization: 上下文压缩(复用缓存摘要,无 LLM 调用) context compaction reusing cached summary "
"thread=%s full_tokens=%d compressed_tokens=%d preserved_msgs=%d",
"summarization: 上下文压缩(复用缓存摘要,无 LLM 调用) context compaction reusing cached summary thread=%s full_tokens=%d compressed_tokens=%d preserved_msgs=%d",
thread_id,
self.token_counter(messages),
self.token_counter(effective),
@@ -415,7 +464,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
# an "it compacts every turn" report is diagnosable straight from the
# backend log: cache=miss → thread_id/key problem; unusable → message-id
# instability; stale → preserved genuinely grew back over the trigger.
cache_state = "miss" if cached is None else "stale"
cache_state = "miss" if cached is None else "stale"
logger.info(
"summarization: compacting thread=%s cache=%s counted_tokens=%d msgs=%d",
thread_id,
@@ -452,22 +501,23 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
) -> list[AnyMessage]:
"""Assemble the compressed message list and update the per-thread cache."""
compressed = [self._summary_message(summary), *preserved]
compressed = _ensure_latest_human_message([*messages_to_summarize, *preserved], compressed)
if thread_id:
summarized_ids = frozenset(m.id for m in messages_to_summarize if m.id is not None)
preserved_ids = {m.id for m in preserved if m.id is not None}
self._store_cache_entry(
thread_id,
entry := _SummaryCacheEntry(
summarized_ids=summarized_ids,
summary_text=summary,
preserved_ids=frozenset(preserved_ids),
preserved_messages=tuple(preserved),
),
)
self._pending_state_cache_update = _serialize_cache_entry(entry)
return compressed
self._store_cache_entry(
thread_id,
entry := _SummaryCacheEntry(
summarized_ids=summarized_ids,
summary_text=summary,
preserved_ids=frozenset(preserved_ids),
preserved_messages=tuple(preserved),
),
)
self._pending_state_cache_update = _serialize_cache_entry(entry)
return compressed
def _get_cache_entry(self, thread_id: str) -> _SummaryCacheEntry | None:
"""Return the cached entry for ``thread_id`` (thread-safe read)."""
@@ -571,7 +621,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
return None
return _resolve_thread_id(runtime)
def _summary_message(self, summary: str) -> SystemMessage:
def _summary_message(self, summary: str) -> SystemMessage:
"""Build the summary message handed to the model.
It is named ``summary`` and flagged ``hide_from_ui`` defensively. Because
@@ -579,21 +629,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
state), the frontend never receives it — the user keeps seeing the
original conversation.
"""
return SystemMessage(
content=(
"Internal conversation summary for continuity only. Use it silently "
"to answer the user's latest request. Never reveal, quote, mention, "
"or describe this summary or the fact that context was compacted.\n\n"
"<internal_context_summary>\n"
f"{summary}\n"
"</internal_context_summary>"
),
return SystemMessage(
content=(
"Internal conversation summary for continuity only. Use it silently "
"to answer the user's latest request. Never reveal, quote, mention, "
"or describe this summary or the fact that context was compacted.\n\n"
"<internal_context_summary>\n"
f"{summary}\n"
"</internal_context_summary>"
),
name="summary",
additional_kwargs={"hide_from_ui": True, "lc_source": "summarization"},
)
@override
def _build_new_messages(self, summary: str) -> list[AnyMessage]:
def _build_new_messages(self, summary: str) -> list[AnyMessage]:
"""Kept for backwards compatibility; delegates to :meth:`_summary_message`."""
return [self._summary_message(summary)]
@@ -68,13 +68,7 @@ class DeepResearchEventRepository:
"""
for attempt in range(max_retries):
async with self._sf() as session:
current_max = (
await session.execute(
select(func.max(DeepResearchEventRow.seq)).where(
DeepResearchEventRow.job_id == job_id
)
)
).scalar() or 0
current_max = (await session.execute(select(func.max(DeepResearchEventRow.seq)).where(DeepResearchEventRow.job_id == job_id))).scalar() or 0
next_seq = int(current_max) + 1
row = DeepResearchEventRow(
job_id=job_id,
@@ -86,49 +80,38 @@ class DeepResearchEventRepository:
)
session.add(row)
try:
# Do not issue ``session.refresh(row)`` after commit. On a
# MySQL read/write-splitting deployment that read can land
# on a lagging replica, which cannot see the just-committed
# event and raises ``Could not refresh instance``. Flush
# assigns generated primary-key/default values on the writer
# connection, so the plain dict is complete before commit.
await session.flush()
result = self._row_to_dict(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
return result
except IntegrityError:
await session.rollback()
continue # retry with recomputed max
raise RuntimeError(
f"deep_research_events.append: seq collision persisted after {max_retries} retries (job={job_id})"
)
raise RuntimeError(f"deep_research_events.append: seq collision persisted after {max_retries} retries (job={job_id})")
async def list_after(
self, job_id: str, *, after: int = 0, limit: int = 200
) -> list[dict[str, Any]]:
async def list_after(self, job_id: str, *, after: int = 0, limit: int = 200) -> list[dict[str, Any]]:
"""Events with ``seq > after`` in ascending order (SSE replay, §15.3)."""
async with self._sf() as session:
stmt = (
select(DeepResearchEventRow)
.where(DeepResearchEventRow.job_id == job_id)
.where(DeepResearchEventRow.seq > after)
.order_by(DeepResearchEventRow.seq.asc())
.limit(limit)
)
stmt = select(DeepResearchEventRow).where(DeepResearchEventRow.job_id == job_id).where(DeepResearchEventRow.seq > after).order_by(DeepResearchEventRow.seq.asc()).limit(limit)
rows = (await session.execute(stmt)).scalars().all()
return [self._row_to_dict(r) for r in rows]
async def last_seq(self, job_id: str) -> int:
async with self._sf() as session:
mx = (
await session.execute(
select(func.max(DeepResearchEventRow.seq)).where(
DeepResearchEventRow.job_id == job_id
)
)
).scalar()
mx = (await session.execute(select(func.max(DeepResearchEventRow.seq)).where(DeepResearchEventRow.job_id == job_id))).scalar()
return int(mx or 0)
async def delete_by_job(self, job_id: str) -> int:
from sqlalchemy import delete
async with self._sf() as session:
result = await session.execute(
delete(DeepResearchEventRow).where(DeepResearchEventRow.job_id == job_id)
)
result = await session.execute(delete(DeepResearchEventRow).where(DeepResearchEventRow.job_id == job_id))
await session.commit()
return result.rowcount
@@ -136,8 +119,6 @@ class DeepResearchEventRepository:
from sqlalchemy import delete
async with self._sf() as session:
result = await session.execute(
delete(DeepResearchEventRow).where(DeepResearchEventRow.session_id == session_id)
)
result = await session.execute(delete(DeepResearchEventRow).where(DeepResearchEventRow.session_id == session_id))
await session.commit()
return result.rowcount
@@ -113,9 +113,18 @@ class DeepResearchJobRepository:
phase=phase,
)
session.add(row)
# Do not issue ``session.refresh(row)`` after commit. A
# MySQL read/write-splitting proxy can route that read to
# a lagging replica, even though the INSERT has already
# committed on the writer. The caller would receive 500
# while an invisible queued job remains in the database.
# ``flush`` materializes ORM defaults on the writer, so we
# can return a complete, durable snapshot without a
# replica-dependent readback.
await session.flush()
result = self._row_to_dict(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row), True
return result, True
except IntegrityError:
# Unique-index conflict → an active job already exists.
pass
@@ -230,13 +239,14 @@ class DeepResearchJobRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
# ── heartbeat ───────────────────────────────────────────────────────────
@@ -351,13 +361,14 @@ class DeepResearchJobRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
# ── two-phase cancel ────────────────────────────────────────────────────
@@ -385,13 +396,14 @@ class DeepResearchJobRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
async def finalize_cancel(
self, job_id: str, *, lease_owner: str | None = None
@@ -419,13 +431,14 @@ class DeepResearchJobRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
# ── resume after human-review interrupt ─────────────────────────────────
@@ -460,13 +473,14 @@ class DeepResearchJobRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
# ── cleanup ─────────────────────────────────────────────────────────────
@@ -74,9 +74,13 @@ class DeepResearchMessageRepository:
seq=int(current_max) + 1,
)
session.add(row)
# Keep the successful append independent of a later replica read.
# The explicit id/seq and Python-side defaults are available after
# flush, before the transaction releases its writer connection.
await session.flush()
snapshot = self._row_to_dict(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
return snapshot
async def list_by_session(
self, session_id: str, *, user_id: str | _AutoSentinel = AUTO, limit: int = 100
@@ -139,10 +143,11 @@ class DeepResearchMessageRepository:
if not result.rowcount:
await session.rollback()
return None
await session.commit()
stmt = select(DeepResearchMessageRow).where(*where)
row = (await session.execute(stmt)).scalar_one_or_none()
return self._row_to_dict(row) if row is not None else None
snapshot = self._row_to_dict(row) if row is not None else None
await session.commit()
return snapshot
async def delete_by_session(self, session_id: str) -> int:
from sqlalchemy import delete
@@ -85,9 +85,13 @@ class DeepResearchSessionRepository:
runtime_thread_id=runtime_thread_id,
)
session.add(row)
# New sessions are a control-plane write. Avoid a post-commit
# refresh because MySQL read/write-splitting can make a just
# created session look absent on a lagging replica.
await session.flush()
snapshot = self._row_to_dict(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
return snapshot
# ── read ────────────────────────────────────────────────────────────────
@@ -187,13 +191,14 @@ class DeepResearchSessionRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchSessionRow).where(DeepResearchSessionRow.id == session_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
async def replace_report_if_unchanged(
self,
@@ -231,13 +236,14 @@ class DeepResearchSessionRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchSessionRow).where(DeepResearchSessionRow.id == session_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
# ── delete ──────────────────────────────────────────────────────────────
@@ -23,6 +23,13 @@ from deerflow.persistence.types import BeijingDateTime, PortableLongText
class DeepResearchSourceRow(Base):
__tablename__ = "deep_research_sources"
__table_args__ = (
# MySQL requires a prefix length when indexing TEXT columns. The
# column remains unbounded so long URLs are preserved; the prefix is
# only an index implementation detail and equality filters still
# compare the complete URL value.
sa.Index("ix_deep_research_sources_url", "url", mysql_length=512),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
session_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
@@ -32,7 +39,7 @@ class DeepResearchSourceRow(Base):
# The query that first surfaced this material.
first_seen_query: Mapped[str | None] = mapped_column(Text, nullable=True)
title: Mapped[str] = mapped_column(Text, nullable=False, default="")
url: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
url: Mapped[str | None] = mapped_column(Text, nullable=True)
raw_content: Mapped[str] = mapped_column(PortableLongText, nullable=False, default="")
snippet: Mapped[str | None] = mapped_column(Text, nullable=True)
source: Mapped[str] = mapped_column(String(256), nullable=False, default="")
@@ -37,9 +37,14 @@ class DeepResearchSourceRepository:
row = DeepResearchSourceRow(session_id=session_id, job_id=job_id, **fields)
session.add(row)
try:
# The source was produced on the worker's write path. Build
# its response on that same connection so a replica-lag read
# cannot turn a successfully collected source into a failed
# research run.
await session.flush()
snapshot = self._row_to_dict(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
return snapshot
except Exception:
await session.rollback()
# Fallback: find existing by rec_uuid or url.
@@ -147,13 +152,14 @@ class DeepResearchSourceRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchSourceRow).where(DeepResearchSourceRow.id == source_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
async def delete_by_session(self, session_id: str) -> int:
from sqlalchemy import delete
@@ -0,0 +1,90 @@
"""Safe helpers for provider text streams.
Some OpenAI-compatible providers place reasoning in the normal ``content``
stream as ``<think>...</think>`` instead of using LangChain's dedicated
reasoning fields. Those fragments must be separated from report/file content
while callers can still surface them in an explicitly marked live UI trace.
"""
from __future__ import annotations
class InlineThinkTagFilter:
"""Separate inline ``<think>`` blocks while preserving token streaming.
Tags can be split across arbitrary provider chunks, so regex-per-chunk is
not safe: it can leak a partial tag or the model's reasoning into the
visible draft. This small state machine holds only the suffix that might
still form a tag and emits all confirmed answer text immediately.
"""
_OPEN_TAG = "<think>"
_CLOSE_TAG = "</think>"
def __init__(self) -> None:
self._inside_think = False
self._pending = ""
@staticmethod
def _possible_tag_prefix_size(content: str, tag: str) -> int:
"""Return the longest suffix which may become the next tag."""
lowered = content.lower()
for size in range(min(len(content), len(tag) - 1), 0, -1):
if lowered[-size:] == tag[:size]:
return size
return 0
def push_parts(self, chunk: str) -> tuple[str, str]:
"""Return ``(visible_text, thinking_text)`` for one provider chunk."""
if not chunk:
return "", ""
remaining = self._pending + chunk
self._pending = ""
visible: list[str] = []
thinking: list[str] = []
while remaining:
tag = self._CLOSE_TAG if self._inside_think else self._OPEN_TAG
marker_at = remaining.lower().find(tag)
if marker_at >= 0:
(thinking if self._inside_think else visible).append(remaining[:marker_at])
remaining = remaining[marker_at + len(tag) :]
self._inside_think = not self._inside_think
continue
suffix_size = self._possible_tag_prefix_size(remaining, tag)
confirmed = remaining[:-suffix_size] if suffix_size else remaining
(thinking if self._inside_think else visible).append(confirmed)
self._pending = remaining[-suffix_size:] if suffix_size else ""
break
return "".join(visible), "".join(thinking)
def push(self, chunk: str) -> str:
"""Compatibility helper returning only confirmed visible text."""
return self.push_parts(chunk)[0]
def finish_parts(self) -> tuple[str, str]:
"""Flush the remaining visible or thinking text at end of stream."""
if self._inside_think:
thinking = self._pending
self._pending = ""
self._inside_think = False
return "", thinking
tail = self._pending
self._pending = ""
return tail, ""
def finish(self) -> str:
"""Compatibility helper returning only the visible stream tail."""
return self.finish_parts()[0]
def strip_inline_think_tags(content: str) -> str:
"""Return complete text with inline model-thinking blocks removed."""
text_filter = InlineThinkTagFilter()
return text_filter.push(content) + text_filter.finish()
__all__ = ["InlineThinkTagFilter", "strip_inline_think_tags"]
@@ -38,17 +38,27 @@ class _ChainStore:
return self.chain
class _AgentStore:
def __init__(self, agents: dict[str, dict[str, object]] | None = None) -> None:
self.agents = agents or {}
async def get_any(self, agent_id: str) -> dict[str, object] | None:
return self.agents.get(agent_id)
def _request(
role: str,
chain: dict[str, object] | None = None,
*,
email: str = "lqq@example.com",
agents: dict[str, dict[str, object]] | None = None,
):
return SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
business_mapping_store=_MappingStore(),
roundtable_chain_store=_ChainStore(chain),
agent_store=_AgentStore(agents),
)
),
state=SimpleNamespace(user=SimpleNamespace(id="admin-id", email=email, system_role=role)),
@@ -130,3 +140,34 @@ async def test_global_mapping_rejects_a_private_chain() -> None:
assert error.value.status_code == 400
assert request.app.state.business_mapping_store.calls == []
@pytest.mark.asyncio
async def test_global_mapping_rejects_a_chain_with_an_unpublished_agent_seat() -> None:
request = _request(
"admin",
{
"id": "chain-8bf",
"title": "岗位协同链",
"is_public": True,
"seats": [
{"agent_id": "published", "name": "已发布智能体"},
{"agent_id": "private", "name": "未发布智能体"},
],
},
agents={
"published": {"id": "published", "user_id": "owner", "published": True, "is_private": False},
"private": {"id": "private", "user_id": "owner", "published": False, "is_private": False},
},
)
with pytest.raises(HTTPException) as error:
await business_mapping_router.update_mapping(
request,
"8BF",
business_mapping_router.BusinessMappingUpdateRequest(chain_id="chain-8bf"),
)
assert error.value.status_code == 400
assert "未发布智能体" in error.value.detail
assert request.app.state.business_mapping_store.calls == []
@@ -0,0 +1,83 @@
"""Fault-tolerance coverage for Deep Research progress events."""
import pytest
from deerflow.agents.deep_research.adapters.event_sink import PersistingEventSink
class _UnavailableEventRepository:
async def append(self, **kwargs): # noqa: ANN003
raise RuntimeError("database temporarily unavailable")
@pytest.mark.asyncio
async def test_event_persistence_failure_keeps_job_live_and_emits_unique_fallback_event():
published: list[dict] = []
async def publish(event: dict) -> None:
published.append(event)
sink = PersistingEventSink(
_UnavailableEventRepository(),
session_id="drs_1",
job_id="drj_1",
on_event=publish,
)
first = await sink.emit("phase_changed", phase="planning", payload={"to": "planning"})
second = await sink.emit("queries_planned", phase="collecting", payload={"queries": ["AI"]})
assert (first.seq, second.seq) == (-1, -2)
assert first.payload["eventPersistenceDegraded"] is True
assert published[0]["liveId"] == "event-persistence-fallback:drj_1:1"
assert published[1]["liveId"] == "event-persistence-fallback:drj_1:2"
@pytest.mark.asyncio
async def test_live_publish_failure_does_not_fail_event_delivery():
class _EventRepository:
async def append(self, **kwargs): # noqa: ANN003
return {
"seq": 1,
"event_type": kwargs["event_type"],
"phase": kwargs["phase"],
"payload": kwargs["payload"],
}
async def broken_publisher(event: dict) -> None: # noqa: ARG001
raise RuntimeError("browser disconnected")
sink = PersistingEventSink(
_EventRepository(),
session_id="drs_1",
job_id="drj_1",
on_event=broken_publisher,
)
event = await sink.emit("phase_changed", phase="planning")
assert event.seq == 1
@pytest.mark.asyncio
async def test_document_rewrite_milestone_is_a_supported_durable_event():
"""Guard the artifact-rewrite job path against EventType regressions."""
class _EventRepository:
async def append(self, **kwargs): # noqa: ANN003
return {
"seq": 1,
"event_type": kwargs["event_type"],
"phase": kwargs["phase"],
"payload": kwargs["payload"],
}
sink = PersistingEventSink(_EventRepository(), session_id="drs_1", job_id="drj_1")
event = await sink.emit(
"document_rewrite",
phase="planning",
payload={"type": "requirements_started"},
)
assert event.type == "document_rewrite"
@@ -0,0 +1,72 @@
"""Failure-ordering regression coverage for final Deep Research report writes."""
import pytest
from app.gateway import deep_research_job_executor as executor_module
from app.gateway.deep_research_job_executor import DeepResearchJobExecutor
from deerflow.agents.deep_research.events import DeepResearchEvent
from deerflow.agents.deep_research.types import DeepResearchResult
class _JobStore:
def __init__(self, session_store) -> None:
self._session_store = session_store
self.progress_calls: list[dict] = []
async def update_progress(self, job_id: str, **fields): # noqa: ARG002
assert self._session_store.report_updates, "report must be persisted before job completion"
self.progress_calls.append(fields)
return {"id": job_id, **fields}
class _SessionStore:
def __init__(self, *, available: bool = True) -> None:
self.available = available
self.report_updates: list[dict] = []
async def update(self, session_id: str, **fields): # noqa: ARG002
if not self.available:
raise RuntimeError("temporary session database failure")
self.report_updates.append(fields)
return {"id": session_id, **fields}
@pytest.mark.asyncio
async def test_report_is_persisted_before_job_is_marked_completed():
sessions = _SessionStore()
jobs = _JobStore(sessions)
runner = DeepResearchJobExecutor(jobs, sessions, event_store=None)
await runner._mark_completed(
"drj_1",
"drs_1",
"worker_1",
DeepResearchResult(report_markdown="# 报告", source_ids=["src_1"]),
)
assert sessions.report_updates[0]["report_markdown"] == "# 报告"
assert jobs.progress_calls[0]["status"] == "completed"
@pytest.mark.asyncio
async def test_report_projection_failure_never_marks_job_completed(monkeypatch):
monkeypatch.setattr(executor_module, "_REPORT_PROJECTION_RETRY_ATTEMPTS", 2)
monkeypatch.setattr(executor_module, "_REPORT_PROJECTION_RETRY_DELAY_SECONDS", 0)
sessions = _SessionStore(available=False)
jobs = _JobStore(sessions)
runner = DeepResearchJobExecutor(jobs, sessions, event_store=None)
with pytest.raises(RuntimeError, match="研究报告未能保存"):
await runner._mark_completed(
"drj_1",
"drs_1",
"worker_1",
DeepResearchResult(report_markdown="# 报告"),
)
assert jobs.progress_calls == []
def test_terminal_timeline_events_are_valid_durable_event_types():
assert DeepResearchEvent(type="job_completed", phase="done").type == "job_completed"
assert DeepResearchEvent(type="job_failed", phase="done").type == "job_failed"
@@ -0,0 +1,538 @@
"""Persistence tests for deep-research sessions, jobs, events, sources, messages.
Uses in-memory SQLite + aiosqlite, mirroring ``test_ai_writing_session_repo``.
Focuses on the durable-job mechanics that are load-bearing for correctness:
- Session CRUD + user isolation.
- Job ``try_create_or_get_active`` idempotency (unique dedupe key).
- Job lease claim / renew / release (atomic CAS).
- Two-phase cancel (``request_cancel`` → ``finalize_cancel``).
- Event seq monotonicity + ``list_after`` replay (SSE reconnect correctness).
Pattern: a plain ``async def _make_repos()`` helper (not a fixture) — matches
the project convention for async DB setup in pytest-asyncio strict mode.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
async def _make_repos():
"""Create an in-memory SQLite repo set with all deep-research tables."""
from deerflow.persistence.base import Base
from deerflow.persistence.deep_research_events.model import DeepResearchEventRow # noqa: F401
from deerflow.persistence.deep_research_events.sql import DeepResearchEventRepository
from deerflow.persistence.deep_research_jobs.model import DeepResearchJobRow # noqa: F401
from deerflow.persistence.deep_research_jobs.sql import DeepResearchJobRepository
from deerflow.persistence.deep_research_messages.model import DeepResearchMessageRow # noqa: F401
from deerflow.persistence.deep_research_messages.sql import DeepResearchMessageRepository
from deerflow.persistence.deep_research_sessions.model import DeepResearchSessionRow # noqa: F401
from deerflow.persistence.deep_research_sessions.sql import DeepResearchSessionRepository
from deerflow.persistence.deep_research_sources.model import DeepResearchSourceRow # noqa: F401
from deerflow.persistence.deep_research_sources.sql import DeepResearchSourceRepository
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
return {
"sessions": DeepResearchSessionRepository(sf),
"jobs": DeepResearchJobRepository(sf),
"events": DeepResearchEventRepository(sf),
"sources": DeepResearchSourceRepository(sf),
"messages": DeepResearchMessageRepository(sf),
"_engine": engine,
}
# ── sessions ────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_session_create_and_get():
repos = await _make_repos()
try:
created = await repos["sessions"].create(id="drs_1", user_id="u1", title="Test", query="AI research", mode="deep")
assert created["id"] == "drs_1"
assert created["status"] == "draft"
got = await repos["sessions"].get("drs_1", user_id="u1")
assert got is not None
assert got["query"] == "AI research"
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_deep_research_writes_never_depend_on_post_commit_refresh(monkeypatch):
"""Control-plane writes must survive a MySQL replica-lag readback."""
from sqlalchemy.ext.asyncio import AsyncSession
async def fail_refresh(self, instance, *args, **kwargs): # noqa: ANN001, ARG001
raise AssertionError("Deep Research writes must not call session.refresh")
monkeypatch.setattr(AsyncSession, "refresh", fail_refresh)
repos = await _make_repos()
try:
session = await repos["sessions"].create(
id="drs_1", user_id="u1", title="Test", query="AI research"
)
assert session["id"] == "drs_1"
assert await repos["sessions"].update("drs_1", user_id="u1", status="running")
job, created = await repos["jobs"].try_create_or_get_active(
id="drj_1", session_id="drs_1", user_id="u1"
)
assert created is True
assert job["id"] == "drj_1"
source = await repos["sources"].upsert(
session_id="drs_1",
id="src_1",
user_id="u1",
title="A",
raw_content="source content",
source="web",
content_hash="hash_1",
)
assert source["id"] == "src_1"
message = await repos["messages"].append(
id="msg_1",
session_id="drs_1",
user_id="u1",
role="assistant",
content="已开始撰写",
)
assert message["id"] == "msg_1"
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_session_user_isolation():
repos = await _make_repos()
try:
await repos["sessions"].create(id="drs_1", user_id="u1", query="q1")
assert await repos["sessions"].get("drs_1", user_id="u2") is None
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_session_list_strips_heavy_columns():
repos = await _make_repos()
try:
await repos["sessions"].create(id="drs_1", user_id="u1", query="q", config_snapshot={"mode": "deep"})
items = await repos["sessions"].list_by_user(user_id="u1")
assert len(items) == 1
assert "report_markdown" not in items[0]
assert "config_snapshot" not in items[0]
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_session_update_and_delete():
repos = await _make_repos()
try:
await repos["sessions"].create(id="drs_1", user_id="u1", query="q")
updated = await repos["sessions"].update("drs_1", user_id="u1", title="New Title")
assert updated["title"] == "New Title"
assert await repos["sessions"].delete("drs_1", user_id="u1") is True
assert await repos["sessions"].get("drs_1", user_id="u1") is None
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_count_active_by_user():
repos = await _make_repos()
try:
await repos["sessions"].create(id="drs_1", user_id="u1", query="q", status="running")
await repos["sessions"].create(id="drs_2", user_id="u1", query="q", status="completed")
count = await repos["sessions"].count_active_by_user(user_id="u1")
assert count == 1
finally:
await repos["_engine"].dispose()
# ── jobs: idempotent create ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_job_try_create_returns_created_true():
repos = await _make_repos()
try:
job, created = await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1", request_id="req_1")
assert created is True
assert job["status"] == "queued"
assert job["active_dedupe_key"] is not None
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_create_never_reads_back_after_commit(monkeypatch):
"""A committed job must not fail when a MySQL replica briefly lags."""
from sqlalchemy.ext.asyncio import AsyncSession
async def fail_refresh(self, instance, *args, **kwargs): # noqa: ANN001, ARG001
raise AssertionError("DeepResearchJobRepository must not call session.refresh")
monkeypatch.setattr(AsyncSession, "refresh", fail_refresh)
repos = await _make_repos()
try:
job, created = await repos["jobs"].try_create_or_get_active(
id="drj_1",
session_id="drs_1",
user_id="u1",
request_id="req_1",
)
assert created is True
assert job["id"] == "drj_1"
assert job["status"] == "queued"
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_try_create_idempotent_returns_existing():
repos = await _make_repos()
try:
job1, created1 = await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1", request_id="req_1")
job2, created2 = await repos["jobs"].try_create_or_get_active(id="drj_2", session_id="drs_1", user_id="u1", request_id="req_2")
assert created1 is True
assert created2 is False
assert job2["id"] == job1["id"]
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_dedupe_released_on_terminal():
repos = await _make_repos()
try:
job, _ = await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
assert job["active_dedupe_key"] is not None
await repos["jobs"].update_progress("drj_1", lease_owner=None, user_id="u1", status="completed")
job2, created2 = await repos["jobs"].try_create_or_get_active(id="drj_2", session_id="drs_1", user_id="u1")
assert created2 is True
assert job2["id"] == "drj_2"
finally:
await repos["_engine"].dispose()
# ── jobs: lease mechanics ───────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_job_claim_succeeds_for_queued():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
lease_until = datetime.now(UTC) + timedelta(seconds=60)
claimed = await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
assert claimed is not None
assert claimed["status"] == "running"
assert claimed["lease_owner"] == "w1"
assert claimed["attempt"] == 1
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_dispatcher_claims_queued_job_and_starts_report_worker():
"""The claimed snapshot must reach the executor before the job can run."""
from app.gateway.deep_research_job_dispatcher import DeepResearchJobDispatcher
class _Executor:
def __init__(self) -> None:
self.started: list[tuple[dict, str, str | None]] = []
def start_job(self, snapshot: dict, *, job_id: str, lease_owner: str | None) -> bool:
self.started.append((snapshot, job_id, lease_owner))
return True
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(
id="drj_1",
session_id="drs_1",
user_id="u1",
input_snapshot={"query": "测试报告"},
)
runner = _Executor()
dispatcher = DeepResearchJobDispatcher(repos["jobs"], runner, worker_id="worker_1")
started = await dispatcher.dispatch_once()
assert started == 1
assert runner.started == [({"query": "测试报告"}, "drj_1", "worker_1")]
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_claim_fails_if_already_claimed():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
lease_until = datetime.now(UTC) + timedelta(seconds=60)
first = await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
assert first is not None
second = await repos["jobs"].claim_job("drj_1", lease_owner="w2", lease_until=lease_until)
assert second is None
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_renew_lease_only_by_owner():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
lease_until = datetime.now(UTC) + timedelta(seconds=60)
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
assert await repos["jobs"].renew_lease("drj_1", lease_owner="w1", lease_until=lease_until) is True
assert await repos["jobs"].renew_lease("drj_1", lease_owner="w2", lease_until=lease_until) is False
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_expired_lease_reclaimable():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
expired = datetime.now(UTC) - timedelta(seconds=10)
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=expired)
now = datetime.now(UTC)
claimable = await repos["jobs"].list_claimable(now=now)
assert "drj_1" in claimable
reclaimed = await repos["jobs"].claim_job("drj_1", lease_owner="w2", lease_until=now + timedelta(seconds=60))
assert reclaimed is not None
assert reclaimed["lease_owner"] == "w2"
finally:
await repos["_engine"].dispose()
# ── jobs: two-phase cancel ──────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_job_cancel_two_phase():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
lease_until = datetime.now(UTC) + timedelta(seconds=60)
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
requested = await repos["jobs"].request_cancel("drj_1", user_id="u1")
assert requested is not None
assert requested["cancel_requested"] is True
finalized = await repos["jobs"].finalize_cancel("drj_1")
assert finalized is not None
assert finalized["status"] == "cancelled"
assert finalized["active_dedupe_key"] is None
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_cancel_idempotent():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
first = await repos["jobs"].request_cancel("drj_1", user_id="u1")
assert first is not None
await repos["jobs"].finalize_cancel("drj_1")
second = await repos["jobs"].request_cancel("drj_1", user_id="u1")
assert second is None
finally:
await repos["_engine"].dispose()
# ── jobs: progress update with lease gate ───────────────────────────────────
@pytest.mark.asyncio
async def test_job_update_progress_lease_gated():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
lease_until = datetime.now(UTC) + timedelta(seconds=60)
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
updated = await repos["jobs"].update_progress("drj_1", lease_owner="w1", phase="collecting", progress=50)
assert updated is not None
assert updated["phase"] == "collecting"
assert updated["progress"] == 50
rejected = await repos["jobs"].update_progress("drj_1", lease_owner="w2", phase="writing")
assert rejected is None
finally:
await repos["_engine"].dispose()
# ── events: monotonic seq + replay ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_event_seq_monotonic():
repos = await _make_repos()
try:
repo = repos["events"]
e1 = await repo.append(session_id="drs_1", job_id="drj_1", event_type="phase_changed", payload={"to": "planning"})
e2 = await repo.append(session_id="drs_1", job_id="drj_1", event_type="source_added", payload={"id": "s1"})
e3 = await repo.append(session_id="drs_1", job_id="drj_1", event_type="source_added", payload={"id": "s2"})
assert e1["seq"] == 1
assert e2["seq"] == 2
assert e3["seq"] == 3
assert await repo.last_seq("drj_1") == 3
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_event_append_never_reads_back_after_commit(monkeypatch):
"""Event persistence must work when a post-commit read hits a lagging replica."""
from sqlalchemy.ext.asyncio import AsyncSession
async def fail_refresh(self, instance, *args, **kwargs): # noqa: ANN001, ARG001
raise AssertionError("DeepResearchEventRepository must not call session.refresh")
monkeypatch.setattr(AsyncSession, "refresh", fail_refresh)
repos = await _make_repos()
try:
event = await repos["events"].append(
session_id="drs_1",
job_id="drj_1",
event_type="phase_changed",
payload={"to": "planning"},
)
assert event["id"] is not None
assert event["seq"] == 1
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_event_list_after_replay():
repos = await _make_repos()
try:
repo = repos["events"]
for i in range(5):
await repo.append(session_id="drs_1", job_id="drj_1", event_type="heartbeat", payload={"i": i})
replayed = await repo.list_after("drj_1", after=2)
assert len(replayed) == 3
assert [e["seq"] for e in replayed] == [3, 4, 5]
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_event_payload_round_trip():
repos = await _make_repos()
try:
repo = repos["events"]
await repo.append(
session_id="drs_1",
job_id="drj_1",
event_type="plan_created",
payload={"subtopics": ["AI", "ML"], "nested": {"a": 1}},
)
events = await repo.list_after("drj_1", after=0)
assert events[0]["payload"]["subtopics"] == ["AI", "ML"]
assert events[0]["payload"]["nested"]["a"] == 1
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_event_seq_independent_per_job():
repos = await _make_repos()
try:
repo = repos["events"]
await repo.append(session_id="drs_1", job_id="drj_1", event_type="heartbeat")
await repo.append(session_id="drs_1", job_id="drj_1", event_type="heartbeat")
await repo.append(session_id="drs_1", job_id="drj_2", event_type="heartbeat")
assert await repo.last_seq("drj_1") == 2
assert await repo.last_seq("drj_2") == 1
finally:
await repos["_engine"].dispose()
# ── sources ─────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_source_upsert_and_list():
repos = await _make_repos()
try:
await repos["sources"].upsert(
session_id="drs_1",
id="src_1",
user_id="u1",
title="Source A",
url="https://example.com/a",
raw_content="content A",
source="web",
source_type="web",
content_hash="hash_a",
)
items = await repos["sources"].list_by_session("drs_1", user_id="u1")
assert len(items) == 1
assert items[0]["title"] == "Source A"
assert "raw_content" not in items[0]
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_source_set_selected():
repos = await _make_repos()
try:
await repos["sources"].upsert(
session_id="drs_1",
id="src_1",
user_id="u1",
title="A",
raw_content="c",
source="web",
content_hash="h",
)
updated = await repos["sources"].set_selected("src_1", selected=True, reason="relevant", citation_key="1", user_id="u1")
assert updated["selected"] is True
assert updated["citation_key"] == "1"
finally:
await repos["_engine"].dispose()
# ── messages ────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_message_append_and_list():
repos = await _make_repos()
try:
await repos["messages"].append(id="msg_1", session_id="drs_1", user_id="u1", role="user", content="question?")
await repos["messages"].append(
id="msg_2",
session_id="drs_1",
user_id="u1",
role="assistant",
content="answer",
citation_source_ids=["src_1"],
)
msgs = await repos["messages"].list_by_session("drs_1", user_id="u1")
assert len(msgs) == 2
assert msgs[0]["role"] == "user"
assert msgs[1]["citation_source_ids"] == ["src_1"]
finally:
await repos["_engine"].dispose()
@@ -0,0 +1,211 @@
"""Regression coverage for idempotent Deep Research job submission."""
from types import SimpleNamespace
import pytest
from app.gateway.routers import deep_research
class _SessionStore:
def __init__(self) -> None:
self.updates: list[dict] = []
async def get(self, session_id: str, *, user_id: str): # noqa: ARG002
return {
"id": session_id,
"query": "测试主题",
"title": "测试主题",
"config_snapshot": {"mode": "basic"},
}
async def count_active_by_user(self, *, user_id: str) -> int: # noqa: ARG002
return 0
async def update(self, session_id: str, *, user_id: str, **fields): # noqa: ARG002
self.updates.append(fields)
return {"id": session_id, **fields}
class _JobStore:
async def get_active_for_session(self, session_id: str, *, user_id: str): # noqa: ARG002
return None
async def try_create_or_get_active(self, **kwargs): # noqa: ANN003
# Simulate a retry after an earlier request committed the job but did
# not finish responding to the browser.
return {"id": "drj_existing", "status": "queued"}, False
class _Dispatcher:
def __init__(self) -> None:
self.nudges = 0
def nudge(self) -> None:
self.nudges += 1
def _request(*, sessions, jobs, dispatcher):
return SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
deep_research_session_store=sessions,
deep_research_job_store=jobs,
deep_research_dispatcher=dispatcher,
)
)
)
@pytest.mark.asyncio
async def test_retry_of_existing_queued_job_nudges_dispatcher(monkeypatch):
"""An idempotent retry must recover a queued-but-not-yet-dispatched job."""
async def fake_require_user(request): # noqa: ANN001
return "u1"
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
sessions = _SessionStore()
dispatcher = _Dispatcher()
request = _request(sessions=sessions, jobs=_JobStore(), dispatcher=dispatcher)
response = await deep_research.start_job(
"drs_1",
deep_research.StartJobRequest(entry="legacy"),
request,
)
assert response == {
"jobId": "drj_existing",
"sessionId": "drs_1",
"status": "queued",
"reused": True,
}
assert dispatcher.nudges == 1
assert sessions.updates == [{"status": "running", "active_job_id": "drj_existing"}]
@pytest.mark.asyncio
async def test_queued_job_still_starts_when_session_projection_temporarily_fails(monkeypatch):
"""A committed job is more important than its non-authoritative UI projection."""
async def fake_require_user(request): # noqa: ANN001
return "u1"
class _ProjectionFailureSessionStore(_SessionStore):
async def update(self, session_id: str, *, user_id: str, **fields): # noqa: ARG002
raise RuntimeError("temporary database readback failure")
class _NewJobStore(_JobStore):
async def try_create_or_get_active(self, **kwargs): # noqa: ANN003
return {"id": "drj_new", "status": "queued"}, True
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
dispatcher = _Dispatcher()
response = await deep_research.start_job(
"drs_1",
deep_research.StartJobRequest(entry="legacy"),
_request(
sessions=_ProjectionFailureSessionStore(),
jobs=_NewJobStore(),
dispatcher=dispatcher,
),
)
assert response["jobId"] == "drj_new"
assert response["reused"] is False
assert dispatcher.nudges == 1
@pytest.mark.asyncio
async def test_chat_report_card_can_enable_images_after_collection(monkeypatch):
"""The write-time card override must win over the collection-time default."""
async def fake_require_user(request): # noqa: ANN001
return "u1"
class _ChatSessionStore(_SessionStore):
async def get(self, session_id: str, *, user_id: str): # noqa: ARG002
return {
"id": session_id,
"query": "测试主题",
"title": "测试主题",
"runtime_thread_id": "collector-thread",
"config_snapshot": {
"mode": "basic",
"collection_mode": "chat",
"generate_images": False,
"max_generated_images": 2,
},
}
class _CapturingJobStore(_JobStore):
def __init__(self) -> None:
self.created: dict | None = None
async def try_create_or_get_active(self, **kwargs): # noqa: ANN003
self.created = kwargs
return {"id": "drj_images", "status": "queued"}, True
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
sessions = _ChatSessionStore()
jobs = _CapturingJobStore()
response = await deep_research.start_job(
"drs_1",
deep_research.StartJobRequest(
entry="chat",
config={"generate_images": True, "max_generated_images": 3},
),
_request(sessions=sessions, jobs=jobs, dispatcher=_Dispatcher()),
)
assert response["jobId"] == "drj_images"
assert jobs.created is not None
snapshot = jobs.created["input_snapshot"]["config"]
assert snapshot["generate_images"] is True
assert snapshot["max_generated_images"] == 3
# The session snapshot is persisted too, so a reload/replay keeps the
# write-time choice instead of reverting to the creation-time false.
assert any(
update.get("config_snapshot", {}).get("generate_images") is True
for update in sessions.updates
)
@pytest.mark.asyncio
async def test_stream_stays_open_when_durable_replay_has_a_transient_failure(monkeypatch):
"""A stream DB blip must yield a recoverable warning, not an ASGI error group."""
async def fake_require_user(request): # noqa: ANN001
return "u1"
class _StreamJobStore:
async def get(self, job_id: str, *, user_id: str): # noqa: ARG002
return {"id": job_id, "session_id": "drs_1"}
async def get_unscoped(self, job_id: str): # noqa: ARG002
return None
class _UnavailableEventStore:
async def list_after(self, *args, **kwargs): # noqa: ANN002, ANN003
raise RuntimeError("temporary replica failure")
async def is_connected() -> bool:
return False
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
request = SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
deep_research_job_store=_StreamJobStore(),
deep_research_event_store=_UnavailableEventStore(),
)
),
is_disconnected=is_connected,
)
response = await deep_research.stream_job("drj_1", request)
chunk = await anext(response.body_iterator)
await response.body_iterator.aclose()
assert "durable_replay_temporarily_unavailable" in chunk
@@ -206,14 +206,14 @@ def test_position_intent_prompt_keeps_the_original_step_one_guardrails():
assert phrase in prompt
def test_position_intent_clarification_waits_for_the_user_before_ready():
"""A same-run READY block must not consume the user-assistance pause."""
def test_fresh_intent_clarification_waits_for_the_user_before_ready():
"""A same-run READY block must not consume a fresh assistance pause."""
assert intent_router._clarification_blocks_ready(
intent_router.POSITION_INTENT_AGENT_ID,
{"name": "ask_clarification"},
is_fresh=True,
)
assert not intent_router._clarification_blocks_ready(
assert intent_router._clarification_blocks_ready(
intent_router.INTENT_AGENT_ID,
{"name": "ask_clarification"},
is_fresh=True,
@@ -0,0 +1,30 @@
"""Regression coverage for provider-safe transient context compression."""
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from deerflow.agents.middlewares.summarization_middleware import _ensure_latest_human_message
def test_compression_restores_latest_human_turn_when_only_tool_tail_remains():
user = HumanMessage(content="请分析这份材料", id="user-1")
source = [
user,
AIMessage(content="", id="ai-1"),
ToolMessage(content="检索结果", tool_call_id="call-1", id="tool-1"),
]
compressed = [SystemMessage(content="internal summary"), *source[1:]]
result = _ensure_latest_human_message(source, compressed)
assert result[0].type == "system"
assert result[1] is user
assert [message.type for message in result[2:]] == ["ai", "tool"]
def test_compression_does_not_duplicate_human_turn_already_in_tail():
user = HumanMessage(content="继续", id="user-1")
compressed = [SystemMessage(content="internal summary"), user]
result = _ensure_latest_human_message([user], compressed)
assert result == compressed