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:
1 parent
9afb9750e5
commit
49c01cb72b
20 files changed
+1449
-381
No files matched your search
@@ -19,6 +19,7 @@ import {
|
|||||||
mergeArtifactPaths,
|
mergeArtifactPaths,
|
||||||
} from "@/core/artifacts/listing";
|
} from "@/core/artifacts/listing";
|
||||||
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
|
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
|
||||||
|
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||||
@@ -390,9 +391,12 @@ const ChatBox: React.FC<{
|
|||||||
((artifactDirectoryModeEnabled && threadChanged) ||
|
((artifactDirectoryModeEnabled && threadChanged) ||
|
||||||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
|
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
|
||||||
) {
|
) {
|
||||||
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
const newest = nextArtifacts[nextArtifacts.length - 1]!;
|
||||||
setArtifactsOpen(true);
|
if (shouldAutoOpenWritingArtifact(newest, thread.messages)) {
|
||||||
setSideTab("document");
|
selectArtifact(newest);
|
||||||
|
setArtifactsOpen(true);
|
||||||
|
setSideTab("document");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
||||||
@@ -424,6 +428,7 @@ const ChatBox: React.FC<{
|
|||||||
setArtifacts,
|
setArtifacts,
|
||||||
availableArtifacts,
|
availableArtifacts,
|
||||||
disableArtifacts,
|
disableArtifacts,
|
||||||
|
thread.messages,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Live sandbox: open as soon as any in-flight write_file has a path or
|
// Live sandbox: open as soon as any in-flight write_file has a path or
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import {
|
|||||||
type RuntimeDisplaySource,
|
type RuntimeDisplaySource,
|
||||||
} from "@/core/skill-display/runtime";
|
} from "@/core/skill-display/runtime";
|
||||||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||||||
|
import { isInterceptedWritingReportWrite } from "@/core/threads/writing-mode";
|
||||||
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -866,6 +867,22 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
|||||||
if (tool_call.name === "task") {
|
if (tool_call.name === "task") {
|
||||||
continue;
|
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 = {
|
const step: CoTToolCallStep = {
|
||||||
id: tool_call.id,
|
id: tool_call.id,
|
||||||
messageId: message.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/);
|
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", () => {
|
test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => {
|
||||||
const preview = latestInFlightWriteFilePreview([
|
const preview = latestInFlightWriteFilePreview([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isInterceptedWritingReportWrite } from "../threads/writing-mode";
|
||||||
import { isImageFile } from "../utils/files";
|
import { isImageFile } from "../utils/files";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -123,6 +124,11 @@ export function latestInFlightWriteFilePreview(
|
|||||||
if (!sourcePath) {
|
if (!sourcePath) {
|
||||||
continue;
|
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:
|
// 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
|
// switching the sandbox onto it freezes the main thread, then the rest
|
||||||
// of the turn appears to dump at once. Keep showing markdown instead.
|
// 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", () => {
|
test("tool messages still attach to an open processing group", () => {
|
||||||
const types = groupTypes([
|
const types = groupTypes([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -166,14 +166,19 @@ export function groupMessages<T>(
|
|||||||
messages: [message],
|
messages: [message],
|
||||||
});
|
});
|
||||||
} else if (hasOnlyDeepResearchProgress(message)) {
|
} else if (hasOnlyDeepResearchProgress(message)) {
|
||||||
// Each deep-research writing bar is its own group. Merging consecutive
|
// 写作模式把进度条插在同一次 `deep_research_report` 之后,必须并进
|
||||||
// processing messages would pin a new report's in-progress steps onto
|
// 当前 processing 组,总结才能排在整条步骤条下面。新一轮写作前面
|
||||||
// the parent bar, which sits above the original file card.
|
// 会有 human/assistant 隔开,不会误并到上一轮的条上。
|
||||||
groups.push({
|
const lastGroup = groups[groups.length - 1];
|
||||||
id: message.id,
|
if (lastGroup?.type === "assistant:processing" && groupHasDeepResearchReport(lastGroup)) {
|
||||||
type: "assistant:processing",
|
lastGroup.messages.push(message);
|
||||||
messages: [message],
|
} else {
|
||||||
});
|
groups.push({
|
||||||
|
id: message.id,
|
||||||
|
type: "assistant:processing",
|
||||||
|
messages: [message],
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (hasSubagent(message)) {
|
} else if (hasSubagent(message)) {
|
||||||
groups.push({
|
groups.push({
|
||||||
id: message.id,
|
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) {
|
export function isClarificationToolMessage(message: Message) {
|
||||||
return message.type === "tool" && message.name === "ask_clarification";
|
return message.type === "tool" && message.name === "ask_clarification";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
buildResearchFeedMessages,
|
buildResearchFeedMessages,
|
||||||
buildResearchProgressToolCalls,
|
buildResearchProgressToolCalls,
|
||||||
buildResearchReportVirtualUrl,
|
buildResearchReportVirtualUrl,
|
||||||
|
composeResearchDisplayMessages,
|
||||||
clearResearchReportStream,
|
clearResearchReportStream,
|
||||||
finalizeResearchReportStream,
|
finalizeResearchReportStream,
|
||||||
ingestResearchReportEvent,
|
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).length, 3);
|
||||||
assert.equal(buildResearchProgressToolCalls(writing)[2].args.status, "active");
|
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 failed = streamOf({ jobId: "j8", phase: "writing", status: "error", error: "模型超时" });
|
||||||
const failedCalls = buildResearchProgressToolCalls(failed);
|
const failedCalls = buildResearchProgressToolCalls(failed);
|
||||||
assert.match(failedCalls.at(-1)?.args.label ?? "", /已中断/);
|
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", () => {
|
test("feed messages attach the write_file stub only once report text exists", () => {
|
||||||
const noText = streamOf({ jobId: "j9", phase: "collecting" });
|
const noText = streamOf({ jobId: "j9", phase: "collecting" });
|
||||||
assert.equal(buildResearchFeedMessages(noText).length, 1);
|
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 withText = streamOf({ jobId: "j10", phase: "writing", reportText: "# 报告正文" });
|
||||||
const feed = buildResearchFeedMessages(withText);
|
const feed = buildResearchFeedMessages(withText);
|
||||||
assert.equal(feed.length, 2);
|
assert.equal(feed.length, 2);
|
||||||
const stub = feed[1].tool_calls[0];
|
const stub = feed[1].tool_calls?.[0];
|
||||||
assert.equal(stub.name, "write_file");
|
assert.equal(stub?.name, "write_file");
|
||||||
assert.equal(stub.args.path, "/mnt/user-data/outputs/report.md");
|
assert.equal(stub?.args.path, "/mnt/user-data/outputs/report.md");
|
||||||
assert.equal(stub.args.content, "# 报告正文");
|
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)", () => {
|
test("virtual URL matches the message-list ArtifactWriteToolCall URL (one suppression key)", () => {
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ function getStore(threadId: string): StoreEntry {
|
|||||||
* 通知若逐帧发出,会让聊天页整棵树(长消息列表 + 沙箱 Markdown 预览)按
|
* 通知若逐帧发出,会让聊天页整棵树(长消息列表 + 沙箱 Markdown 预览)按
|
||||||
* token 频率重渲染,页面直接卡死。工作台用 requestAnimationFrame 批处理
|
* token 频率重渲染,页面直接卡死。工作台用 requestAnimationFrame 批处理
|
||||||
* (DeepResearchWorkbench),聊天这里等效地按固定间隔合并——每秒最多
|
* (DeepResearchWorkbench),聊天这里等效地按固定间隔合并——每秒最多
|
||||||
* 10 次通知,流式观感不受影响。终态帧(tool_finished)与
|
* 10 次通知,流式观感不受影响。终态帧(tool_finished /
|
||||||
* finalize/clear 立即 flush,结束态不滞后。
|
* report_completed)与 finalize/clear 立即 flush,结束态不滞后。
|
||||||
*/
|
*/
|
||||||
const EMIT_COALESCE_MS = 100;
|
const EMIT_COALESCE_MS = 100;
|
||||||
|
|
||||||
@@ -193,7 +193,8 @@ export function ingestResearchReportEvent(threadId: string, frame: ResearchRepor
|
|||||||
case "report_completed":
|
case "report_completed":
|
||||||
next = {
|
next = {
|
||||||
...base,
|
...base,
|
||||||
phase: "writing",
|
// 文件已落盘:步骤条应立刻标完成,不必等随后的 tool_finished。
|
||||||
|
phase: "done",
|
||||||
sourceCount: typeof payload.sourceCount === "number" && payload.sourceCount > 0
|
sourceCount: typeof payload.sourceCount === "number" && payload.sourceCount > 0
|
||||||
? payload.sourceCount
|
? payload.sourceCount
|
||||||
: base.sourceCount,
|
: base.sourceCount,
|
||||||
@@ -223,7 +224,7 @@ export function ingestResearchReportEvent(threadId: string, frame: ResearchRepor
|
|||||||
if (next !== previous) {
|
if (next !== previous) {
|
||||||
store.state = next;
|
store.state = next;
|
||||||
// 状态同步更新(peek/快照立即可见);通知按 token 帧频率合并,终态立即。
|
// 状态同步更新(peek/快照立即可见);通知按 token 帧频率合并,终态立即。
|
||||||
if (frame.type === "tool_finished") {
|
if (frame.type === "tool_finished" || frame.type === "report_completed") {
|
||||||
emitNow(store);
|
emitNow(store);
|
||||||
} else {
|
} else {
|
||||||
emitCoalesced(store);
|
emitCoalesced(store);
|
||||||
@@ -280,7 +281,11 @@ export type ResearchProgressToolCall = {
|
|||||||
export function buildResearchProgressToolCalls(state: ResearchReportStreamState): ResearchProgressToolCall[] {
|
export function buildResearchProgressToolCalls(state: ResearchReportStreamState): ResearchProgressToolCall[] {
|
||||||
const rank = phaseRank(state.phase);
|
const rank = phaseRank(state.phase);
|
||||||
const finished = state.status !== "active";
|
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 = [
|
const stages = [
|
||||||
{
|
{
|
||||||
id: "plan",
|
id: "plan",
|
||||||
@@ -304,9 +309,11 @@ export function buildResearchProgressToolCalls(state: ResearchReportStreamState)
|
|||||||
id: "write",
|
id: "write",
|
||||||
label: "撰写研究报告",
|
label: "撰写研究报告",
|
||||||
boundary: PHASE_ORDER.length,
|
boundary: PHASE_ORDER.length,
|
||||||
detail: state.reportText
|
detail: succeeded
|
||||||
? "正在按「通用分析结构」逐字撰写,右侧沙箱同步预览"
|
? "已按「通用分析结构」写完并保存为 report.md"
|
||||||
: "正在整理资料并开始撰写报告",
|
: state.reportText
|
||||||
|
? "正在按「通用分析结构」逐字撰写,右侧沙箱同步预览"
|
||||||
|
: "正在整理资料并开始撰写报告",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const activeIndex = stages.findIndex((stage) => effectiveRank < stage.boundary);
|
const activeIndex = stages.findIndex((stage) => effectiveRank < stage.boundary);
|
||||||
@@ -330,19 +337,25 @@ export function buildResearchProgressToolCalls(state: ResearchReportStreamState)
|
|||||||
|
|
||||||
export type ResearchFeedMessage = {
|
export type ResearchFeedMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
type: "ai";
|
type: "ai" | "tool";
|
||||||
content: string;
|
content: string;
|
||||||
tool_calls: {
|
name?: string;
|
||||||
|
tool_call_id?: string;
|
||||||
|
tool_calls?: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isResearchWriteFinished(state: ResearchReportStreamState): boolean {
|
||||||
|
return state.status === "success" || state.phase === "done";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 组装聊天页要注入的两条虚拟 ai 消息:进度步骤条 + report.md 的 write_file
|
* 组装聊天页要注入的虚拟消息:进度步骤条 + report.md 的 write_file stub
|
||||||
* stub(沙箱 write-file 虚拟 URL 预览的数据源)。仅在报告尚无正文或仍在
|
* (沙箱 write-file 虚拟 URL 预览的数据源)。写完后补一条 tool result,
|
||||||
* 流式时输出 stub —— 完成后由真实 artifacts 接管。
|
* 让沙箱的「正在生成」立刻收起,不必等真实 artifacts 交接。
|
||||||
*/
|
*/
|
||||||
export function buildResearchFeedMessages(state: ResearchReportStreamState): ResearchFeedMessage[] {
|
export function buildResearchFeedMessages(state: ResearchReportStreamState): ResearchFeedMessage[] {
|
||||||
const messages: ResearchFeedMessage[] = [
|
const messages: ResearchFeedMessage[] = [
|
||||||
@@ -353,23 +366,127 @@ export function buildResearchFeedMessages(state: ResearchReportStreamState): Res
|
|||||||
tool_calls: buildResearchProgressToolCalls(state),
|
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: writeCallId,
|
||||||
|
name: "write_file",
|
||||||
|
args: { path: RESEARCH_REPORT_OUTPUT_PATH, content: state.reportText },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (isResearchWriteFinished(state)) {
|
||||||
messages.push({
|
messages.push({
|
||||||
id: `research-report-${state.jobId}`,
|
id: `research-report-${state.jobId}-write-result`,
|
||||||
type: "ai",
|
type: "tool",
|
||||||
content: "",
|
name: "write_file",
|
||||||
tool_calls: [
|
tool_call_id: writeCallId,
|
||||||
{
|
content: "ok",
|
||||||
id: `research-report-${state.jobId}-write`,
|
|
||||||
name: "write_file",
|
|
||||||
args: { path: RESEARCH_REPORT_OUTPUT_PATH, content: state.reportText },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return messages;
|
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 消息读流式内容)。 */
|
/** write-file 虚拟 URL(ArtifactFileDetail 用它从 stub 消息读流式内容)。 */
|
||||||
export function buildResearchReportVirtualUrl(state: ResearchReportStreamState): string {
|
export function buildResearchReportVirtualUrl(state: ResearchReportStreamState): string {
|
||||||
return new URL(
|
return new URL(
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
/**
|
/**
|
||||||
* 聊天页「写作模式」深度研究进度注入。
|
* 聊天页「写作模式」深度研究进度注入。
|
||||||
*
|
*
|
||||||
* 把 `useResearchReportStream` 的流式状态转成 displayThread 尾部追加的
|
* 把 `useResearchReportStream` 的流式状态插到 `deep_research_report`
|
||||||
* 虚拟消息(进度步骤条 + report.md 的 write_file stub):
|
* 工具结果之后、模型总结之前(进度步骤条 + report.md 的 write_file stub):
|
||||||
*
|
*
|
||||||
* - 进行中 / 中断:始终注入(中断态展示「已中断」,下一轮 jobId 变化自然淘汰);
|
* - 进行中 / 中断:始终注入(中断态展示「已中断」,下一轮 jobId 变化自然淘汰);
|
||||||
* - 成功:注入到真实 report.md 产物落地为止(artifacts 出现该路径即撤出),
|
* - 成功:注入到真实 report.md 产物落地为止(artifacts 出现该路径即撤出),
|
||||||
@@ -26,13 +26,16 @@ import {
|
|||||||
artifactPreviewKey,
|
artifactPreviewKey,
|
||||||
useArtifactsOptional,
|
useArtifactsOptional,
|
||||||
} from "@/components/workspace/artifacts/context";
|
} from "@/components/workspace/artifacts/context";
|
||||||
|
import { resolveWriteFilePath } from "@/core/artifacts/write-file-in-flight";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildResearchFeedMessages,
|
buildResearchFeedMessages,
|
||||||
buildResearchReportVirtualUrl,
|
buildResearchReportVirtualUrl,
|
||||||
|
composeResearchDisplayMessages,
|
||||||
RESEARCH_REPORT_OUTPUT_PATH,
|
RESEARCH_REPORT_OUTPUT_PATH,
|
||||||
useResearchReportStream,
|
useResearchReportStream,
|
||||||
} from "./use-research-report-stream";
|
} from "./use-research-report-stream";
|
||||||
|
import { isInterceptedWritingReportWrite } from "./writing-mode";
|
||||||
|
|
||||||
type FeedThreadLike = {
|
type FeedThreadLike = {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
@@ -55,10 +58,15 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
|
|||||||
[thread.values?.artifacts],
|
[thread.values?.artifacts],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 报告首帧正文到达且仍在写入 → 主动展开右侧沙箱并选中 write-file 虚拟
|
// 正文首 token 到达且仍在写入 → 主动展开右侧沙箱。思考阶段不打开,
|
||||||
// 预览,报告在沙箱里边写边流式渲染。
|
// 避免「写入文件 report.md」出现在模型还在想的时候。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!research || research.status !== "active" || !research.reportText.trim()) {
|
if (
|
||||||
|
!research ||
|
||||||
|
research.status !== "active" ||
|
||||||
|
research.phase !== "writing" ||
|
||||||
|
!research.reportText.trim()
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (autoOpenedJobRef.current === research.jobId) return;
|
if (autoOpenedJobRef.current === research.jobId) return;
|
||||||
@@ -77,7 +85,14 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
|
|||||||
if (!artifacts) return;
|
if (!artifacts) return;
|
||||||
handedOverJobRef.current = research.jobId;
|
handedOverJobRef.current = research.jobId;
|
||||||
const current = artifacts.selectedArtifact;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
artifacts.select(RESEARCH_REPORT_OUTPUT_PATH);
|
artifacts.select(RESEARCH_REPORT_OUTPUT_PATH);
|
||||||
@@ -91,11 +106,11 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
|
|||||||
}, [hasRealArtifact, research]);
|
}, [hasRealArtifact, research]);
|
||||||
|
|
||||||
const displayThread = useMemo(() => {
|
const displayThread = useMemo(() => {
|
||||||
if (feedMessages.length === 0) return thread;
|
const messages = composeResearchDisplayMessages(
|
||||||
const messages = [
|
thread.messages,
|
||||||
...thread.messages,
|
feedMessages as unknown as Message[],
|
||||||
...(feedMessages as unknown as Message[]),
|
);
|
||||||
];
|
if (messages === thread.messages) return thread;
|
||||||
return {
|
return {
|
||||||
...thread,
|
...thread,
|
||||||
values: { ...thread.values, messages },
|
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));
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStrea
|
|||||||
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
||||||
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
|
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -612,16 +613,13 @@ export default function AgentChatPage({
|
|||||||
} as typeof displayThread;
|
} as typeof displayThread;
|
||||||
}, [displayThread, showReportImportCard, taskId, threadId]);
|
}, [displayThread, showReportImportCard, taskId, threadId]);
|
||||||
|
|
||||||
// 写作模式续写轮:已有 md 产物(report.md 已生成)时回传 writing_artifact_path,
|
// 写作模式续写轮:仅当已有写作模式产物 report.md 时回传 writing_artifact_path,
|
||||||
// 后端据此走「直接编辑画布」分支而不是再次挂载深度研究写报告管线(与
|
// 后端据此走「直接编辑画布」分支而不是再次挂载深度研究写报告管线。其它 leftover
|
||||||
// ChatPage 的回传逻辑对齐)。
|
// .md 不得跳过管线(与 ChatPage 对齐)。
|
||||||
const writingArtifactPath = useMemo(() => {
|
const writingArtifactPath = useMemo(
|
||||||
const isMarkdown = (path: string | null | undefined) =>
|
() => resolveWritingModeReportPath(thread.values.artifacts),
|
||||||
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
|
[thread.values.artifacts],
|
||||||
return [...(thread.values.artifacts ?? [])]
|
);
|
||||||
.reverse()
|
|
||||||
.find((path) => isMarkdown(path));
|
|
||||||
}, [thread.values.artifacts]);
|
|
||||||
|
|
||||||
// ── 「模板数据转换器」对话页专属调试入口(仅此 agent)──────────────────────────
|
// ── 「模板数据转换器」对话页专属调试入口(仅此 agent)──────────────────────────
|
||||||
// 选一个页面模板,问答跑完后点「渲染预览」:取最近一条 AI 回复里的 JSON 填进模板,
|
// 选一个页面模板,问答跑完后点「渲染预览」:取最近一条 AI 回复里的 JSON 填进模板,
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import {
|
|||||||
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
|
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
|
||||||
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
||||||
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
||||||
|
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||||
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
||||||
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
|
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
|
||||||
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
|
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
|
||||||
@@ -354,16 +355,11 @@ export default function ChatPage() {
|
|||||||
researchDisplayThread,
|
researchDisplayThread,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const writingArtifactPath = useMemo(() => {
|
const writingArtifactPath = useMemo(
|
||||||
const isMarkdown = (path: string | null | undefined) =>
|
() =>
|
||||||
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
|
resolveWritingModeReportPath(thread.values.artifacts, selectedArtifact),
|
||||||
if (isMarkdown(selectedArtifact)) {
|
[selectedArtifact, thread.values.artifacts],
|
||||||
return selectedArtifact ?? undefined;
|
);
|
||||||
}
|
|
||||||
return [...(thread.values.artifacts ?? [])]
|
|
||||||
.reverse()
|
|
||||||
.find((path) => isMarkdown(path));
|
|
||||||
}, [selectedArtifact, thread.values.artifacts]);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(message: PromptInputMessage) => {
|
(message: PromptInputMessage) => {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
import { usePluginManagementSettings } from "@/core/system-settings/hooks";
|
import { usePluginManagementSettings } from "@/core/system-settings/hooks";
|
||||||
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
|
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
|
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -285,16 +286,11 @@ export default function IframeChatPage() {
|
|||||||
threadMetadataAgentId,
|
threadMetadataAgentId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const writingArtifactPath = useMemo(() => {
|
const writingArtifactPath = useMemo(
|
||||||
const isMarkdown = (path: string | null | undefined) =>
|
() =>
|
||||||
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
|
resolveWritingModeReportPath(thread.values.artifacts, selectedArtifact),
|
||||||
if (isMarkdown(selectedArtifact)) {
|
[selectedArtifact, thread.values.artifacts],
|
||||||
return selectedArtifact ?? undefined;
|
);
|
||||||
}
|
|
||||||
return [...(thread.values.artifacts ?? [])]
|
|
||||||
.reverse()
|
|
||||||
.find((path) => isMarkdown(path));
|
|
||||||
}, [selectedArtifact, thread.values.artifacts]);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(
|
(
|
||||||
|
|||||||
+68
-20
@@ -43,6 +43,41 @@ if TYPE_CHECKING:
|
|||||||
logger = logging.getLogger(__name__)
|
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 ──────────────────────────────────────────────────────
|
# ── message conversion ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -347,26 +382,39 @@ class DeerFlowCompletionBackend:
|
|||||||
text_parts: list[str] = []
|
text_parts: list[str] = []
|
||||||
usage: dict[str, Any] = {}
|
usage: dict[str, Any] = {}
|
||||||
inline_think_filter = InlineThinkTagFilter()
|
inline_think_filter = InlineThinkTagFilter()
|
||||||
async for chunk in model.astream(to_langchain_messages(messages), **kwargs):
|
# Isolate from the parent LangGraph run: inherited callbacks would
|
||||||
if self._cancel is not None:
|
# replay every report token into the chat ``messages`` stream, so the
|
||||||
self._cancel.raise_if_cancelled()
|
# left-hand bubble and the sandbox would write the same prose at once.
|
||||||
reasoning = _reasoning_delta(chunk)
|
# Custom ``report_delta`` events remain the only live preview channel.
|
||||||
if reasoning and on_reasoning is not None:
|
isolated_config = _parent_isolated_run_config(f"deep_research:{operation}")
|
||||||
await on_reasoning(reasoning)
|
reset_parent_config = _clear_inherited_runnable_config(isolated_config)
|
||||||
# A few OpenAI-compatible models put their reasoning in the
|
try:
|
||||||
# ordinary content channel instead of ``reasoning_content``.
|
async for chunk in model.astream(
|
||||||
# Split it from Markdown even when tags span tokens: callers may
|
to_langchain_messages(messages),
|
||||||
# display a live thought trace, but it never joins ``text``.
|
config=isolated_config,
|
||||||
delta, inline_reasoning = inline_think_filter.push_parts(_content_to_text(chunk))
|
**kwargs,
|
||||||
if inline_reasoning and on_reasoning is not None:
|
):
|
||||||
await on_reasoning(inline_reasoning)
|
if self._cancel is not None:
|
||||||
if delta:
|
self._cancel.raise_if_cancelled()
|
||||||
text_parts.append(delta)
|
reasoning = _reasoning_delta(chunk)
|
||||||
if on_delta is not None:
|
if reasoning and on_reasoning is not None:
|
||||||
await on_delta(delta)
|
await on_reasoning(reasoning)
|
||||||
chunk_usage = _extract_usage(chunk)
|
# A few OpenAI-compatible models put their reasoning in the
|
||||||
if any(chunk_usage.values()):
|
# ordinary content channel instead of ``reasoning_content``.
|
||||||
usage = chunk_usage
|
# 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:
|
||||||
|
await on_delta(delta)
|
||||||
|
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()
|
tail, inline_reasoning_tail = inline_think_filter.finish_parts()
|
||||||
if inline_reasoning_tail and on_reasoning is not None:
|
if inline_reasoning_tail and on_reasoning is not None:
|
||||||
|
|||||||
+78
-22
@@ -198,35 +198,63 @@ class BasicRunner:
|
|||||||
cancellation.raise_if_cancelled()
|
cancellation.raise_if_cancelled()
|
||||||
|
|
||||||
if not all_materials:
|
if not all_materials:
|
||||||
|
knowledge_fill = _allows_knowledge_fill(request.config.report_instruction)
|
||||||
await events.emit(
|
await events.emit(
|
||||||
"warning",
|
"warning",
|
||||||
phase="curating",
|
phase="curating",
|
||||||
payload={"code": "no_materials", "message": "未收集到任何研究材料", "recoverable": False},
|
payload={
|
||||||
|
"code": "no_materials",
|
||||||
|
"message": (
|
||||||
|
"未收集到外部研究材料,改用领域知识撰写"
|
||||||
|
if knowledge_fill
|
||||||
|
else "未收集到任何研究材料"
|
||||||
|
),
|
||||||
|
"recoverable": knowledge_fill,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
report_md = f"# {request.query}\n\n未能收集到与研究课题「{request.query}」相关的材料,无法生成报告。"
|
if not knowledge_fill:
|
||||||
await adapters.artifacts.write_text("report.md", report_md)
|
report_md = (
|
||||||
await events.emit("report_completed", phase="writing", payload={"reportVersion": 1, "sourceCount": 0})
|
f"# {request.query}\n\n"
|
||||||
return DeepResearchResult(report_markdown=report_md, source_ids=[], plan=plan)
|
f"未能收集到与研究课题「{request.query}」相关的材料,无法生成报告。"
|
||||||
|
)
|
||||||
|
await adapters.artifacts.write_text("report.md", report_md)
|
||||||
|
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
|
||||||
|
|
||||||
from deerflow.agents.deep_research.adapters.material import dedupe_materials
|
deduped = dedupe_materials(all_materials)
|
||||||
|
context_comp.set_materials(deduped)
|
||||||
|
|
||||||
deduped = dedupe_materials(all_materials)
|
selected_ids = [m.id for m in deduped]
|
||||||
context_comp.set_materials(deduped)
|
await events.emit(
|
||||||
|
"sources_curated",
|
||||||
|
phase="curating",
|
||||||
|
payload={"selectedIds": selected_ids, "removedIds": [], "reason": "deduped"},
|
||||||
|
)
|
||||||
|
|
||||||
selected_ids = [m.id for m in deduped]
|
# ── 4. compress context ────────────────────────────────────────
|
||||||
await events.emit(
|
await events.emit(
|
||||||
"sources_curated",
|
"phase_changed",
|
||||||
phase="curating",
|
phase="compressing",
|
||||||
payload={"selectedIds": selected_ids, "removedIds": [], "reason": "deduped"},
|
payload={"from": "curating", "to": "compressing"},
|
||||||
)
|
)
|
||||||
|
if cancellation:
|
||||||
|
cancellation.raise_if_cancelled()
|
||||||
|
|
||||||
# ── 4. compress context ────────────────────────────────────────
|
context_list = await context_comp.async_get_context(
|
||||||
await events.emit("phase_changed", phase="compressing", payload={"from": "curating", "to": "compressing"})
|
request.query,
|
||||||
if cancellation:
|
max_results=min(len(deduped), config.max_search_results_per_query * 2),
|
||||||
cancellation.raise_if_cancelled()
|
)
|
||||||
|
context_text = _format_context(context_list, deduped)
|
||||||
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 ────────────────────────────────
|
# ── 5. writing: generate report ────────────────────────────────
|
||||||
await events.emit("phase_changed", phase="writing", payload={"from": "compressing", "to": "writing"})
|
await events.emit("phase_changed", phase="writing", payload={"from": "compressing", "to": "writing"})
|
||||||
@@ -403,6 +431,13 @@ class BasicRunner:
|
|||||||
)
|
)
|
||||||
outline_block = _report_outline_instruction(outline)
|
outline_block = _report_outline_instruction(outline)
|
||||||
requirements_block = _report_requirements_instruction(request.config.report_instruction)
|
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":
|
if request.config.structure_mode == "fixed":
|
||||||
# Recency bias: keep the skeleton immediately before "请撰写报告".
|
# Recency bias: keep the skeleton immediately before "请撰写报告".
|
||||||
context_block = (
|
context_block = (
|
||||||
@@ -427,6 +462,8 @@ class BasicRunner:
|
|||||||
if filled_preamble:
|
if filled_preamble:
|
||||||
await emitter.add(filled_preamble + "\n\n")
|
await emitter.add(filled_preamble + "\n\n")
|
||||||
system = _SYSTEM
|
system = _SYSTEM
|
||||||
|
if system_extra:
|
||||||
|
system += system_extra
|
||||||
if request.config.structure_mode == "fixed" and (outline or "").strip():
|
if request.config.structure_mode == "fixed" and (outline or "").strip():
|
||||||
system += "撰写时必须原样沿用用户大纲中的章节标题与层级,不得改写成通用报告套式。"
|
system += "撰写时必须原样沿用用户大纲中的章节标题与层级,不得改写成通用报告套式。"
|
||||||
result = await llm.stream_complete(
|
result = await llm.stream_complete(
|
||||||
@@ -624,12 +661,31 @@ def ensure_markdown_standalone_lines(text: str) -> str:
|
|||||||
return "\n".join(output)
|
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:
|
def _report_requirements_instruction(report_instruction: str | None) -> str:
|
||||||
"""Render confirmed generation requirements as bounded, untrusted content."""
|
"""Render confirmed generation requirements as bounded, untrusted content."""
|
||||||
if not report_instruction:
|
if not report_instruction:
|
||||||
return "User generation requirements: none provided."
|
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 (
|
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>"
|
f"<user-report-requirements>\n{report_instruction}\n</user-report-requirements>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+79
-61
@@ -1,5 +1,5 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from langchain.agents import create_agent
|
from langchain.agents import create_agent
|
||||||
from langchain.agents.middleware import AgentMiddleware
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
@@ -38,45 +38,45 @@ logger = logging.getLogger(__name__)
|
|||||||
# 写作配置 Q&A 现在「先生成大纲并与用户多轮确认 → 再提交配置」,需要更多来回,故上限放宽。
|
# 写作配置 Q&A 现在「先生成大纲并与用户多轮确认 → 再提交配置」,需要更多来回,故上限放宽。
|
||||||
# 仍是兜底安全阀:到顶才用 tool_choice 强制提交,正常流程由用户确认大纲后自然提交。
|
# 仍是兜底安全阀:到顶才用 tool_choice 强制提交,正常流程由用户确认大纲后自然提交。
|
||||||
WRITING_SETUP_MAX_ROUNDS = 8
|
WRITING_SETUP_MAX_ROUNDS = 8
|
||||||
REPORT_STRUCTURE_SETUP_MAX_ROUNDS = 8
|
REPORT_STRUCTURE_SETUP_MAX_ROUNDS = 8
|
||||||
|
|
||||||
_ES_QUERY_ROUTING_OPEN_TAG = '<skill_routing skill="es_query">'
|
_ES_QUERY_ROUTING_OPEN_TAG = '<skill_routing skill="es_query">'
|
||||||
|
|
||||||
|
|
||||||
def _log_es_query_routing_registration(
|
def _log_es_query_routing_registration(
|
||||||
*,
|
*,
|
||||||
system_prompt: str,
|
system_prompt: str,
|
||||||
app_config: AppConfig,
|
app_config: AppConfig,
|
||||||
agent_id: str | None,
|
agent_id: str | None,
|
||||||
available_skills: set[str] | None,
|
available_skills: set[str] | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Log whether the exact configured ES routing block reached this Q&A prompt."""
|
"""Log whether the exact configured ES routing block reached this Q&A prompt."""
|
||||||
routing = app_config.skills.es_query_routing
|
routing = app_config.skills.es_query_routing
|
||||||
configured_prompt = (routing.prompt or "").strip()
|
configured_prompt = (routing.prompt or "").strip()
|
||||||
expected_block = (
|
expected_block = (
|
||||||
f"{_ES_QUERY_ROUTING_OPEN_TAG}\n{configured_prompt}\n</skill_routing>"
|
f"{_ES_QUERY_ROUTING_OPEN_TAG}\n{configured_prompt}\n</skill_routing>"
|
||||||
if configured_prompt
|
if configured_prompt
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
eligible = available_skills is None or "es_query" in available_skills
|
eligible = available_skills is None or "es_query" in available_skills
|
||||||
registered = bool(expected_block) and expected_block in system_prompt
|
registered = bool(expected_block) and expected_block in system_prompt
|
||||||
fingerprint = (
|
fingerprint = (
|
||||||
hashlib.sha256(configured_prompt.encode("utf-8")).hexdigest()[:12]
|
hashlib.sha256(configured_prompt.encode("utf-8")).hexdigest()[:12]
|
||||||
if configured_prompt
|
if configured_prompt
|
||||||
else "-"
|
else "-"
|
||||||
)
|
)
|
||||||
|
|
||||||
log = logger.info if registered or not routing.enabled or not eligible else logger.warning
|
log = logger.info if registered or not routing.enabled or not eligible else logger.warning
|
||||||
log(
|
log(
|
||||||
"ES query routing prompt registration: agent_id=%s enabled=%s eligible=%s "
|
"ES query routing prompt registration: agent_id=%s enabled=%s eligible=%s "
|
||||||
"registered=%s prompt_chars=%d prompt_sha256=%s",
|
"registered=%s prompt_chars=%d prompt_sha256=%s",
|
||||||
agent_id or "default",
|
agent_id or "default",
|
||||||
routing.enabled,
|
routing.enabled,
|
||||||
eligible,
|
eligible,
|
||||||
registered,
|
registered,
|
||||||
len(configured_prompt),
|
len(configured_prompt),
|
||||||
fingerprint,
|
fingerprint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_runtime_config(config: RunnableConfig) -> dict:
|
def _get_runtime_config(config: RunnableConfig) -> dict:
|
||||||
@@ -426,6 +426,18 @@ def _build_middlewares(
|
|||||||
if agent_name == FORCED_RESEARCH_AGENT_ID:
|
if agent_name == FORCED_RESEARCH_AGENT_ID:
|
||||||
middlewares.append(ForcedResearchMiddleware())
|
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
|
# LoopDetectionMiddleware — detect and break repetitive tool call loops
|
||||||
middlewares.append(LoopDetectionMiddleware())
|
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
|
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():
|
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
|
||||||
writing_artifact_path = None
|
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"))
|
agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name"))
|
||||||
requested_excluded_tools = cfg.get("excluded_tools") or None
|
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
|
from deerflow.tools.builtins.notebook_search_tool import notebook_search_tool
|
||||||
|
|
||||||
default_tools = [notebook_search_tool] + default_tools
|
default_tools = [notebook_search_tool] + default_tools
|
||||||
# 写作模式首轮(尚无 md 产物):挂深度研究报告工具,报告由管线生成而非
|
# 写作模式首轮(尚无 report.md):挂深度研究报告工具,报告由管线生成而非
|
||||||
# 模型手工 write_file。续写轮(已有 writing_artifact_path)不挂,保持
|
# 模型手工 write_file。续写轮(已有 report.md)不挂,保持原有「直接编辑
|
||||||
# 原有「直接编辑该文件」行为。不受 tool_groups/excluded_tools 过滤——
|
# 该文件」行为。不受 tool_groups/excluded_tools 过滤——它是写作模式合约
|
||||||
# 它是写作模式合约的核心交付手段。
|
# 的核心交付手段。
|
||||||
if writing_mode and not writing_artifact_path:
|
if writing_mode and not writing_artifact_path:
|
||||||
from deerflow.tools.builtins.deep_research_report_tool import deep_research_report_tool
|
from deerflow.tools.builtins.deep_research_report_tool import deep_research_report_tool
|
||||||
|
|
||||||
@@ -726,8 +744,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
writing_mode=writing_mode,
|
writing_mode=writing_mode,
|
||||||
writing_artifact_path=writing_artifact_path,
|
writing_artifact_path=writing_artifact_path,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
system_prompt = apply_prompt_template(
|
system_prompt = apply_prompt_template(
|
||||||
subagent_enabled=subagent_enabled,
|
subagent_enabled=subagent_enabled,
|
||||||
max_concurrent_subagents=max_concurrent_subagents,
|
max_concurrent_subagents=max_concurrent_subagents,
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
@@ -737,20 +755,20 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
available_skills=prompt_available_skills,
|
available_skills=prompt_available_skills,
|
||||||
app_config=resolved_app_config,
|
app_config=resolved_app_config,
|
||||||
memory_injection_enabled=memory_injection_enabled,
|
memory_injection_enabled=memory_injection_enabled,
|
||||||
rag_mode_enabled=rag_mode_enabled,
|
rag_mode_enabled=rag_mode_enabled,
|
||||||
is_notebook_mode=is_notebook_mode,
|
is_notebook_mode=is_notebook_mode,
|
||||||
notebook_source_ids=notebook_source_ids,
|
notebook_source_ids=notebook_source_ids,
|
||||||
is_scheduled_run=bool(cfg.get("is_scheduled_run", False)),
|
is_scheduled_run=bool(cfg.get("is_scheduled_run", False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
_log_es_query_routing_registration(
|
_log_es_query_routing_registration(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
app_config=resolved_app_config,
|
app_config=resolved_app_config,
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
available_skills=prompt_available_skills,
|
available_skills=prompt_available_skills,
|
||||||
)
|
)
|
||||||
|
|
||||||
return create_agent(
|
return create_agent(
|
||||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
|
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
|
||||||
tools=default_tools,
|
tools=default_tools,
|
||||||
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
|
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
|
||||||
|
|||||||
+54
-57
@@ -486,19 +486,19 @@ task(description="Oracle Cloud analysis", prompt="...", subagent_type="general-p
|
|||||||
</subagent_system>"""
|
</subagent_system>"""
|
||||||
|
|
||||||
|
|
||||||
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """
|
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """
|
||||||
<ordinary_qa_markdown_format>
|
<ordinary_qa_markdown_format>
|
||||||
当普通问答需要输出带章节结构的较长内容时,必须采用以下 Markdown 规范,以便下载为 Word 后得到正确的自动编号和公文标题格式:
|
当普通问答需要输出带章节结构的较长内容时,必须采用以下 Markdown 规范,以便下载为 Word 后得到正确的自动编号和公文标题格式:
|
||||||
- 文档主标题使用 `# 主标题`;主标题只写标题文字,不添加章节编号。
|
- 文档主标题使用 `# 主标题`;主标题只写标题文字,不添加章节编号。
|
||||||
- 一级章节使用 `## 一、标题`,二级章节使用 `### (一) 标题`,三级章节使用 `#### 1. 标题`。二级编号必须使用半角括号 `(一)`。
|
- 一级章节使用 `## 一、标题`,二级章节使用 `### (一) 标题`,三级章节使用 `#### 1. 标题`。二级编号必须使用半角括号 `(一)`。
|
||||||
- 不要在标题中使用 `2.1`、`2.1.1` 等多级阿拉伯数字编号;章节编号直接按上述三种形式书写。
|
- 不要在标题中使用 `2.1`、`2.1.1` 等多级阿拉伯数字编号;章节编号直接按上述三种形式书写。
|
||||||
- 默认禁止使用以 `-`、`*`、`+` 或 `•` 开头的无序圆点列表;并列内容优先改写为完整自然段,确需逐项表达时使用有序列表。只有用户明确要求无序列表时才使用。
|
- 默认禁止使用以 `-`、`*`、`+` 或 `•` 开头的无序圆点列表;并列内容优先改写为完整自然段,确需逐项表达时使用有序列表。只有用户明确要求无序列表时才使用。
|
||||||
- 简短问答不必为了套用格式而强行添加标题;本规范只约束实际使用的标题和列表。
|
- 简短问答不必为了套用格式而强行添加标题;本规范只约束实际使用的标题和列表。
|
||||||
</ordinary_qa_markdown_format>
|
</ordinary_qa_markdown_format>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT_TEMPLATE = """
|
SYSTEM_PROMPT_TEMPLATE = """
|
||||||
<role>
|
<role>
|
||||||
You are {agent_name}, an internal super agent.
|
You are {agent_name}, an internal super agent.
|
||||||
</role>
|
</role>
|
||||||
@@ -624,15 +624,15 @@ You: "Deploying to staging..." [proceed]
|
|||||||
{acp_section}
|
{acp_section}
|
||||||
</working_directory>
|
</working_directory>
|
||||||
|
|
||||||
<response_style>
|
<response_style>
|
||||||
- Clear and Concise: Avoid over-formatting unless requested
|
- Clear and Concise: Avoid over-formatting unless requested
|
||||||
- Natural Tone: Use paragraphs and prose, not bullet points by default
|
- Natural Tone: Use paragraphs and prose, not bullet points by default
|
||||||
- Action-Oriented: Focus on delivering results, not explaining processes
|
- Action-Oriented: Focus on delivering results, not explaining processes
|
||||||
</response_style>
|
</response_style>
|
||||||
|
|
||||||
{ordinary_qa_format_section}
|
{ordinary_qa_format_section}
|
||||||
|
|
||||||
<citations>
|
<citations>
|
||||||
**CRITICAL: Always include citations when using web search results**
|
**CRITICAL: Always include citations when using web search results**
|
||||||
|
|
||||||
- **When to Use**: MANDATORY after web_search, web_fetch, or any external information source
|
- **When to Use**: MANDATORY after web_search, web_fetch, or any external information source
|
||||||
@@ -792,8 +792,7 @@ def apply_agent_only_prompt_template(
|
|||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
if writing_mode and not writing_artifact_path:
|
if writing_mode and not writing_artifact_path:
|
||||||
# 与通用模板一致:对话行为照旧,交付时刻的「写 md」动作交给
|
# 对话行为照旧;只有开始写 md 时才走 deep_research_report。资料不足也直接开写。
|
||||||
# deep_research_report 管线;资料不足时先经 ask_clarification 问用户。
|
|
||||||
writing_mode_section = """<writing_mode>
|
writing_mode_section = """<writing_mode>
|
||||||
Writing mode is enabled for this run (first report turn — no existing artifact).
|
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.
|
- 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).
|
`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)
|
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.
|
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,
|
- If harvested materials are thin, still call `deep_research_report` once and let it write immediately.
|
||||||
re-call the tool with the same `topic`/`focus` plus `retrieve=true` (search more) or `retrieve=false` (existing materials only).
|
Do NOT ask the user whether to search for more, and do NOT call `ask_clarification` for insufficient materials.
|
||||||
NEVER pass `retrieve=true` before the user has answered — the tool rejects it and bounces you back to the question.
|
- 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.
|
- 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.
|
- Only if the tool fails, tell the user and offer to retry.
|
||||||
</writing_mode>
|
</writing_mode>
|
||||||
"""
|
"""
|
||||||
@@ -1407,7 +1406,7 @@ def build_report_structure_setup_prompt(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def apply_prompt_template(
|
def apply_prompt_template(
|
||||||
subagent_enabled: bool = False,
|
subagent_enabled: bool = False,
|
||||||
max_concurrent_subagents: int = 3,
|
max_concurrent_subagents: int = 3,
|
||||||
*,
|
*,
|
||||||
@@ -1419,11 +1418,11 @@ def apply_prompt_template(
|
|||||||
is_bootstrap: bool = False,
|
is_bootstrap: bool = False,
|
||||||
app_config: AppConfig | None = None,
|
app_config: AppConfig | None = None,
|
||||||
memory_injection_enabled: bool = True,
|
memory_injection_enabled: bool = True,
|
||||||
rag_mode_enabled: bool = False,
|
rag_mode_enabled: bool = False,
|
||||||
is_notebook_mode: bool = False,
|
is_notebook_mode: bool = False,
|
||||||
notebook_source_ids: list[int] | None = None,
|
notebook_source_ids: list[int] | None = None,
|
||||||
is_scheduled_run: bool = False,
|
is_scheduled_run: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
storage_agent_id = agent_id if agent_id is not None else agent_name
|
storage_agent_id = agent_id if agent_id is not None else agent_name
|
||||||
|
|
||||||
# Get memory context
|
# Get memory context
|
||||||
@@ -1473,9 +1472,8 @@ def apply_prompt_template(
|
|||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
if writing_mode and not writing_artifact_path:
|
if writing_mode and not writing_artifact_path:
|
||||||
# 首轮:对话行为一切照旧,仅在交付时刻把「写 md」动作交给
|
# 对话行为一切照旧;只有开始写 md 时才把交付交给 deep_research_report。
|
||||||
# deep_research_report 管线(收割对话资料 + 通用分析结构)。资料不足时
|
# 资料不足也直接开写,不再经 ask_clarification 征询用户是否补充检索。
|
||||||
# 工具会返回指令,让模型经 ask_clarification 征询用户是否补充检索。
|
|
||||||
writing_mode_section = """
|
writing_mode_section = """
|
||||||
<writing_mode>
|
<writing_mode>
|
||||||
Writing mode is enabled for this run (first report turn — no existing artifact).
|
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:
|
- 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.
|
- `topic`: the user's subject/thesis, copied faithfully.
|
||||||
- `focus`: the user's extra requirements (length, tone, must-cover points), if any.
|
- `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.
|
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
|
- If harvested materials are thin, still call `deep_research_report` once and let it start the writing pipeline immediately.
|
||||||
whether to search for more materials, and after the user answers, re-call `deep_research_report` with the same `topic`/`focus` plus
|
Do NOT ask the user whether to search for more materials, and do NOT call `ask_clarification` for that reason.
|
||||||
`retrieve=true` (user agreed) or `retrieve=false` (user declined — write with what exists).
|
- Search with your normal conversation tools. `deep_research_report` only writes the report file from materials already in this conversation; it does not search.
|
||||||
NEVER pass `retrieve=true` before the user has answered — the tool rejects it and bounces you back to the question.
|
|
||||||
- Do NOT write the report body yourself with `write_file`, and do NOT call `present_files` for the report — the tool already handles both.
|
- 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.
|
- 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>
|
</writing_mode>
|
||||||
"""
|
"""
|
||||||
@@ -1528,7 +1525,7 @@ Writing mode is enabled for this run.
|
|||||||
|
|
||||||
# Build the offline-network policy block (empty unless DEER_FLOW_OFFLINE_MODE is set).
|
# Build the offline-network policy block (empty unless DEER_FLOW_OFFLINE_MODE is set).
|
||||||
network_policy_section = _build_network_policy_section()
|
network_policy_section = _build_network_policy_section()
|
||||||
rag_mode_section = (
|
rag_mode_section = (
|
||||||
"""
|
"""
|
||||||
<rag_citation_mode>
|
<rag_citation_mode>
|
||||||
RAG/reference display mode is enabled.
|
RAG/reference display mode is enabled.
|
||||||
@@ -1541,15 +1538,15 @@ When a search, knowledge, skill, or MCP tool returns a result list:
|
|||||||
</rag_citation_mode>
|
</rag_citation_mode>
|
||||||
"""
|
"""
|
||||||
if rag_mode_enabled
|
if rag_mode_enabled
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
ordinary_qa_format_section = (
|
ordinary_qa_format_section = (
|
||||||
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION
|
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION
|
||||||
if not is_bootstrap and not writing_mode and not is_notebook_mode and not is_scheduled_run
|
if not is_bootstrap and not writing_mode and not is_notebook_mode and not is_scheduled_run
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Format the prompt with dynamic skills and memory
|
# Format the prompt with dynamic skills and memory
|
||||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
||||||
agent_name=agent_name or storage_agent_id or "CM 助手",
|
agent_name=agent_name or storage_agent_id or "CM 助手",
|
||||||
soul=get_agent_soul(storage_agent_id),
|
soul=get_agent_soul(storage_agent_id),
|
||||||
@@ -1562,10 +1559,10 @@ When a search, knowledge, skill, or MCP tool returns a result list:
|
|||||||
network_policy_section=network_policy_section,
|
network_policy_section=network_policy_section,
|
||||||
subagent_section=subagent_section,
|
subagent_section=subagent_section,
|
||||||
subagent_reminder=subagent_reminder,
|
subagent_reminder=subagent_reminder,
|
||||||
subagent_thinking=subagent_thinking,
|
subagent_thinking=subagent_thinking,
|
||||||
acp_section=acp_and_mounts_section,
|
acp_section=acp_and_mounts_section,
|
||||||
ordinary_qa_format_section=ordinary_qa_format_section,
|
ordinary_qa_format_section=ordinary_qa_format_section,
|
||||||
)
|
)
|
||||||
|
|
||||||
prompt = prompt + rag_mode_section + _current_time_context()
|
prompt = prompt + rag_mode_section + _current_time_context()
|
||||||
|
|
||||||
|
|||||||
+468
@@ -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",
|
||||||
|
]
|
||||||
+48
-140
@@ -7,19 +7,12 @@
|
|||||||
``outputs/`` 目录——前端画布 / 编辑 / 下载 / 续写全部沿用既有 artifacts 通道。
|
``outputs/`` 目录——前端画布 / 编辑 / 下载 / 续写全部沿用既有 artifacts 通道。
|
||||||
除「写 md」这一动作换成管线外,agent 的对话 / 检索 / 澄清行为一切照旧。
|
除「写 md」这一动作换成管线外,agent 的对话 / 检索 / 澄清行为一切照旧。
|
||||||
|
|
||||||
资料来源三分支(收割池先按**当前主题**做词面相关度强过滤——旧课题的检索
|
资料来源(收割池先按**当前主题**做词面相关度强过滤——旧课题的检索
|
||||||
结果既不计入门槛,也不进报告):
|
结果不进报告):
|
||||||
|
|
||||||
- 主题相关池足够(≥ :data:`_MIN_HARVESTED_MATERIALS`)→ 直接用对话资料写;
|
- 一律用对话里已经产生的资料写(哪怕为空——basic runner 有领域知识兜底),
|
||||||
- 不足且用户已答复 → ``retrieve=True`` 自检索(对话资料合并去重)、
|
**不再**因条数不足打断用户,也**不再**在管线内部补检索。
|
||||||
``retrieve=False`` 只用现有资料(哪怕为空——basic runner 有优雅兜底);
|
需要检索时由 lead agent 用对话工具先搜,再调用本工具写文件。
|
||||||
- 不足且未询问 → 返回指令 ToolMessage,让模型用 ``ask_clarification``
|
|
||||||
询问用户是否补充检索,用户答复后带 ``retrieve`` 重调本工具。
|
|
||||||
|
|
||||||
``retrieve=True`` 不只是提示词约束:工具会核验消息史里存在
|
|
||||||
「本工具的资料不足答复 → ask_clarification → 用户回复」完整链
|
|
||||||
(:func:`_retrieval_confirmed_by_user`),模型擅自传 ``retrieve=True``
|
|
||||||
会被拒绝并打回询问流程。
|
|
||||||
|
|
||||||
报告管线继承聊天里选择的模型与思考开关(``context.model_name`` /
|
报告管线继承聊天里选择的模型与思考开关(``context.model_name`` /
|
||||||
``thinking_enabled`` / ``thinking_force_disabled``,经 run config 白名单透
|
``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.deep_research.types import AdapterBundle, DeepResearchRequest, ResearchMaterial
|
||||||
from deerflow.agents.thread_state import ThreadState
|
from deerflow.agents.thread_state import ThreadState
|
||||||
from deerflow.persistence.report_structures.defaults import GENERAL_OUTLINE
|
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
|
from deerflow.runtime.thread_paths import resolve_path_user_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 收割池低于该条数视为「资料不足」,需要先征询用户是否补充检索。
|
# 收割池低于该条数视为「资料偏少」,仅记日志,不再打断用户。
|
||||||
_MIN_HARVESTED_MATERIALS = 3
|
_MIN_HARVESTED_MATERIALS = 3
|
||||||
|
|
||||||
# 引擎事件中值得转发到聊天的子集(model_usage / heartbeat / job_* 只对持久
|
# 引擎事件中值得转发到聊天的子集(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"
|
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:
|
class StreamWriterEventSink:
|
||||||
"""把引擎事件经 LangGraph custom 通道转发到聊天前端。
|
"""把引擎事件经 LangGraph custom 通道转发到聊天前端。
|
||||||
|
|
||||||
@@ -211,6 +214,15 @@ class HarvestPlusSearchMaterialProvider:
|
|||||||
return dedupe_materials(combined)
|
return dedupe_materials(combined)
|
||||||
|
|
||||||
|
|
||||||
|
# 写作模式常在「不用搜索 / 对话资料很少」时开写。工作台默认的「材料不足就
|
||||||
|
# 写明资料不足」会把报告写成元分析;这里强制用领域知识补全正文。
|
||||||
|
_KNOWLEDGE_WRITE_INSTRUCTION = (
|
||||||
|
"对话材料很少或用户要求直接开写时:必须基于模型自身的领域知识写成完整、有实质内容的研究报告"
|
||||||
|
"(含架构说明;用户要求流程图时用 Mermaid)。禁止把「材料不足/未提供文献/现有材料高度浓缩」"
|
||||||
|
"写成报告主线或用其填充多个章节。参考文献可标为基于公开领域知识,不要用「材料未提供」占整节。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_writing_research_config(
|
def build_writing_research_config(
|
||||||
*,
|
*,
|
||||||
focus: str | None = None,
|
focus: str | None = None,
|
||||||
@@ -224,7 +236,10 @@ def build_writing_research_config(
|
|||||||
覆盖 fast/smart/strategic 三个角色,使报告管线与对话用同一个模型和思考
|
覆盖 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(
|
return DeepResearchConfig(
|
||||||
mode="basic",
|
mode="basic",
|
||||||
collection_mode="legacy",
|
collection_mode="legacy",
|
||||||
@@ -235,7 +250,7 @@ def build_writing_research_config(
|
|||||||
fast_model=model_name,
|
fast_model=model_name,
|
||||||
smart_model=model_name,
|
smart_model=model_name,
|
||||||
strategic_model=model_name,
|
strategic_model=model_name,
|
||||||
**({"report_instruction": instruction} if instruction else {}),
|
report_instruction="\n".join(parts),
|
||||||
).clamp()
|
).clamp()
|
||||||
|
|
||||||
|
|
||||||
@@ -325,33 +340,6 @@ def _filter_relevant_materials(
|
|||||||
return relevant, len(materials) - len(relevant)
|
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:
|
def _safe_stream_writer() -> Any:
|
||||||
"""获取当前 run 的 custom 事件写入器;非流式上下文返回 None。"""
|
"""获取当前 run 的 custom 事件写入器;非流式上下文返回 None。"""
|
||||||
try:
|
try:
|
||||||
@@ -362,45 +350,6 @@ def _safe_stream_writer() -> Any:
|
|||||||
return None
|
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)
|
@tool("deep_research_report", parse_docstring=True)
|
||||||
async def deep_research_report_tool(
|
async def deep_research_report_tool(
|
||||||
runtime: ToolRuntime[ContextT, ThreadState],
|
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.
|
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
|
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
|
tool still starts the writing pipeline immediately with whatever relevant
|
||||||
(via ask_clarification); after the user answers, re-call this tool with
|
materials exist — do NOT ask the user whether to search for more, and do NOT
|
||||||
`retrieve` set accordingly (same topic/focus). `retrieve=true` is REJECTED
|
call ask_clarification for that reason. Search in the conversation first with
|
||||||
unless the conversation shows that ask_clarification exchange — never pass it
|
your normal tools; this tool only writes the file.
|
||||||
speculatively.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
topic: The research topic / thesis to investigate. Copy the user's subject faithfully;
|
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.
|
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
|
focus: Optional extra write-time requirements from the user (length, tone, must-cover
|
||||||
points, language preferences). Omit when the user gave none.
|
points, language preferences). Omit when the user gave none.
|
||||||
retrieve: Only set on a re-call after the user answered the insufficient-materials
|
retrieve: Ignored. Kept for older callers; the writing-mode pipeline never
|
||||||
question. True = search more materials (combined with the conversation pool);
|
searches. False / omitted / True all write from conversation materials only.
|
||||||
False = write directly with existing materials only. Omit on the first call.
|
|
||||||
tool_call_id: Injected tool call id.
|
tool_call_id: Injected tool call id.
|
||||||
"""
|
"""
|
||||||
# 与 present_file_tool 相同的解析顺序:runtime.context → runtime.config。
|
# 与 present_file_tool 相同的解析顺序:runtime.context → runtime.config。
|
||||||
@@ -473,63 +420,22 @@ async def deep_research_report_tool(
|
|||||||
dropped_count,
|
dropped_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
if retrieve is True and not _retrieval_confirmed_by_user(conversation_messages):
|
if harvested_count < _MIN_HARVESTED_MATERIALS:
|
||||||
# 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:
|
|
||||||
# 资料不足且未征询过用户:不静默自检索,交给模型发起用户协助。
|
|
||||||
logger.info(
|
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,
|
thread_id,
|
||||||
harvested_count,
|
harvested_count,
|
||||||
_MIN_HARVESTED_MATERIALS,
|
_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:
|
||||||
materials_provider: Any = StaticHarvestMaterialProvider(materials)
|
logger.info(
|
||||||
material_source = "对话资料"
|
"[deep_research_report] thread=%s ignoring retrieve=true; "
|
||||||
else: # retrieve is True 且已核验用户同意补充检索
|
"writing-mode pipeline writes from conversation materials only",
|
||||||
materials_provider = HarvestPlusSearchMaterialProvider(
|
thread_id,
|
||||||
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 = "对话资料+补充检索"
|
materials_provider: Any = StaticHarvestMaterialProvider(materials)
|
||||||
|
material_source = "对话资料"
|
||||||
|
|
||||||
user_id = resolve_path_user_id(str(thread_id))
|
user_id = resolve_path_user_id(str(thread_id))
|
||||||
session_id = f"chat-{thread_id}"
|
session_id = f"chat-{thread_id}"
|
||||||
@@ -635,7 +541,9 @@ async def deep_research_report_tool(
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"HarvestPlusSearchMaterialProvider",
|
"HarvestPlusSearchMaterialProvider",
|
||||||
|
"REPORT_VIRTUAL_PATH",
|
||||||
"StreamWriterEventSink",
|
"StreamWriterEventSink",
|
||||||
"build_writing_research_config",
|
"build_writing_research_config",
|
||||||
"deep_research_report_tool",
|
"deep_research_report_tool",
|
||||||
|
"is_writing_mode_report_path",
|
||||||
]
|
]
|
||||||
Reference in new issue
Block a user