fix: writing mode uses chat search then pipeline write only

Keep deep_research_report as a write-only deliverable from conversation materials, block post-success rewrite loops with a summary nudge, fill thin/empty pools from model knowledge, and open the sandbox only on real report.md content.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-30 12:41:04 +08:00
1 parent 9afb9750e5
commit 49c01cb72b
20 files changed
+1297 -229

No files matched your search

@@ -19,6 +19,7 @@ import {
mergeArtifactPaths,
} from "@/core/artifacts/listing";
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
@@ -390,10 +391,13 @@ const ChatBox: React.FC<{
((artifactDirectoryModeEnabled && threadChanged) ||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
) {
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
const newest = nextArtifacts[nextArtifacts.length - 1]!;
if (shouldAutoOpenWritingArtifact(newest, thread.messages)) {
selectArtifact(newest);
setArtifactsOpen(true);
setSideTab("document");
}
}
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
// if (
@@ -424,6 +428,7 @@ const ChatBox: React.FC<{
setArtifacts,
availableArtifacts,
disableArtifacts,
thread.messages,
]);
// Live sandbox: open as soon as any in-flight write_file has a path or
@@ -69,6 +69,7 @@ import {
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import { isInterceptedWritingReportWrite } from "@/core/threads/writing-mode";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@@ -866,6 +867,22 @@ function convertToSteps(messages: Message[]): CoTStep[] {
if (tool_call.name === "task") {
continue;
}
const writePath =
typeof (tool_call.args as { path?: unknown } | undefined)?.path === "string"
? (tool_call.args as { path: string }).path
: "";
if (
(
tool_call.name === "write_file" ||
tool_call.name === "str_replace" ||
tool_call.name === "read_file" ||
tool_call.name === "read_multiple_files" ||
tool_call.name === "present_files"
) &&
isInterceptedWritingReportWrite(writePath, message.id, messages)
) {
continue;
}
const step: CoTToolCallStep = {
id: tool_call.id,
messageId: message.id,
@@ -61,6 +61,73 @@ test("latestInFlightWriteFilePreview keeps markdown when a later json write is i
assert.match(preview?.artifactPath ?? "", /report\.md/);
});
test("latestInFlightWriteFilePreview skips intercepted custom outputs markdown", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/ai-agent-architecture-paper.md",
content: "# 草稿\n",
},
},
],
},
]);
assert.equal(preview, null);
});
test("latestInFlightWriteFilePreview skips report.md while the first-turn pipeline is active", () => {
const preview = latestInFlightWriteFilePreview([
{ type: "human", content: "写一篇论文" },
{
id: "m-pipe",
type: "ai",
tool_calls: [{ id: "p1", name: "deep_research_report", args: { topic: "课题" } }],
},
{
id: "m-write",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 我自己写\n",
},
},
],
},
]);
assert.equal(preview, null);
});
test("latestInFlightWriteFilePreview still opens the injected research stub", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "research-report-j1",
type: "ai",
tool_calls: [
{
id: "research-report-j1-write",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 报告\n",
},
},
],
},
]);
assert.ok(preview);
assert.match(preview?.artifactPath ?? "", /report\.md/);
});
test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => {
const preview = latestInFlightWriteFilePreview([
{
@@ -1,3 +1,4 @@
import { isInterceptedWritingReportWrite } from "../threads/writing-mode";
import { isImageFile } from "../utils/files";
import {
@@ -123,6 +124,11 @@ export function latestInFlightWriteFilePreview(
if (!sourcePath) {
continue;
}
// Writing mode rewrites custom outputs/*.md to deep_research_report.
// Opening that filename would 404-poll until report.md lands.
if (isInterceptedWritingReportWrite(sourcePath, message.id, messages)) {
continue;
}
// JSON (e.g. agentfx task-reports.json) is too large to live-preview:
// switching the sandbox onto it freezes the main thread, then the rest
// of the turn appears to dump at once. Keep showing markdown instead.
@@ -56,6 +56,61 @@ test("write_file tool after 用户协助 opens a processing group instead of err
]);
});
test("writing-mode progress merges into the deep_research_report bar so the summary sits below", () => {
const types = groupTypes([
{ type: "human", id: "h1", content: "写一篇论文" } as Message,
{
type: "ai",
id: "a1",
content: "",
tool_calls: [{ id: "c1", name: "deep_research_report", args: { query: "多智能体" } }],
} as Message,
{
type: "tool",
id: "t1",
name: "deep_research_report",
content: "ok",
tool_call_id: "c1",
} as Message,
{
type: "ai",
id: "progress",
content: "",
tool_calls: [
{ id: "p1", name: "deep_research_progress", args: { label: "撰写研究报告", status: "complete" } },
],
} as Message,
{
type: "ai",
id: "write",
content: "",
tool_calls: [{ id: "w1", name: "write_file", args: { path: "/mnt/user-data/outputs/report.md" } }],
} as Message,
{
type: "ai",
id: "summary",
content: "已生成多智能体论文,详见 report.md。",
} as Message,
]);
assert.deepEqual(types, ["human", "assistant:processing", "assistant"]);
});
test("a lone deep-research progress bar still opens its own group", () => {
const types = groupTypes([
{
type: "ai",
id: "progress",
content: "",
tool_calls: [
{ id: "p1", name: "deep_research_progress", args: { label: "撰写研究报告", status: "active" } },
],
} as Message,
]);
assert.deepEqual(types, ["assistant:processing"]);
});
test("tool messages still attach to an open processing group", () => {
const types = groupTypes([
{
+16 -3
View File
@@ -166,14 +166,19 @@ export function groupMessages<T>(
messages: [message],
});
} else if (hasOnlyDeepResearchProgress(message)) {
// Each deep-research writing bar is its own group. Merging consecutive
// processing messages would pin a new report's in-progress steps onto
// the parent bar, which sits above the original file card.
// 写作模式把进度条插在同一次 `deep_research_report` 之后,必须并进
// 当前 processing 组,总结才能排在整条步骤条下面。新一轮写作前面
// 会有 human/assistant 隔开,不会误并到上一轮的条上。
const lastGroup = groups[groups.length - 1];
if (lastGroup?.type === "assistant:processing" && groupHasDeepResearchReport(lastGroup)) {
lastGroup.messages.push(message);
} else {
groups.push({
id: message.id,
type: "assistant:processing",
messages: [message],
});
}
} else if (hasSubagent(message)) {
groups.push({
id: message.id,
@@ -426,6 +431,14 @@ export function hasOnlyDeepResearchProgress(message: Message) {
);
}
function groupHasDeepResearchReport(group: MessageGroup): boolean {
return group.messages.some(
(message) =>
message.type === "ai" &&
Boolean(message.tool_calls?.some((toolCall) => toolCall.name === "deep_research_report")),
);
}
export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification";
}
@@ -6,6 +6,7 @@ import {
buildResearchFeedMessages,
buildResearchProgressToolCalls,
buildResearchReportVirtualUrl,
composeResearchDisplayMessages,
clearResearchReportStream,
finalizeResearchReportStream,
ingestResearchReportEvent,
@@ -148,6 +149,22 @@ test("progress tool calls reveal stages stepwise and mark interruption", () => {
assert.equal(buildResearchProgressToolCalls(writing).length, 3);
assert.equal(buildResearchProgressToolCalls(writing)[2].args.status, "active");
const completedByPhase = streamOf({ jobId: "j7b", phase: "done", reportText: "# x" });
const completedCalls = buildResearchProgressToolCalls(completedByPhase);
assert.equal(completedCalls.length, 3);
assert.ok(completedCalls.every((call) => call.args.status === "complete"));
assert.match(completedCalls[2].args.detail, /report\.md/);
const completedByStatus = streamOf({
jobId: "j7c",
phase: "writing",
status: "success",
reportText: "# x",
});
assert.ok(
buildResearchProgressToolCalls(completedByStatus).every((call) => call.args.status === "complete"),
);
const failed = streamOf({ jobId: "j8", phase: "writing", status: "error", error: "模型超时" });
const failedCalls = buildResearchProgressToolCalls(failed);
assert.match(failedCalls.at(-1)?.args.label ?? "", /已中断/);
@@ -157,15 +174,73 @@ test("progress tool calls reveal stages stepwise and mark interruption", () => {
test("feed messages attach the write_file stub only once report text exists", () => {
const noText = streamOf({ jobId: "j9", phase: "collecting" });
assert.equal(buildResearchFeedMessages(noText).length, 1);
assert.equal(buildResearchFeedMessages(noText)[0].tool_calls[0].name, "deep_research_progress");
assert.equal(buildResearchFeedMessages(noText)[0].tool_calls?.[0]?.name, "deep_research_progress");
const writingEmpty = streamOf({ jobId: "j9b", phase: "writing" });
assert.equal(buildResearchFeedMessages(writingEmpty).length, 1);
const withText = streamOf({ jobId: "j10", phase: "writing", reportText: "# 报告正文" });
const feed = buildResearchFeedMessages(withText);
assert.equal(feed.length, 2);
const stub = feed[1].tool_calls[0];
assert.equal(stub.name, "write_file");
assert.equal(stub.args.path, "/mnt/user-data/outputs/report.md");
assert.equal(stub.args.content, "# 报告正文");
const stub = feed[1].tool_calls?.[0];
assert.equal(stub?.name, "write_file");
assert.equal(stub?.args.path, "/mnt/user-data/outputs/report.md");
assert.equal(stub?.args.content, "# 报告正文");
const done = streamOf({ jobId: "j10b", phase: "done", reportText: "# 报告正文" });
const doneFeed = buildResearchFeedMessages(done);
assert.equal(doneFeed.length, 3);
assert.equal(doneFeed[2].type, "tool");
assert.equal(doneFeed[2].tool_call_id, "research-report-j10b-write");
});
test("report_completed flips phase to done and write step completes before tool_finished", () => {
const tid = nextThread();
ingestAll(tid, [
{ type: "report_delta", jobId: "jdone", payload: { delta: "正文", offset: 0 } },
{ type: "report_completed", jobId: "jdone", payload: { sourceCount: 2 } },
]);
const state = peekResearchReportStream(tid);
assert.equal(state?.phase, "done");
assert.equal(state?.status, "active");
const calls = buildResearchProgressToolCalls(state!);
assert.ok(calls.every((call) => call.args.status === "complete"));
});
test("composeResearchDisplayMessages inserts the bar before the summary and drops leaked prose", () => {
const feed = buildResearchFeedMessages(streamOf({
jobId: "jsplice",
phase: "done",
reportText: "# 报告",
}));
const composed = composeResearchDisplayMessages(
[
{ id: "h", type: "human", content: "写论文" },
{
id: "ai-tool",
type: "ai",
content: "",
tool_calls: [{ id: "c1", name: "deep_research_report", args: {} }],
},
{ id: "leak", type: "ai", content: "# 报告\n\n泄漏到消息列表的正文" },
{ id: "tool", type: "tool", tool_call_id: "c1", content: "ok" },
{ id: "summary", type: "ai", content: "已生成论文,详见 report.md。" },
],
feed,
);
assert.deepEqual(
composed.map((message) => message.id),
[
"h",
"ai-tool",
"tool",
"research-progress-jsplice",
"research-report-jsplice",
"research-report-jsplice-write-result",
"summary",
],
);
});
test("virtual URL matches the message-list ArtifactWriteToolCall URL (one suppression key)", () => {
@@ -84,8 +84,8 @@ function getStore(threadId: string): StoreEntry {
* 通知若逐帧发出,会让聊天页整棵树(长消息列表 + 沙箱 Markdown 预览)按
* token 频率重渲染,页面直接卡死。工作台用 requestAnimationFrame 批处理
* (DeepResearchWorkbench),聊天这里等效地按固定间隔合并——每秒最多
* 10 次通知,流式观感不受影响。终态帧(tool_finished)与
* finalize/clear 立即 flush,结束态不滞后。
* 10 次通知,流式观感不受影响。终态帧(tool_finished /
* report_completed)与 finalize/clear 立即 flush,结束态不滞后。
*/
const EMIT_COALESCE_MS = 100;
@@ -193,7 +193,8 @@ export function ingestResearchReportEvent(threadId: string, frame: ResearchRepor
case "report_completed":
next = {
...base,
phase: "writing",
// 文件已落盘:步骤条应立刻标完成,不必等随后的 tool_finished。
phase: "done",
sourceCount: typeof payload.sourceCount === "number" && payload.sourceCount > 0
? payload.sourceCount
: base.sourceCount,
@@ -223,7 +224,7 @@ export function ingestResearchReportEvent(threadId: string, frame: ResearchRepor
if (next !== previous) {
store.state = next;
// 状态同步更新(peek/快照立即可见);通知按 token 帧频率合并,终态立即。
if (frame.type === "tool_finished") {
if (frame.type === "tool_finished" || frame.type === "report_completed") {
emitNow(store);
} else {
emitCoalesced(store);
@@ -280,7 +281,11 @@ export type ResearchProgressToolCall = {
export function buildResearchProgressToolCalls(state: ResearchReportStreamState): ResearchProgressToolCall[] {
const rank = phaseRank(state.phase);
const finished = state.status !== "active";
const effectiveRank = finished && state.status === "success" ? PHASE_ORDER.length - 1 : rank;
// success 时必须用 length(越过 write 的 boundary),length-1 永远完不成最后一步。
// report_completed 会先把 phase 置 done,此时工具可能还没返回,也视为写完。
const succeeded =
state.status === "success" || (state.status === "active" && state.phase === "done");
const effectiveRank = succeeded ? PHASE_ORDER.length : rank;
const stages = [
{
id: "plan",
@@ -304,7 +309,9 @@ export function buildResearchProgressToolCalls(state: ResearchReportStreamState)
id: "write",
label: "撰写研究报告",
boundary: PHASE_ORDER.length,
detail: state.reportText
detail: succeeded
? "已按「通用分析结构」写完并保存为 report.md"
: state.reportText
? "正在按「通用分析结构」逐字撰写,右侧沙箱同步预览"
: "正在整理资料并开始撰写报告",
},
@@ -330,19 +337,25 @@ export function buildResearchProgressToolCalls(state: ResearchReportStreamState)
export type ResearchFeedMessage = {
id: string;
type: "ai";
type: "ai" | "tool";
content: string;
tool_calls: {
name?: string;
tool_call_id?: string;
tool_calls?: {
id: string;
name: string;
args: Record<string, unknown>;
}[];
};
function isResearchWriteFinished(state: ResearchReportStreamState): boolean {
return state.status === "success" || state.phase === "done";
}
/**
* 组装聊天页要注入的两条虚拟 ai 消息:进度步骤条 + report.md 的 write_file
* stub(沙箱 write-file 虚拟 URL 预览的数据源)。仅在报告尚无正文或仍在
* 流式时输出 stub —— 完成后由真实 artifacts 接管。
* 组装聊天页要注入的虚拟消息:进度步骤条 + report.md 的 write_file stub
* (沙箱 write-file 虚拟 URL 预览的数据源)。写完后补一条 tool result,
* 让沙箱的「正在生成」立刻收起,不必等真实 artifacts 交接。
*/
export function buildResearchFeedMessages(state: ResearchReportStreamState): ResearchFeedMessage[] {
const messages: ResearchFeedMessage[] = [
@@ -353,23 +366,127 @@ export function buildResearchFeedMessages(state: ResearchReportStreamState): Res
tool_calls: buildResearchProgressToolCalls(state),
},
];
if (state.reportText.trim()) {
// 必须等正文首 token:思考阶段挂 stub 会提前显示「写入文件 report.md」。
if (!state.reportText.trim()) {
return messages;
}
const writeCallId = `research-report-${state.jobId}-write`;
messages.push({
id: `research-report-${state.jobId}`,
type: "ai",
content: "",
tool_calls: [
{
id: `research-report-${state.jobId}-write`,
id: writeCallId,
name: "write_file",
args: { path: RESEARCH_REPORT_OUTPUT_PATH, content: state.reportText },
},
],
});
if (isResearchWriteFinished(state)) {
messages.push({
id: `research-report-${state.jobId}-write-result`,
type: "tool",
name: "write_file",
tool_call_id: writeCallId,
content: "ok",
});
}
return messages;
}
function isDeepResearchReportAi(message: { type?: string; tool_calls?: Array<{ name?: string; id?: string }> }): boolean {
return (
message.type === "ai" &&
Boolean(message.tool_calls?.some((toolCall) => toolCall.name === "deep_research_report"))
);
}
function deepResearchReportCallIds(message: { tool_calls?: Array<{ name?: string; id?: string }> }): Set<string> {
return new Set(
(message.tool_calls ?? [])
.filter((toolCall) => toolCall.name === "deep_research_report" && toolCall.id)
.map((toolCall) => toolCall.id as string),
);
}
function messageHasVisibleText(message: { type?: string; content?: unknown; tool_calls?: unknown[] }): boolean {
if (message.type !== "ai") return false;
if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) return false;
return typeof message.content === "string" && message.content.trim().length > 0;
}
/**
* 丢掉管线内部 LLM 泄漏到父图 messages 流的正文:它们夹在
* `deep_research_report` 调用与其 tool result 之间,会让左侧气泡和沙箱一起写。
*/
export function omitLeakedReportTokens<T extends { type?: string; tool_calls?: Array<{ name?: string; id?: string }>; tool_call_id?: string; content?: unknown }>(
messages: T[],
): T[] {
const hidden = new Set<number>();
let pendingIds: Set<string> | null = null;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (isDeepResearchReportAi(message)) {
pendingIds = deepResearchReportCallIds(message);
continue;
}
if (!pendingIds) continue;
if (
message.type === "tool" &&
typeof message.tool_call_id === "string" &&
pendingIds.has(message.tool_call_id)
) {
pendingIds = null;
continue;
}
if (messageHasVisibleText(message)) {
hidden.add(i);
}
}
return hidden.size === 0 ? messages : messages.filter((_, index) => !hidden.has(index));
}
/**
* 把进度条 / write_file stub 插到 `deep_research_report` 及其结果之后、
* 模型总结之前,这样总结气泡永远在步骤条下面。
*/
export function insertResearchFeedMessages<T>(messages: T[], feed: T[]): T[] {
if (feed.length === 0) return messages;
let insertAt = -1;
let callIds = new Set<string>();
for (let i = 0; i < messages.length; i++) {
const message = messages[i] as { type?: string; tool_calls?: Array<{ name?: string; id?: string }> };
if (isDeepResearchReportAi(message)) {
insertAt = i + 1;
callIds = deepResearchReportCallIds(message);
}
}
if (insertAt === -1) {
return [...messages, ...feed];
}
while (insertAt < messages.length) {
const message = messages[insertAt] as { type?: string; tool_call_id?: string };
if (
message.type === "tool" &&
typeof message.tool_call_id === "string" &&
callIds.has(message.tool_call_id)
) {
insertAt += 1;
continue;
}
break;
}
return [...messages.slice(0, insertAt), ...feed, ...messages.slice(insertAt)];
}
export function composeResearchDisplayMessages<T extends { type?: string; tool_calls?: Array<{ name?: string; id?: string }>; tool_call_id?: string; content?: unknown }>(
messages: T[],
feed: T[],
): T[] {
return insertResearchFeedMessages(omitLeakedReportTokens(messages), feed);
}
/** write-file 虚拟 URL(ArtifactFileDetail 用它从 stub 消息读流式内容)。 */
export function buildResearchReportVirtualUrl(state: ResearchReportStreamState): string {
return new URL(
@@ -3,8 +3,8 @@
/**
* 聊天页「写作模式」深度研究进度注入。
*
* 把 `useResearchReportStream` 的流式状态转成 displayThread 尾部追加的
* 虚拟消息(进度步骤条 + report.md 的 write_file stub):
* 把 `useResearchReportStream` 的流式状态插到 `deep_research_report`
* 工具结果之后、模型总结之前(进度步骤条 + report.md 的 write_file stub):
*
* - 进行中 / 中断:始终注入(中断态展示「已中断」,下一轮 jobId 变化自然淘汰);
* - 成功:注入到真实 report.md 产物落地为止(artifacts 出现该路径即撤出),
@@ -26,13 +26,16 @@ import {
artifactPreviewKey,
useArtifactsOptional,
} from "@/components/workspace/artifacts/context";
import { resolveWriteFilePath } from "@/core/artifacts/write-file-in-flight";
import {
buildResearchFeedMessages,
buildResearchReportVirtualUrl,
composeResearchDisplayMessages,
RESEARCH_REPORT_OUTPUT_PATH,
useResearchReportStream,
} from "./use-research-report-stream";
import { isInterceptedWritingReportWrite } from "./writing-mode";
type FeedThreadLike = {
messages: Message[];
@@ -55,10 +58,15 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
[thread.values?.artifacts],
);
// 报告首帧正文到达且仍在写入 → 主动展开右侧沙箱并选中 write-file 虚拟
// 预览,报告在沙箱里边写边流式渲染。
// 正文首 token 到达且仍在写入 → 主动展开右侧沙箱。思考阶段不打开,
// 避免「写入文件 report.md」出现在模型还在想的时候。
useEffect(() => {
if (!research || research.status !== "active" || !research.reportText.trim()) {
if (
!research ||
research.status !== "active" ||
research.phase !== "writing" ||
!research.reportText.trim()
) {
return;
}
if (autoOpenedJobRef.current === research.jobId) return;
@@ -77,7 +85,14 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
if (!artifacts) return;
handedOverJobRef.current = research.jobId;
const current = artifacts.selectedArtifact;
if (current !== null && current !== buildResearchReportVirtualUrl(research)) {
const virtualUrl = buildResearchReportVirtualUrl(research);
const currentPath = current ? resolveWriteFilePath(current) : "";
const keepUserSelection =
current !== null &&
current !== virtualUrl &&
current !== RESEARCH_REPORT_OUTPUT_PATH &&
!isInterceptedWritingReportWrite(currentPath);
if (keepUserSelection) {
return;
}
artifacts.select(RESEARCH_REPORT_OUTPUT_PATH);
@@ -91,11 +106,11 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
}, [hasRealArtifact, research]);
const displayThread = useMemo(() => {
if (feedMessages.length === 0) return thread;
const messages = [
...thread.messages,
...(feedMessages as unknown as Message[]),
];
const messages = composeResearchDisplayMessages(
thread.messages,
feedMessages as unknown as Message[],
);
if (messages === thread.messages) return thread;
return {
...thread,
values: { ...thread.values, messages },
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isInterceptedWritingReportWrite,
isWritingFirstReportTurn,
shouldAutoOpenWritingArtifact,
} from "./writing-mode";
test("custom outputs markdown is always intercepted except the research stub", () => {
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/paper.md"),
true,
);
assert.equal(
isInterceptedWritingReportWrite(
"/mnt/user-data/outputs/report.md",
"research-report-j1",
),
false,
);
});
test("report.md is intercepted for the whole first-report turn", () => {
const beforePipeline = [{ type: "human", content: "写一篇论文" }];
assert.equal(isWritingFirstReportTurn(beforePipeline), true);
assert.equal(
isInterceptedWritingReportWrite(
"/mnt/user-data/outputs/report.md",
"m1",
beforePipeline,
),
true,
);
const inFlight = [
{ type: "human", content: "写一篇论文" },
{
type: "ai",
tool_calls: [{ name: "deep_research_report" }],
},
];
assert.equal(isWritingFirstReportTurn(inFlight), true);
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m2", inFlight),
true,
);
const succeeded = [
...inFlight,
{
type: "tool",
name: "deep_research_report",
content: "研究报告已完成:已写入 report.md。",
},
];
assert.equal(isWritingFirstReportTurn(succeeded), true);
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m3", succeeded),
true,
);
});
test("sandbox auto-open waits for report.md on the first-report turn", () => {
const firstTurn = [
{ type: "human", content: "写一篇论文" },
{
type: "ai",
tool_calls: [{ name: "deep_research_report" }],
},
];
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/research-notes.md", firstTurn),
false,
);
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/report.md", firstTurn),
true,
);
});
test("a later user turn releases report.md live edits", () => {
const followUp = [
{ type: "human", content: "写一篇论文" },
{
type: "ai",
tool_calls: [{ name: "deep_research_report" }],
},
{
type: "tool",
name: "deep_research_report",
content: "研究报告已完成:已写入 report.md。",
},
{ type: "human", content: "把第二节改短一点" },
];
assert.equal(isWritingFirstReportTurn(followUp), false);
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m4", followUp),
false,
);
});
@@ -0,0 +1,112 @@
/** Only the writing-mode deliverable counts as a follow-up edit target. */
const OUTPUTS_PREFIX = "/mnt/user-data/outputs";
export function normalizeWritingPath(path: string | null | undefined): string {
if (!path) return "";
return path.replace(/\\/g, "/").split("?")[0]?.split("#")[0]?.replace(/\/+$/, "") ?? "";
}
export function isWritingModeReportPath(
path: string | null | undefined,
): boolean {
const normalized = normalizeWritingPath(path);
return /(?:^|\/)report\.md$/i.test(normalized);
}
/** Markdown under the sandbox outputs dir — writing mode intercepts these as the report. */
export function isOutputsMarkdownPath(path: string | null | undefined): boolean {
const normalized = normalizeWritingPath(path);
if (!normalized || !/\.(?:md|markdown)$/i.test(normalized)) return false;
return (
normalized === OUTPUTS_PREFIX ||
normalized.startsWith(`${OUTPUTS_PREFIX}/`) ||
normalized === "outputs" ||
normalized.startsWith("outputs/")
);
}
type WritingPipelineMessage = {
type?: string;
name?: string;
content?: unknown;
additional_kwargs?: Record<string, unknown>;
tool_calls?: Array<{ name?: string }>;
};
/**
* First-report turn: no successful pipeline yet, or the latest visible user
* message is still the one that triggered that pipeline. Follow-up edits
* (a later visible human after success) keep live `report.md` writes visible.
*/
export function isWritingFirstReportTurn(
messages: readonly WritingPipelineMessage[] | null | undefined,
): boolean {
if (!messages?.length) return false;
let lastVisibleHuman = -1;
let lastSuccess = -1;
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index];
if (
message?.type === "human" &&
message.additional_kwargs?.hide_from_ui !== true
) {
lastVisibleHuman = index;
}
if (message?.type === "tool" && message.name === "deep_research_report") {
const content = typeof message.content === "string" ? message.content : "";
if (content && !content.toLowerCase().startsWith("error")) {
lastSuccess = index;
}
}
}
return lastVisibleHuman >= 0 && (lastSuccess < 0 || lastVisibleHuman <= lastSuccess);
}
/**
* Hand-written outputs/*.md writes the backend rewrites or drops:
* custom filenames always, and `report.md` while the first-turn pipeline is
* in-flight or just succeeded. The injected research stub stays visible.
*/
export function isInterceptedWritingReportWrite(
path: string | null | undefined,
messageId?: string | null,
messages?: readonly WritingPipelineMessage[] | null,
): boolean {
if (typeof messageId === "string" && messageId.startsWith("research-report-")) {
return false;
}
if (!isOutputsMarkdownPath(path)) {
return false;
}
if (!isWritingModeReportPath(path)) {
return true;
}
return isWritingFirstReportTurn(messages);
}
/** Auto-open the sandbox only for the real report during the first-report turn. */
export function shouldAutoOpenWritingArtifact(
path: string | null | undefined,
messages?: readonly WritingPipelineMessage[] | null,
): boolean {
if (!path) {
return false;
}
if (isWritingFirstReportTurn(messages)) {
return isWritingModeReportPath(path);
}
return !isInterceptedWritingReportWrite(path, undefined, messages);
}
export function resolveWritingModeReportPath(
artifacts: readonly string[] | null | undefined,
selectedArtifact?: string | null,
): string | undefined {
if (isWritingModeReportPath(selectedArtifact)) {
return selectedArtifact ?? undefined;
}
return [...(artifacts ?? [])]
.reverse()
.find((path) => isWritingModeReportPath(path));
}
+8 -10
View File
@@ -45,6 +45,7 @@ import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStrea
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { textOfMessage } from "@/core/threads/utils";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { OptType, writeYLog } from "@/core/log/y-log";
import {
Select,
@@ -612,16 +613,13 @@ export default function AgentChatPage({
} as typeof displayThread;
}, [displayThread, showReportImportCard, taskId, threadId]);
// 写作模式续写轮:已有 md 产物(report.md 已生成)时回传 writing_artifact_path,
// 后端据此走「直接编辑画布」分支而不是再次挂载深度研究写报告管线(与
// ChatPage 的回传逻辑对齐)。
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [thread.values.artifacts]);
// 写作模式续写轮:仅当已有写作模式产物 report.md 时回传 writing_artifact_path,
// 后端据此走「直接编辑画布」分支而不是再次挂载深度研究写报告管线。其它 leftover
// .md 不得跳过管线(与 ChatPage 对齐)。
const writingArtifactPath = useMemo(
() => resolveWritingModeReportPath(thread.values.artifacts),
[thread.values.artifacts],
);
// ── 「模板数据转换器」对话页专属调试入口(仅此 agent)──────────────────────────
// 选一个页面模板,问答跑完后点「渲染预览」:取最近一条 AI 回复里的 JSON 填进模板,
+6 -10
View File
@@ -63,6 +63,7 @@ import {
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
@@ -354,16 +355,11 @@ export default function ChatPage() {
researchDisplayThread,
]);
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
if (isMarkdown(selectedArtifact)) {
return selectedArtifact ?? undefined;
}
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [selectedArtifact, thread.values.artifacts]);
const writingArtifactPath = useMemo(
() =>
resolveWritingModeReportPath(thread.values.artifacts, selectedArtifact),
[selectedArtifact, thread.values.artifacts],
);
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
+6 -10
View File
@@ -42,6 +42,7 @@ import {
import { usePluginManagementSettings } from "@/core/system-settings/hooks";
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import {
@@ -285,16 +286,11 @@ export default function IframeChatPage() {
threadMetadataAgentId,
]);
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
if (isMarkdown(selectedArtifact)) {
return selectedArtifact ?? undefined;
}
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [selectedArtifact, thread.values.artifacts]);
const writingArtifactPath = useMemo(
() =>
resolveWritingModeReportPath(thread.values.artifacts, selectedArtifact),
[selectedArtifact, thread.values.artifacts],
);
const handleSubmit = useCallback(
(
@@ -43,6 +43,41 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _parent_isolated_run_config(run_name: str) -> dict[str, Any]:
"""Keep LangGraph runtime, drop only inherited LLM streaming callbacks.
``StreamWriter`` reads ``get_config()[configurable]`` at emit time. Replacing
the child config with a blank dict makes ``report_delta`` / ``report_thinking``
raise and get swallowed, so the sandbox only appears after the file lands.
"""
parent: dict[str, Any] = {}
try:
from langchain_core.runnables.config import var_child_runnable_config
current = var_child_runnable_config.get()
if isinstance(current, dict):
parent = dict(current)
except Exception: # noqa: BLE001 — isolation must never fail the completion
parent = {}
tags = [tag for tag in (parent.get("tags") or []) if tag != "nostream"]
tags.append("nostream")
parent["callbacks"] = []
parent["tags"] = tags
parent["run_name"] = run_name
return parent
def _clear_inherited_runnable_config(isolated: dict[str, Any]) -> Callable[[], None]:
"""Install the isolated config for the duration of ``astream``."""
try:
from langchain_core.runnables.config import var_child_runnable_config
except ImportError:
return lambda: None
token = var_child_runnable_config.set(isolated)
return lambda: var_child_runnable_config.reset(token)
# ── message conversion ──────────────────────────────────────────────────────
@@ -347,7 +382,18 @@ 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):
# Isolate from the parent LangGraph run: inherited callbacks would
# replay every report token into the chat ``messages`` stream, so the
# left-hand bubble and the sandbox would write the same prose at once.
# Custom ``report_delta`` events remain the only live preview channel.
isolated_config = _parent_isolated_run_config(f"deep_research:{operation}")
reset_parent_config = _clear_inherited_runnable_config(isolated_config)
try:
async for chunk in model.astream(
to_langchain_messages(messages),
config=isolated_config,
**kwargs,
):
if self._cancel is not None:
self._cancel.raise_if_cancelled()
reasoning = _reasoning_delta(chunk)
@@ -367,6 +413,8 @@ class DeerFlowCompletionBackend:
chunk_usage = _extract_usage(chunk)
if any(chunk_usage.values()):
usage = chunk_usage
finally:
reset_parent_config()
tail, inline_reasoning_tail = inline_think_filter.finish_parts()
if inline_reasoning_tail and on_reasoning is not None:
@@ -198,16 +198,37 @@ class BasicRunner:
cancellation.raise_if_cancelled()
if not all_materials:
knowledge_fill = _allows_knowledge_fill(request.config.report_instruction)
await events.emit(
"warning",
phase="curating",
payload={"code": "no_materials", "message": "未收集到任何研究材料", "recoverable": False},
payload={
"code": "no_materials",
"message": (
"未收集到外部研究材料,改用领域知识撰写"
if knowledge_fill
else "未收集到任何研究材料"
),
"recoverable": knowledge_fill,
},
)
if not knowledge_fill:
report_md = (
f"# {request.query}\n\n"
f"未能收集到与研究课题「{request.query}」相关的材料,无法生成报告。"
)
report_md = f"# {request.query}\n\n未能收集到与研究课题「{request.query}」相关的材料,无法生成报告。"
await adapters.artifacts.write_text("report.md", report_md)
await events.emit("report_completed", phase="writing", payload={"reportVersion": 1, "sourceCount": 0})
await events.emit(
"report_completed",
phase="writing",
payload={"reportVersion": 1, "sourceCount": 0},
)
return DeepResearchResult(report_markdown=report_md, source_ids=[], plan=plan)
deduped: list[ResearchMaterial] = []
selected_ids: list[str] = []
context_list: list[dict[str, Any]] = []
context_text = _KNOWLEDGE_FILL_EMPTY_CONTEXT
else:
from deerflow.agents.deep_research.adapters.material import dedupe_materials
deduped = dedupe_materials(all_materials)
@@ -221,11 +242,18 @@ class BasicRunner:
)
# ── 4. compress context ────────────────────────────────────────
await events.emit("phase_changed", phase="compressing", payload={"from": "curating", "to": "compressing"})
await events.emit(
"phase_changed",
phase="compressing",
payload={"from": "curating", "to": "compressing"},
)
if cancellation:
cancellation.raise_if_cancelled()
context_list = await context_comp.async_get_context(request.query, max_results=min(len(deduped), config.max_search_results_per_query * 2))
context_list = await context_comp.async_get_context(
request.query,
max_results=min(len(deduped), config.max_search_results_per_query * 2),
)
context_text = _format_context(context_list, deduped)
# ── 5. writing: generate report ────────────────────────────────
@@ -403,6 +431,13 @@ class BasicRunner:
)
outline_block = _report_outline_instruction(outline)
requirements_block = _report_requirements_instruction(request.config.report_instruction)
if _allows_knowledge_fill(request.config.report_instruction):
system_extra = (
"用户已要求在材料不足时用模型自身的公开领域知识写成完整报告。"
"禁止把「材料不足/未提供文献」写成章节主线;参考文献可标注为基于公开领域知识。"
)
else:
system_extra = ""
if request.config.structure_mode == "fixed":
# Recency bias: keep the skeleton immediately before "请撰写报告".
context_block = (
@@ -427,6 +462,8 @@ class BasicRunner:
if filled_preamble:
await emitter.add(filled_preamble + "\n\n")
system = _SYSTEM
if system_extra:
system += system_extra
if request.config.structure_mode == "fixed" and (outline or "").strip():
system += "撰写时必须原样沿用用户大纲中的章节标题与层级,不得改写成通用报告套式。"
result = await llm.stream_complete(
@@ -624,12 +661,31 @@ def ensure_markdown_standalone_lines(text: str) -> str:
return "\n".join(output)
_KNOWLEDGE_FILL_EMPTY_CONTEXT = (
"(本次未收集到外部研究材料。请基于模型自身的公开领域知识撰写完整、有实质内容的研究报告,"
"含架构说明;用户要求流程图时输出 Mermaid。"
"禁止只写「无法生成报告」或把「材料不足」写成全文。)"
)
_KNOWLEDGE_FILL_MARKERS = ("必须基于模型自身的领域知识", "基于模型自身的领域知识")
def _allows_knowledge_fill(report_instruction: str | None) -> bool:
text = report_instruction or ""
return any(marker in text for marker in _KNOWLEDGE_FILL_MARKERS)
def _report_requirements_instruction(report_instruction: str | None) -> str:
"""Render confirmed generation requirements as bounded, untrusted content."""
if not report_instruction:
return "User generation requirements: none provided."
evidence_note = (
"these requirements override the default 'do not go beyond materials' rule "
"when they ask to write from model knowledge"
if _allows_knowledge_fill(report_instruction)
else "content only; system and evidence rules still win"
)
return (
"User generation requirements (content only; system and evidence rules still win):\n"
f"User generation requirements ({evidence_note}):\n"
f"<user-report-requirements>\n{report_instruction}\n</user-report-requirements>"
)
@@ -426,6 +426,18 @@ def _build_middlewares(
if agent_name == FORCED_RESEARCH_AGENT_ID:
middlewares.append(ForcedResearchMiddleware())
# 写作模式首轮:允许先聊/检索;只有模型开始写 md 时才改走 deep_research_report。
# 不设 tool_choice——thinking 模式的兼容接口会 400。
writing_mode = bool(cfg.get("writing_mode", False))
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
if writing_mode:
from deerflow.tools.builtins.deep_research_report_tool import is_writing_mode_report_path
if not is_writing_mode_report_path(writing_artifact_path if isinstance(writing_artifact_path, str) else None):
from deerflow.agents.middlewares.writing_mode_report_middleware import WritingModeReportMiddleware
middlewares.append(WritingModeReportMiddleware())
# LoopDetectionMiddleware — detect and break repetitive tool call loops
middlewares.append(LoopDetectionMiddleware())
@@ -517,6 +529,12 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
writing_artifact_path = None
# 只有写作模式产物 report.md 才走续写;其它 leftover .md 不得跳过管线。
if writing_artifact_path is not None:
from deerflow.tools.builtins.deep_research_report_tool import is_writing_mode_report_path
if not is_writing_mode_report_path(writing_artifact_path):
writing_artifact_path = None
agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name"))
requested_excluded_tools = cfg.get("excluded_tools") or None
@@ -691,10 +709,10 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
from deerflow.tools.builtins.notebook_search_tool import notebook_search_tool
default_tools = [notebook_search_tool] + default_tools
# 写作模式首轮(尚无 md 产物):挂深度研究报告工具,报告由管线生成而非
# 模型手工 write_file。续写轮(已有 writing_artifact_path)不挂,保持
# 原有「直接编辑该文件」行为。不受 tool_groups/excluded_tools 过滤——
# 它是写作模式合约的核心交付手段。
# 写作模式首轮(尚无 report.md):挂深度研究报告工具,报告由管线生成而非
# 模型手工 write_file。续写轮(已有 report.md)不挂,保持原有「直接编辑
# 该文件」行为。不受 tool_groups/excluded_tools 过滤——它是写作模式合约
# 的核心交付手段。
if writing_mode and not writing_artifact_path:
from deerflow.tools.builtins.deep_research_report_tool import deep_research_report_tool
@@ -792,8 +792,7 @@ def apply_agent_only_prompt_template(
else ""
)
if writing_mode and not writing_artifact_path:
# 与通用模板一致:对话行为照旧,交付时刻的「写 md」动作交给
# deep_research_report 管线;资料不足时先经 ask_clarification 问用户。
# 对话行为照旧;只有开始写 md 时才走 deep_research_report。资料不足也直接开写。
writing_mode_section = """<writing_mode>
Writing mode is enabled for this run (first report turn — no existing artifact).
- HARD RULE: the report file is produced ONLY by the `deep_research_report` tool — never write it yourself with `write_file`, never `present_files` it.
@@ -801,11 +800,11 @@ Writing mode is enabled for this run (first report turn — no existing artifact
`topic` = the user's subject copied faithfully, `focus` = the user's extra requirements (if any).
The tool reuses the conversation's collected materials and writes the full report (general-analysis structure with references)
to `/mnt/user-data/outputs/report.md`, presented automatically.
- If the tool reports insufficient materials, call `ask_clarification` to ask whether to search for more; after the user answers,
re-call the tool with the same `topic`/`focus` plus `retrieve=true` (search more) or `retrieve=false` (existing materials only).
NEVER pass `retrieve=true` before the user has answered — the tool rejects it and bounces you back to the question.
- If harvested materials are thin, still call `deep_research_report` once and let it write immediately.
Do NOT ask the user whether to search for more, and do NOT call `ask_clarification` for insufficient materials.
- Search with your normal conversation tools. `deep_research_report` only writes the report file from materials already in this conversation; it does not search.
- Do NOT write the report body yourself with `write_file`, and do NOT call `present_files` for it.
- After the tool returns, reply briefly (2-3 sentences) summarizing the core conclusions; do not duplicate the report inline.
- After the tool returns successfully: do NOT call `read_file`, `write_file`, `str_replace`, or `present_files` on the report. Reply briefly (2-3 sentences) summarizing the core conclusions. Do not rewrite even if you think materials were thin.
- Only if the tool fails, tell the user and offer to retry.
</writing_mode>
"""
@@ -1473,9 +1472,8 @@ def apply_prompt_template(
else ""
)
if writing_mode and not writing_artifact_path:
# 首轮:对话行为一切照旧,仅在交付时刻把「写 md」动作交给
# deep_research_report 管线(收割对话资料 + 通用分析结构)。资料不足时
# 工具会返回指令,让模型经 ask_clarification 征询用户是否补充检索。
# 对话行为一切照旧;只有开始写 md 时才把交付交给 deep_research_report。
# 资料不足也直接开写,不再经 ask_clarification 征询用户是否补充检索。
writing_mode_section = """
<writing_mode>
Writing mode is enabled for this run (first report turn — no existing artifact).
@@ -1487,14 +1485,13 @@ Writing mode is enabled for this run (first report turn — no existing artifact
- When the report file is due — the user asks to start writing, or you have finished gathering what the conversation needs — call the `deep_research_report` tool:
- `topic`: the user's subject/thesis, copied faithfully.
- `focus`: the user's extra requirements (length, tone, must-cover points), if any.
- The tool reuses the materials already collected in this conversation (and searches more only if the user agrees), then writes the full
- The tool reuses the materials already collected in this conversation, then writes the full
report (general-analysis structure with a numbered reference section) to `/mnt/user-data/outputs/report.md`, presented automatically.
- If the tool replies that the harvested materials are insufficient, it returns instructions: immediately call `ask_clarification` asking
whether to search for more materials, and after the user answers, re-call `deep_research_report` with the same `topic`/`focus` plus
`retrieve=true` (user agreed) or `retrieve=false` (user declined — write with what exists).
NEVER pass `retrieve=true` before the user has answered — the tool rejects it and bounces you back to the question.
- If harvested materials are thin, still call `deep_research_report` once and let it start the writing pipeline immediately.
Do NOT ask the user whether to search for more materials, and do NOT call `ask_clarification` for that reason.
- Search with your normal conversation tools. `deep_research_report` only writes the report file from materials already in this conversation; it does not search.
- Do NOT write the report body yourself with `write_file`, and do NOT call `present_files` for the report — the tool already handles both.
- While the tool runs, stay silent; after it returns, reply briefly (2-3 sentences) summarizing the report's core conclusions and point the user to the Markdown artifact. Do not duplicate the report inline.
- While the tool runs, stay silent. After it returns successfully: do NOT call `read_file`, `write_file`, `str_replace`, or `present_files` on the report. Reply briefly (2-3 sentences) summarizing the report's core conclusions and point the user to the Markdown artifact. Do not duplicate the report inline, and do not rewrite even if you think materials were thin.
- Only if the tool fails, tell the user what went wrong and offer to retry; do not silently fall back to writing the report by hand unless the user explicitly asks.
</writing_mode>
"""
@@ -0,0 +1,468 @@
"""Redirect writing-mode Markdown deliverables onto ``deep_research_report``.
Writing mode still allows normal chat, search, and clarification. The pipeline
starts only when the model begins writing the report file (``write_file`` /
``str_replace`` of ``report.md`` or any Markdown under
``/mnt/user-data/outputs/``): those calls are rewritten to
``deep_research_report``. Sibling ``present_files`` of the same report are
dropped because the pipeline presents the file itself.
After the pipeline has succeeded once in this thread, further
``deep_research_report`` / hand-written report writes / ``read_file`` of the
report are dropped. The first time that happens, a hidden summary nudge is
injected and the graph jumps back to the model so it can write the 2–3
sentence wrap-up. If it still tries to loop after that nudge, the turn ends.
Ending immediately on the first rewrite attempt left the model's
"I will rewrite this" text as the visible summary.
This middleware never sets ``tool_choice``. Thinking-mode providers (Aliyun
compatible-mode) reject ``required`` / object ``tool_choice`` with HTTP 400.
"""
from __future__ import annotations
import json
import logging
from collections.abc import Awaitable, Callable
from copy import deepcopy
from typing import Any, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware, hook_config
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.runtime import Runtime
from langgraph.types import Command
from deerflow.tools.builtins.deep_research_report_tool import is_writing_mode_report_path
logger = logging.getLogger(__name__)
TOOL_NAME = "deep_research_report"
_WRITE_TOOLS = frozenset({"write_file", "str_replace"})
_READ_TOOLS = frozenset({"read_file", "read_multiple_files"})
_PRESENT_TOOL = "present_files"
_OUTPUTS_PREFIX = "/mnt/user-data/outputs"
_MAX_TOPIC_CHARS = 500
_NUDGE_KWARG = "writing_mode_summary_nudge"
_REJECT_HANDWRITTEN = (
"写作模式的报告文件必须由 `deep_research_report` 生成,禁止用 write_file / "
"str_replace 手写报告。请立即调用 `deep_research_report`(topic=用户课题原文)。"
)
_REJECT_AFTER_SUCCESS = (
"写作管线已经完成,报告已写入 /mnt/user-data/outputs/report.md 并展示给用户。"
"禁止再次调用 deep_research_report,也禁止用 write_file / str_replace / "
"read_file 读写该报告。请只用 2-3 句话总结核心结论后结束本轮,不要再调用任何文件或写作工具。"
)
_SUMMARY_NUDGE = (
"<system_reminder>\n"
"写作管线已经完成,报告已写入 /mnt/user-data/outputs/report.md 并展示给用户。\n"
"请只用 2-3 句话总结核心结论,不要再调用任何工具,不要阅读或重写该文件。\n"
"不要评价材料是否不足,不要计划重写。\n"
"</system_reminder>"
)
def _is_visible_human(message: Any) -> bool:
if not isinstance(message, HumanMessage):
return False
kwargs = getattr(message, "additional_kwargs", None) or {}
return not kwargs.get("hide_from_ui")
def _normalize_args(raw: object) -> dict[str, Any]:
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
parsed = json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
return {}
return parsed if isinstance(parsed, dict) else {}
return {}
def _tool_field(tool_call: object, key: str, default: Any = None) -> Any:
if isinstance(tool_call, dict):
return tool_call.get(key, default)
getter = getattr(tool_call, "get", None)
if callable(getter):
return getter(key, default)
return getattr(tool_call, key, default)
def _tool_name(tool_call: object) -> str:
return str(_tool_field(tool_call, "name", "") or "")
def _tool_path(tool_call: object) -> str:
args = _normalize_args(_tool_field(tool_call, "args", {}))
path = args.get("path")
if path is None:
files = args.get("files") or args.get("filepaths") or args.get("paths")
if isinstance(files, list) and files:
path = files[0]
return str(path or "").strip().replace("\\", "/")
def _is_outputs_markdown_path(path: str) -> bool:
if is_writing_mode_report_path(path):
return True
if not path:
return False
lowered = path.lower()
if not (lowered.endswith(".md") or lowered.endswith(".markdown")):
return False
if path == _OUTPUTS_PREFIX or path.startswith(f"{_OUTPUTS_PREFIX}/"):
return True
relative = path.lstrip("./")
return relative == "outputs" or relative.startswith("outputs/")
def is_handwritten_report_call(tool_call: object) -> bool:
"""True when the model is about to hand-write the writing-mode Markdown file."""
if _tool_name(tool_call) not in _WRITE_TOOLS:
return False
return _is_outputs_markdown_path(_tool_path(tool_call))
def is_report_present_call(tool_call: object) -> bool:
if _tool_name(tool_call) != _PRESENT_TOOL:
return False
path = _tool_path(tool_call)
if path:
return _is_outputs_markdown_path(path)
args = _normalize_args(_tool_field(tool_call, "args", {}))
files = args.get("files") or args.get("filepaths") or args.get("paths") or []
if isinstance(files, list):
return any(_is_outputs_markdown_path(str(item or "").strip().replace("\\", "/")) for item in files)
return False
def is_pipeline_call(tool_call: object) -> bool:
return _tool_name(tool_call) == TOOL_NAME
def is_report_reread_call(tool_call: object) -> bool:
"""True when the model is about to re-read the writing-mode Markdown file."""
if _tool_name(tool_call) not in _READ_TOOLS:
return False
path = _tool_path(tool_call)
if path:
return _is_outputs_markdown_path(path)
args = _normalize_args(_tool_field(tool_call, "args", {}))
files = args.get("files") or args.get("filepaths") or args.get("paths") or []
if isinstance(files, list):
return any(_is_outputs_markdown_path(str(item or "").strip().replace("\\", "/")) for item in files)
return False
def is_deliverable_loop_call(tool_call: object) -> bool:
"""Pipeline, hand-written report, or presenting the report — all loop fuel."""
return is_pipeline_call(tool_call) or is_handwritten_report_call(tool_call) or is_report_present_call(tool_call)
def is_post_success_loop_call(tool_call: object) -> bool:
"""After a successful pipeline, re-reading the report is also a loop."""
return is_deliverable_loop_call(tool_call) or is_report_reread_call(tool_call)
def _already_nudged_summary(messages: list[Any]) -> bool:
for message in messages:
if not isinstance(message, HumanMessage):
continue
kwargs = getattr(message, "additional_kwargs", None) or {}
if kwargs.get(_NUDGE_KWARG):
return True
return False
def _summary_nudge_message() -> HumanMessage:
return HumanMessage(
content=_SUMMARY_NUDGE,
additional_kwargs={"hide_from_ui": True, _NUDGE_KWARG: True},
)
def is_post_success_loop_call(tool_call: object) -> bool:
"""After a successful pipeline, re-reading the report is also a loop."""
return is_deliverable_loop_call(tool_call) or is_report_reread_call(tool_call)
def _already_nudged_summary(messages: list[Any]) -> bool:
for message in messages:
if not isinstance(message, HumanMessage):
continue
kwargs = getattr(message, "additional_kwargs", None) or {}
if kwargs.get(_NUDGE_KWARG):
return True
return False
def _summary_nudge_message() -> HumanMessage:
return HumanMessage(
content=_SUMMARY_NUDGE,
additional_kwargs={"hide_from_ui": True, _NUDGE_KWARG: True},
)
def _report_tool_succeeded(message: ToolMessage) -> bool:
if str(getattr(message, "name", "") or "") != TOOL_NAME:
return False
status = str(getattr(message, "status", "") or "").lower()
if status == "error":
return False
content = str(getattr(message, "content", "") or "").strip()
if not content:
return False
return not content.lower().startswith("error")
def pipeline_already_succeeded(messages: list[Any]) -> bool:
"""True after any successful ``deep_research_report`` in this thread."""
return any(isinstance(message, ToolMessage) and _report_tool_succeeded(message) for message in messages)
def _message_text(message: Any) -> str:
content = getattr(message, "content", "")
if isinstance(content, list):
parts: list[str] = []
for part in content:
if isinstance(part, str):
parts.append(part)
elif isinstance(part, dict):
text = part.get("text")
if isinstance(text, str):
parts.append(text)
return " ".join(parts).strip()
return str(content or "").strip()
def extract_report_topic(messages: list[Any]) -> str:
"""Use the latest visible user text as ``deep_research_report.topic``."""
for message in reversed(messages):
if _is_visible_human(message):
text = " ".join(_message_text(message).split())
if text:
return text[:_MAX_TOPIC_CHARS]
return "当前课题"
def _as_report_call(tool_call: object, topic: str) -> dict[str, Any]:
call_id = str(_tool_field(tool_call, "id") or "")
return {
"name": TOOL_NAME,
"args": {"topic": topic},
"id": call_id,
"type": "tool_call",
}
def _kept_call_ids(calls: list[Any]) -> set[str]:
return {str(_tool_field(call, "id") or "") for call in calls if _tool_field(call, "id")}
def _sync_raw_tool_calls(last: AIMessage, kept: list[Any]) -> dict[str, Any]:
update: dict[str, Any] = {"tool_calls": kept}
additional_kwargs = dict(getattr(last, "additional_kwargs", {}) or {})
raw_calls = additional_kwargs.get("tool_calls")
kept_ids = _kept_call_ids(kept)
rewritten_by_id = {
str(_tool_field(call, "id") or ""): call
for call in kept
if _tool_field(call, "id")
}
if isinstance(raw_calls, list) and raw_calls:
synced: list[Any] = []
for item in raw_calls:
if not isinstance(item, dict):
continue
item_id = str(item.get("id") or "")
if item_id not in kept_ids:
continue
replacement = rewritten_by_id.get(item_id)
if replacement is not None and _tool_name(replacement) == TOOL_NAME:
function = dict(item.get("function") or {})
function["name"] = TOOL_NAME
function["arguments"] = json.dumps(
_normalize_args(_tool_field(replacement, "args", {})),
ensure_ascii=False,
)
synced.append({**item, "function": function})
else:
synced.append(item)
if synced:
additional_kwargs["tool_calls"] = synced
else:
additional_kwargs.pop("tool_calls", None)
additional_kwargs.pop("function_call", None)
elif not kept:
additional_kwargs.pop("tool_calls", None)
additional_kwargs.pop("function_call", None)
update["additional_kwargs"] = additional_kwargs
if not kept:
response_metadata = deepcopy(getattr(last, "response_metadata", {}) or {})
if response_metadata.get("finish_reason") == "tool_calls":
response_metadata["finish_reason"] = "stop"
update["response_metadata"] = response_metadata
return update
def rewrite_handwritten_report_calls(
last: AIMessage,
*,
topic: str,
pipeline_already_done: bool = False,
) -> AIMessage | None:
"""Rewrite or drop report deliverable tool calls.
Before the pipeline has succeeded: rewrite the first hand-written report
write to ``deep_research_report`` and drop sibling presents.
After it has succeeded: drop every pipeline / hand-written / present /
report-reread call so the model cannot start another writing loop.
"""
tool_calls = list(getattr(last, "tool_calls", None) or [])
if not tool_calls:
return None
if pipeline_already_done:
kept = [call for call in tool_calls if not is_post_success_loop_call(call)]
if len(kept) == len(tool_calls):
return None
update = _sync_raw_tool_calls(last, kept)
if not kept:
update["content"] = ""
return last.model_copy(update=update)
has_pipeline = any(is_pipeline_call(call) for call in tool_calls)
rewritten: list[Any] = []
changed = False
redirected = has_pipeline
for call in tool_calls:
if is_pipeline_call(call):
rewritten.append(call)
continue
if is_handwritten_report_call(call):
changed = True
if redirected:
continue
rewritten.append(_as_report_call(call, topic))
redirected = True
continue
if is_report_present_call(call) and (
has_pipeline or redirected or any(is_handwritten_report_call(item) for item in tool_calls)
):
changed = True
continue
rewritten.append(call)
if not changed:
return None
return last.model_copy(update=_sync_raw_tool_calls(last, rewritten))
def _messages_from_tool_request(request: ToolCallRequest) -> list[Any]:
state = getattr(request, "state", None) or {}
if isinstance(state, dict):
return list(state.get("messages") or [])
messages = getattr(state, "messages", None)
return list(messages or [])
class WritingModeReportMiddleware(AgentMiddleware[AgentState]):
"""Start the writing pipeline only when the model begins writing the Markdown file."""
state_schema = AgentState
def _redirect_or_stop(self, state: AgentState) -> dict[str, Any] | None:
messages = list(state.get("messages") or [])
if not messages:
return None
last = messages[-1]
if not isinstance(last, AIMessage):
return None
already_done = pipeline_already_succeeded(messages)
patched = rewrite_handwritten_report_calls(
last,
topic=extract_report_topic(messages),
pipeline_already_done=already_done,
)
if patched is None:
return None
result: dict[str, Any] = {"messages": [patched]}
if already_done and not (patched.tool_calls or []):
if _already_nudged_summary(messages):
logger.info(
"Writing-mode pipeline already succeeded; summary nudge ignored, ending turn"
)
result["jump_to"] = "end"
return result
logger.info(
"Writing-mode pipeline already succeeded; dropping deliverable tools and nudging summary"
)
result["messages"] = [patched, _summary_nudge_message()]
result["jump_to"] = "model"
return result
def _reject_loop_tool(self, request: ToolCallRequest) -> ToolMessage | None:
tool_call = getattr(request, "tool_call", None) or {}
already_done = pipeline_already_succeeded(_messages_from_tool_request(request))
if already_done and is_post_success_loop_call(tool_call):
return ToolMessage(
content=f"Error: {_REJECT_AFTER_SUCCESS}",
tool_call_id=str(_tool_field(tool_call, "id") or ""),
name=str(_tool_name(tool_call) or TOOL_NAME),
)
if not already_done and is_handwritten_report_call(tool_call):
return ToolMessage(
content=f"Error: {_REJECT_HANDWRITTEN}",
tool_call_id=str(_tool_field(tool_call, "id") or ""),
name=str(_tool_name(tool_call) or "write_file"),
)
return None
@hook_config(can_jump_to=["end", "model"])
@override
def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
return self._redirect_or_stop(state)
@hook_config(can_jump_to=["end", "model"])
@override
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
return self._redirect_or_stop(state)
@override
def wrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
rejected = self._reject_loop_tool(request)
if rejected is not None:
return rejected
return handler(request)
@override
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
) -> ToolMessage | Command:
rejected = self._reject_loop_tool(request)
if rejected is not None:
return rejected
return await handler(request)
__all__ = [
"TOOL_NAME",
"WritingModeReportMiddleware",
"extract_report_topic",
"is_handwritten_report_call",
"is_post_success_loop_call",
"is_report_present_call",
"is_report_reread_call",
"pipeline_already_succeeded",
"rewrite_handwritten_report_calls",
]
@@ -7,19 +7,12 @@
``outputs/`` 目录——前端画布 / 编辑 / 下载 / 续写全部沿用既有 artifacts 通道。
除「写 md」这一动作换成管线外,agent 的对话 / 检索 / 澄清行为一切照旧。
资料来源三分支(收割池先按**当前主题**做词面相关度强过滤——旧课题的检索
结果既不计入门槛,也不进报告):
资料来源(收割池先按**当前主题**做词面相关度强过滤——旧课题的检索
结果不进报告):
- 主题相关池足够(≥ :data:`_MIN_HARVESTED_MATERIALS`)→ 直接用对话资料写;
- 不足且用户已答复 → ``retrieve=True`` 自检索(对话资料合并去重)、
``retrieve=False`` 只用现有资料(哪怕为空——basic runner 有优雅兜底);
- 不足且未询问 → 返回指令 ToolMessage,让模型用 ``ask_clarification``
询问用户是否补充检索,用户答复后带 ``retrieve`` 重调本工具。
``retrieve=True`` 不只是提示词约束:工具会核验消息史里存在
「本工具的资料不足答复 → ask_clarification → 用户回复」完整链
(:func:`_retrieval_confirmed_by_user`),模型擅自传 ``retrieve=True``
会被拒绝并打回询问流程。
- 一律用对话里已经产生的资料写(哪怕为空——basic runner 有领域知识兜底),
**不再**因条数不足打断用户,也**不再**在管线内部补检索。
需要检索时由 lead agent 用对话工具先搜,再调用本工具写文件。
报告管线继承聊天里选择的模型与思考开关(``context.model_name`` /
``thinking_enabled`` / ``thinking_force_disabled``,经 run config 白名单透
@@ -60,12 +53,11 @@ from deerflow.agents.deep_research.events import DeepResearchEvent, EventType, R
from deerflow.agents.deep_research.types import AdapterBundle, DeepResearchRequest, ResearchMaterial
from deerflow.agents.thread_state import ThreadState
from deerflow.persistence.report_structures.defaults import GENERAL_OUTLINE
from deerflow.runtime.references import _message_name, _message_type
from deerflow.runtime.thread_paths import resolve_path_user_id
logger = logging.getLogger(__name__)
# 收割池低于该条数视为「资料不足」,需要先征询用户是否补充检索。
# 收割池低于该条数视为「资料偏少」,仅记日志,不再打断用户。
_MIN_HARVESTED_MATERIALS = 3
# 引擎事件中值得转发到聊天的子集(model_usage / heartbeat / job_* 只对持久
@@ -96,6 +88,17 @@ _FORWARDABLE_LIVE_EVENTS: frozenset[str] = frozenset({"report_delta", "report_th
REPORT_VIRTUAL_PATH = "/mnt/user-data/outputs/report.md"
def is_writing_mode_report_path(path: str | None) -> bool:
"""Only ``report.md`` counts as the writing-mode deliverable for follow-up edits.
Leftover notes / skill Markdown must not skip the first-report pipeline.
"""
if not path or not isinstance(path, str):
return False
normalized = path.replace("\\", "/").split("?", 1)[0].split("#", 1)[0].rstrip("/")
return normalized.lower().endswith("/report.md") or normalized.lower() == "report.md"
class StreamWriterEventSink:
"""把引擎事件经 LangGraph custom 通道转发到聊天前端。
@@ -211,6 +214,15 @@ class HarvestPlusSearchMaterialProvider:
return dedupe_materials(combined)
# 写作模式常在「不用搜索 / 对话资料很少」时开写。工作台默认的「材料不足就
# 写明资料不足」会把报告写成元分析;这里强制用领域知识补全正文。
_KNOWLEDGE_WRITE_INSTRUCTION = (
"对话材料很少或用户要求直接开写时:必须基于模型自身的领域知识写成完整、有实质内容的研究报告"
"(含架构说明;用户要求流程图时用 Mermaid)。禁止把「材料不足/未提供文献/现有材料高度浓缩」"
"写成报告主线或用其填充多个章节。参考文献可标为基于公开领域知识,不要用「材料未提供」占整节。"
)
def build_writing_research_config(
*,
focus: str | None = None,
@@ -224,7 +236,10 @@ def build_writing_research_config(
覆盖 fast/smart/strategic 三个角色,使报告管线与对话用同一个模型和思考
开关(缺省回落系统默认模型)。
"""
instruction = focus.strip() if focus and focus.strip() else None
parts: list[str] = []
if focus and focus.strip():
parts.append(focus.strip())
parts.append(_KNOWLEDGE_WRITE_INSTRUCTION)
return DeepResearchConfig(
mode="basic",
collection_mode="legacy",
@@ -235,7 +250,7 @@ def build_writing_research_config(
fast_model=model_name,
smart_model=model_name,
strategic_model=model_name,
**({"report_instruction": instruction} if instruction else {}),
report_instruction="\n".join(parts),
).clamp()
@@ -325,33 +340,6 @@ def _filter_relevant_materials(
return relevant, len(materials) - len(relevant)
# ── retrieve=True 的用户确认核验(P1:不靠提示词,消息史里找证据链) ──────────
def _retrieval_confirmed_by_user(messages: list[Any]) -> bool:
"""核验消息史存在「本工具资料不足答复 → ask_clarification → 用户回复」。
模型无法伪造 ToolMessage / HumanMessage(分别由工具执行与真实用户输入
产生),因此该链成立 ⇔ 用户确实被问过并答过。跨 run 依然有效——消息
历史就是 LangGraph 的持久状态。
"""
last_report_reply = -1
for index, message in enumerate(messages):
if _message_type(message) == "tool" and str(_message_name(message) or "") == "deep_research_report":
last_report_reply = index
if last_report_reply < 0:
return False
asked = False
for message in messages[last_report_reply + 1 :]:
if _message_type(message) != "tool":
if asked and _message_type(message) == "human":
return True
continue
if str(_message_name(message) or "") == "ask_clarification":
asked = True
return False
def _safe_stream_writer() -> Any:
"""获取当前 run 的 custom 事件写入器;非流式上下文返回 None。"""
try:
@@ -362,45 +350,6 @@ def _safe_stream_writer() -> Any:
return None
def _insufficient_materials_message(
relevant_count: int,
minimum: int,
*,
topic: str,
total_count: int,
dropped_count: int,
) -> str:
detail = (
f"对话中共整理出 {total_count} 条资料,其中与当前主题「{topic}」相关的仅 {relevant_count} 条"
if dropped_count > 0
else f"对话中仅整理出 {relevant_count} 条与主题「{topic}」相关的资料"
)
return (
f"[需要用户确认] {detail}(低于 {minimum} 条的最低参考线)。"
"在生成报告前,请立即调用 `ask_clarification` 询问用户:\n"
'question: "对话中已有的资料较少,是否自动检索更多资料来写报告?"\n'
'clarification_type: "missing_info"\n'
f'context: "当前对话仅整理出 {relevant_count} 条与主题相关的资料;补充检索可让报告更充实,但会多花一些时间。"\n'
'options: ["检索更多资料,再写报告", "不用检索,直接用现有资料写"]\n'
"用户选择检索 → 携带 retrieve=true 重新调用本工具(参数 topic/focus 保持不变);"
"用户选择不检索 → 携带 retrieve=false 重新调用本工具。"
"在得到用户答复前,不要自行检索,也不要用 write_file 手工撰写报告。"
"注意:未经上述询问并获得用户答复,本工具会拒绝 retrieve=true。"
)
def _retrieve_without_confirmation_message() -> str:
return (
"[参数被拒绝] retrieve=true 只允许在用户通过 ask_clarification 明确同意补充检索之后使用;"
"本次调用未检测到「询问用户 → 用户答复」的记录,已按资料不足处理。"
"请立即调用 `ask_clarification` 询问用户是否检索更多资料"
'(question: "对话中已有的资料较少,是否自动检索更多资料来写报告?",'
'clarification_type: "missing_info",options: ["检索更多资料,再写报告", "不用检索,直接用现有资料写"]),'
"用户答复后再按其选择重新调用本工具(retrieve=true / false)。"
"不要不经询问直接传 retrieve=true,也不要用 write_file 手工撰写报告。"
)
@tool("deep_research_report", parse_docstring=True)
async def deep_research_report_tool(
runtime: ToolRuntime[ContextT, ThreadState],
@@ -423,20 +372,18 @@ async def deep_research_report_tool(
automatically; do NOT write the report body yourself with write_file.
If the harvested pool is thin (or little of it matches the current topic), the
tool returns instructions to ask the user whether to search for more materials
(via ask_clarification); after the user answers, re-call this tool with
`retrieve` set accordingly (same topic/focus). `retrieve=true` is REJECTED
unless the conversation shows that ask_clarification exchange — never pass it
speculatively.
tool still starts the writing pipeline immediately with whatever relevant
materials exist — do NOT ask the user whether to search for more, and do NOT
call ask_clarification for that reason. Search in the conversation first with
your normal tools; this tool only writes the file.
Args:
topic: The research topic / thesis to investigate. Copy the user's subject faithfully;
do not paraphrase it into something narrower unless the user asked for that scope.
focus: Optional extra write-time requirements from the user (length, tone, must-cover
points, language preferences). Omit when the user gave none.
retrieve: Only set on a re-call after the user answered the insufficient-materials
question. True = search more materials (combined with the conversation pool);
False = write directly with existing materials only. Omit on the first call.
retrieve: Ignored. Kept for older callers; the writing-mode pipeline never
searches. False / omitted / True all write from conversation materials only.
tool_call_id: Injected tool call id.
"""
# 与 present_file_tool 相同的解析顺序:runtime.context → runtime.config。
@@ -473,63 +420,22 @@ async def deep_research_report_tool(
dropped_count,
)
if retrieve is True and not _retrieval_confirmed_by_user(conversation_messages):
# P1:retrieve=true 只认「已询问且用户已答复」,提示词约束之外在工具层强制。
logger.warning(
"[deep_research_report] thread=%s rejected retrieve=true without user confirmation",
thread_id,
)
return Command(
update={
"messages": [
ToolMessage(
content=_retrieve_without_confirmation_message(),
tool_call_id=tool_call_id,
name="deep_research_report",
)
]
}
)
if harvested_count < _MIN_HARVESTED_MATERIALS and retrieve is None:
# 资料不足且未征询过用户:不静默自检索,交给模型发起用户协助。
if harvested_count < _MIN_HARVESTED_MATERIALS:
logger.info(
"[deep_research_report] thread=%s insufficient materials (%d < %d); asking user",
"[deep_research_report] thread=%s thin materials (%d < %d); writing immediately",
thread_id,
harvested_count,
_MIN_HARVESTED_MATERIALS,
)
return Command(
update={
"messages": [
ToolMessage(
content=_insufficient_materials_message(
harvested_count,
_MIN_HARVESTED_MATERIALS,
topic=query,
total_count=len(harvested),
dropped_count=dropped_count,
),
tool_call_id=tool_call_id,
name="deep_research_report",
)
]
}
)
if harvested_count >= _MIN_HARVESTED_MATERIALS or retrieve is False:
if retrieve is True:
logger.info(
"[deep_research_report] thread=%s ignoring retrieve=true; "
"writing-mode pipeline writes from conversation materials only",
thread_id,
)
materials_provider: Any = StaticHarvestMaterialProvider(materials)
material_source = "对话资料"
else: # retrieve is True 且已核验用户同意补充检索
materials_provider = HarvestPlusSearchMaterialProvider(
materials,
DeerFlowMaterialProvider(
user_id=resolve_path_user_id(str(thread_id)),
session_id=f"chat-{thread_id}",
runtime_thread_id=str(thread_id),
),
)
material_source = "对话资料+补充检索"
user_id = resolve_path_user_id(str(thread_id))
session_id = f"chat-{thread_id}"
@@ -635,7 +541,9 @@ async def deep_research_report_tool(
__all__ = [
"HarvestPlusSearchMaterialProvider",
"REPORT_VIRTUAL_PATH",
"StreamWriterEventSink",
"build_writing_research_config",
"deep_research_report_tool",
"is_writing_mode_report_path",
]