From 4964967295de3182ff6c704b495e7deb0a77a4f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 30 Aug 2026 15:53:43 +0800 Subject: [PATCH] fix: writing mode first-report allows only the pipeline to write the report - Strip write_file/str_replace/bash from the model binding in the first-report run: after_model rewrites only fire after a full model response, so any bound write tool let weak models draft the whole essay into tool args (write_file content or bash cat>/python heredocs) for tens of invisible seconds before interception - Hallucinated hand-writes are still handled: after_model rewrites bash cat >/python open('w')/tee and leftover write calls to deep_research_report; wrap_tool_call rejects whatever reaches the tool layer; post-success writes are dropped, nudged to a 2-3 sentence summary, and patched with a fallback closing message so the turn never ends silently - The rewritten pipeline call carries topic = original request (clarification answers skipped) plus focus = clarification answers, so the pipeline writes the clarified subject instead of the raw prompt text; prompts updated to match (tools unavailable, call the tool directly, do not draft first) - Frontend write-step interception and streaming preview scoping now applies only in writing mode; normal mode shows/previews writes exactly as before --- .gitignore | 3 + .../components/workspace/chats/chat-box.tsx | 10 +- .../workspace/messages/message-group.tsx | 12 +- .../artifacts/write-file-in-flight.test.ts | 146 +++- .../core/artifacts/write-file-in-flight.ts | 9 +- .../core/threads/use-research-writing-feed.ts | 3 +- .../src/core/threads/writing-mode.test.ts | 44 +- frontend-web/src/core/threads/writing-mode.ts | 19 +- .../deerflow/agents/lead_agent/prompt.py | 17 +- .../writing_mode_report_middleware.py | 268 +++++-- .../test_writing_mode_report_middleware.py | 707 ++++++++++++++++++ .../tests/test_writing_rewrite_prompt.py | 166 ++++ 12 files changed, 1271 insertions(+), 133 deletions(-) create mode 100644 offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py create mode 100644 offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py diff --git a/.gitignore b/.gitignore index 6ac82388..2ca6b87e 100644 --- a/.gitignore +++ b/.gitignore @@ -127,6 +127,9 @@ offline-backend-20260512/backend/tests/* !offline-backend-20260512/backend/tests/test_workflow_engine.py !offline-backend-20260512/backend/tests/test_workflow_runs.py !offline-backend-20260512/backend/tests/test_workflow_concurrency.py +# Writing-mode first-report pipeline enforcement regressions. +!offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py +!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py deploy .history diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index 2cdc2fba..4589c288 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -101,6 +101,9 @@ const ChatBox: React.FC<{ const [pinnedReferenceId, setPinnedReferenceId] = useState(null); const [backendReferenceBatches, setBackendReferenceBatches] = useState([]); const [settings] = useLocalSettings(); + // 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持 + // 原有行为(新产物自动打开 + in-flight 预览不过滤)。 + const writingModeEnabled = !!settings.context.writing_mode; const artifactDirectoryModeEnabled = settings.display.artifactDirectoryModeEnabled ?? false; const ragModeEnabled = @@ -392,7 +395,7 @@ const ChatBox: React.FC<{ ((hasNewArtifact || !hadArtifacts) && !selectedArtifact)) ) { const newest = nextArtifacts[nextArtifacts.length - 1]!; - if (shouldAutoOpenWritingArtifact(newest, thread.messages)) { + if (shouldAutoOpenWritingArtifact(newest, thread.messages, writingModeEnabled)) { selectArtifact(newest); setArtifactsOpen(true); setSideTab("document"); @@ -429,6 +432,7 @@ const ChatBox: React.FC<{ availableArtifacts, disableArtifacts, thread.messages, + writingModeEnabled, ]); // Live sandbox: open as soon as any in-flight write_file has a path or @@ -439,8 +443,8 @@ const ChatBox: React.FC<{ () => disableArtifacts || !thread.isLoading ? null - : latestInFlightWriteFilePreview(thread.messages ?? []), - [disableArtifacts, thread.isLoading, thread.messages], + : latestInFlightWriteFilePreview(thread.messages ?? [], writingModeEnabled), + [disableArtifacts, thread.isLoading, thread.messages, writingModeEnabled], ); useEffect(() => { if (!streamingWritePreview) return; diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index 3505ef92..ddd7bd20 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -101,8 +101,14 @@ export function MessageGroup({ const [showLastThinking, setShowLastThinking] = useState( env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", ); - const steps = useMemo(() => convertToSteps(messages), [messages]); const [settings] = useLocalSettings(); + // 写作模式的「拦截 outputs/*.md 手写」只在写作模式生效;普通模式的 + // write_file / read_file 步骤条必须照旧显示。 + const writingModeEnabled = !!settings.context.writing_mode; + const steps = useMemo( + () => convertToSteps(messages, writingModeEnabled), + [messages, writingModeEnabled], + ); const { skills } = useSkills(); const skillDisplayMap = useMemo( () => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), @@ -849,7 +855,7 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> { type CoTStep = CoTReasoningStep | CoTToolCallStep; -function convertToSteps(messages: Message[]): CoTStep[] { +function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] { const steps: CoTStep[] = []; for (const message of messages) { if (message.type === "ai") { @@ -879,7 +885,7 @@ function convertToSteps(messages: Message[]): CoTStep[] { tool_call.name === "read_multiple_files" || tool_call.name === "present_files" ) && - isInterceptedWritingReportWrite(writePath, message.id, messages) + isInterceptedWritingReportWrite(writePath, message.id, messages, writingMode) ) { continue; } diff --git a/frontend-web/src/core/artifacts/write-file-in-flight.test.ts b/frontend-web/src/core/artifacts/write-file-in-flight.test.ts index 21e99b2d..827504e6 100644 --- a/frontend-web/src/core/artifacts/write-file-in-flight.test.ts +++ b/frontend-web/src/core/artifacts/write-file-in-flight.test.ts @@ -61,7 +61,27 @@ test("latestInFlightWriteFilePreview keeps markdown when a later json write is i assert.match(preview?.artifactPath ?? "", /report\.md/); }); -test("latestInFlightWriteFilePreview skips intercepted custom outputs markdown", () => { +test("latestInFlightWriteFilePreview skips intercepted custom outputs markdown in writing mode", () => { + const messages = [ + { + 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(latestInFlightWriteFilePreview(messages, true), null); +}); + +test("latestInFlightWriteFilePreview keeps custom outputs markdown live in normal mode", () => { const preview = latestInFlightWriteFilePreview([ { id: "m1", @@ -78,54 +98,114 @@ test("latestInFlightWriteFilePreview skips intercepted custom outputs markdown", ], }, ]); - assert.equal(preview, null); + assert.ok(preview); + assert.match(preview?.artifactPath ?? "", /ai-agent-architecture-paper\.md/); }); 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", + 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", + }, }, - }, - ], - }, - ]); + ], + }, + ], + true, + ); 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", + }, + }, + ], + }, + ], + true, + ); + assert.ok(preview); + assert.match(preview?.artifactPath ?? "", /report\.md/); +}); + +test("writing mode still previews follow-up-turn writes after a later user message", () => { + // 续写轮(成功之后用户又发了新消息):真实 write_file 编辑照常预览。 + const preview = latestInFlightWriteFilePreview( + [ + { type: "human", content: "写一篇论文" }, + { + id: "m-pipe", + type: "ai", + tool_calls: [{ id: "p1", name: "deep_research_report", args: { topic: "课题" } }], + }, + { + id: "m-result", + type: "tool", + tool_call_id: "p1", + name: "deep_research_report", + content: "研究报告已完成:已写入 report.md 并展示给用户。", + }, + { type: "human", content: "把第二章改写得更学术一些" }, + { + id: "m-edit", + type: "ai", + tool_calls: [ + { + id: "w2", + name: "write_file", + args: { + path: "/mnt/user-data/outputs/report.md", + content: "# 修订后的报告\n", + }, + }, + ], + }, + ], + true, + ); + assert.ok(preview); + assert.equal(preview?.previewKey, "m-edit:w2"); +}); + +test("normal mode keeps previewing pathless streaming drafts", () => { const preview = latestInFlightWriteFilePreview([ { - id: "research-report-j1", + id: "m1", type: "ai", tool_calls: [ - { - id: "research-report-j1-write", - name: "write_file", - args: { - path: "/mnt/user-data/outputs/report.md", - content: "# 报告\n", - }, - }, + { id: "w1", name: "write_file", args: { content: "# 没有路径的流式草稿\n" } }, ], }, ]); assert.ok(preview); - assert.match(preview?.artifactPath ?? "", /report\.md/); + assert.match(preview?.artifactPath ?? "", /\.md/); }); test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => { diff --git a/frontend-web/src/core/artifacts/write-file-in-flight.ts b/frontend-web/src/core/artifacts/write-file-in-flight.ts index f3487bf6..477af28e 100644 --- a/frontend-web/src/core/artifacts/write-file-in-flight.ts +++ b/frontend-web/src/core/artifacts/write-file-in-flight.ts @@ -38,7 +38,10 @@ type WriteFileThreadLike = { messages: Array<{ id?: string; type?: string; + name?: string; + content?: unknown; tool_call_id?: string; + additional_kwargs?: Record; tool_calls?: Array<{ id?: string; name?: string; @@ -95,6 +98,7 @@ export function isWriteFileInFlight( */ export function latestInFlightWriteFilePreview( messages: WriteFileThreadLike["messages"], + writingMode = false, ): { artifactPath: string; previewKey: string } | null { const thread = { messages }; for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) { @@ -125,8 +129,9 @@ export function latestInFlightWriteFilePreview( 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)) { + // Opening that filename would 404-poll until report.md lands. Normal + // mode never skips: the write is a real deliverable, not a rewrite. + if (isInterceptedWritingReportWrite(sourcePath, message.id, messages, writingMode)) { continue; } // JSON (e.g. agentfx task-reports.json) is too large to live-preview: diff --git a/frontend-web/src/core/threads/use-research-writing-feed.ts b/frontend-web/src/core/threads/use-research-writing-feed.ts index 06441e41..fe7c5366 100644 --- a/frontend-web/src/core/threads/use-research-writing-feed.ts +++ b/frontend-web/src/core/threads/use-research-writing-feed.ts @@ -91,7 +91,8 @@ export function useResearchWritingFeed( current !== null && current !== virtualUrl && current !== RESEARCH_REPORT_OUTPUT_PATH && - !isInterceptedWritingReportWrite(currentPath); + // research 流存在即本线程跑过写作模式管线,这里恒按写作模式判定。 + !isInterceptedWritingReportWrite(currentPath, undefined, undefined, true); if (keepUserSelection) { return; } diff --git a/frontend-web/src/core/threads/writing-mode.test.ts b/frontend-web/src/core/threads/writing-mode.test.ts index a7f2e86e..5be6cae4 100644 --- a/frontend-web/src/core/threads/writing-mode.test.ts +++ b/frontend-web/src/core/threads/writing-mode.test.ts @@ -9,18 +9,33 @@ import { test("custom outputs markdown is always intercepted except the research stub", () => { assert.equal( - isInterceptedWritingReportWrite("/mnt/user-data/outputs/paper.md"), + isInterceptedWritingReportWrite("/mnt/user-data/outputs/paper.md", undefined, undefined, true), true, ); assert.equal( isInterceptedWritingReportWrite( "/mnt/user-data/outputs/report.md", "research-report-j1", + undefined, + true, ), false, ); }); +test("normal mode never intercepts outputs markdown writes", () => { + assert.equal( + isInterceptedWritingReportWrite("/mnt/user-data/outputs/paper.md"), + false, + ); + assert.equal( + isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m0", [ + { type: "human", content: "写一篇论文" }, + ]), + false, + ); +}); + test("report.md is intercepted for the whole first-report turn", () => { const beforePipeline = [{ type: "human", content: "写一篇论文" }]; assert.equal(isWritingFirstReportTurn(beforePipeline), true); @@ -29,6 +44,7 @@ test("report.md is intercepted for the whole first-report turn", () => { "/mnt/user-data/outputs/report.md", "m1", beforePipeline, + true, ), true, ); @@ -42,7 +58,7 @@ test("report.md is intercepted for the whole first-report turn", () => { ]; assert.equal(isWritingFirstReportTurn(inFlight), true); assert.equal( - isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m2", inFlight), + isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m2", inFlight, true), true, ); @@ -56,7 +72,7 @@ test("report.md is intercepted for the whole first-report turn", () => { ]; assert.equal(isWritingFirstReportTurn(succeeded), true); assert.equal( - isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m3", succeeded), + isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m3", succeeded, true), true, ); }); @@ -70,9 +86,27 @@ test("sandbox auto-open waits for report.md on the first-report turn", () => { }, ]; assert.equal( - shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/research-notes.md", firstTurn), + shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/research-notes.md", firstTurn, true), false, ); + assert.equal( + shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/report.md", firstTurn, true), + true, + ); +}); + +test("normal mode auto-opens every new artifact like before writing mode existed", () => { + const firstTurn = [ + { type: "human", content: "写一篇论文" }, + { + type: "ai", + tool_calls: [{ name: "deep_research_report" }], + }, + ]; + assert.equal( + shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/research-notes.md", firstTurn), + true, + ); assert.equal( shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/report.md", firstTurn), true, @@ -95,7 +129,7 @@ test("a later user turn releases report.md live edits", () => { ]; assert.equal(isWritingFirstReportTurn(followUp), false); assert.equal( - isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m4", followUp), + isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m4", followUp, true), false, ); }); diff --git a/frontend-web/src/core/threads/writing-mode.ts b/frontend-web/src/core/threads/writing-mode.ts index 79a6e5c3..29888f61 100644 --- a/frontend-web/src/core/threads/writing-mode.ts +++ b/frontend-web/src/core/threads/writing-mode.ts @@ -67,12 +67,19 @@ export function isWritingFirstReportTurn( * 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. + * + * Everything above only exists in writing mode — normal mode writes md files + * by hand as always, so `writingMode=false` (the default) never intercepts. */ export function isInterceptedWritingReportWrite( path: string | null | undefined, messageId?: string | null, messages?: readonly WritingPipelineMessage[] | null, + writingMode = false, ): boolean { + if (!writingMode) { + return false; + } if (typeof messageId === "string" && messageId.startsWith("research-report-")) { return false; } @@ -85,18 +92,26 @@ export function isInterceptedWritingReportWrite( return isWritingFirstReportTurn(messages); } -/** Auto-open the sandbox only for the real report during the first-report turn. */ +/** + * Auto-open the sandbox only for the real report during the first-report + * turn. Normal mode keeps the pre-writing-mode behavior: always auto-open + * the newest artifact. + */ export function shouldAutoOpenWritingArtifact( path: string | null | undefined, messages?: readonly WritingPipelineMessage[] | null, + writingMode = false, ): boolean { if (!path) { return false; } + if (!writingMode) { + return true; + } if (isWritingFirstReportTurn(messages)) { return isWritingModeReportPath(path); } - return !isInterceptedWritingReportWrite(path, undefined, messages); + return !isInterceptedWritingReportWrite(path, undefined, messages, true); } export function resolveWritingModeReportPath( diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 623ca245..757f9aac 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -795,15 +795,14 @@ def apply_agent_only_prompt_template( # 对话行为照旧;只有开始写 md 时才走 deep_research_report。资料不足也直接开写。 writing_mode_section = """ 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 — call it directly at delivery time and do NOT draft the full report body first (not in chat, not inside tool arguments). File-writing tools (`write_file` / `str_replace`) and the `bash` tool are NOT available in this run; any hand-write attempt is intercepted and redirected to `deep_research_report`. Never `present_files` the report. - Work normally (chat, search, clarify) until the report is due; then produce it by calling the `deep_research_report` tool: - `topic` = the user's subject copied faithfully, `focus` = the user's extra requirements (if any). + `topic` = the user's subject (the refined thesis after clarifications, not the raw first message), `focus` = the extra requirements from the user's answers (type, length, tone, must-cover points). The tool reuses the conversation's collected materials and writes the full report (general-analysis structure with references) to `/mnt/user-data/outputs/report.md`, presented automatically. - If harvested materials are thin, still call `deep_research_report` once and let it write immediately. Do NOT ask the user whether to search for more, and do NOT call `ask_clarification` for insufficient materials. - Search with your normal conversation tools. `deep_research_report` only writes the report file from materials already in this conversation; it does not search. -- Do NOT write the report body yourself with `write_file`, and do NOT call `present_files` for it. - After the tool returns 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. @@ -1479,20 +1478,18 @@ def apply_prompt_template( Writing mode is enabled for this run (first report turn — no existing artifact). **Mandatory Output Contract** -- HARD RULE: the report file is produced ONLY by the `deep_research_report` tool. You are FORBIDDEN from writing the report yourself — do not call `write_file` - (or any file-writing tool) to create the report, and do not call `present_files` for it. +- HARD RULE: the report file is produced ONLY by the `deep_research_report` tool. Call the tool directly when the report is due — do NOT draft the full report body first (not in chat, not inside tool arguments). File-writing tools (`write_file` / `str_replace`) and the `bash` tool are NOT available in this run; any hand-write attempt is intercepted and redirected to `deep_research_report`. Do not call `present_files` for the report. - Treat the user's request as a research-report writing task. Work normally until delivery time: discuss, search, and clarify exactly as you always would. -- When the report file is due — the user asks to start writing, or you have finished gathering what the conversation needs — call the `deep_research_report` tool: - - `topic`: the user's subject/thesis, copied faithfully. - - `focus`: the user's extra requirements (length, tone, must-cover points), if any. +- 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 directly: + - `topic`: the user's subject/thesis — the refined one settled in the clarifications, not the raw first message. + - `focus`: the extra requirements from the user's answers (type, length, tone, must-cover points), if any. - The tool reuses the materials already collected in this conversation, then writes the full report (general-analysis structure with a numbered reference section) to `/mnt/user-data/outputs/report.md`, presented automatically. - If harvested materials are thin, still call `deep_research_report` once and let it start the writing pipeline immediately. Do NOT ask the user whether to search for more materials, and do NOT call `ask_clarification` for that reason. - Search with your normal conversation tools. `deep_research_report` only writes the report file from materials already in this conversation; it does not search. -- Do NOT write the report body yourself with `write_file`, and do NOT call `present_files` for the report — the tool already handles both. - While the tool runs, stay silent. After it returns 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. """ else: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/writing_mode_report_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/writing_mode_report_middleware.py index a1ef9887..639006e9 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/writing_mode_report_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/writing_mode_report_middleware.py @@ -1,19 +1,35 @@ """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. +Writing mode still allows normal chat, search, and clarification. The report +only comes from the pipeline, and no hand-write path can even start: + +1. ``wrap_model_call`` removes ``write_file`` / ``str_replace`` / ``bash`` + from the model binding for the whole first-report run. ``after_model`` + rewrites can only fire AFTER a model response completes, so any write tool + left bound lets weak models stream the full essay into its arguments first + (``write_file`` content or a bash ``cat > report.md << EOF`` heredoc) for + tens of seconds, invisible to the user. bash is the only execution tool + that writes files, so excluding it too leaves NO tool the model could use + to start a file write — at delivery time the only path is the + ``deep_research_report`` call itself. +2. Hallucinated calls (emitted despite not being bound) are still handled: + bash heredoc / ``python open(..., 'w')`` / ``tee`` writes and leftover + ``write_file`` / ``str_replace`` calls are rewritten to the pipeline by + ``after_model``, and ``wrap_tool_call`` rejects whatever reaches the tool + layer. + +The rewritten pipeline call carries ``topic`` = the user's original request +(clarification answers skipped) plus ``focus`` = the clarification answers +(direction/type/length), so the pipeline writes the refined subject instead of +the raw prompt text. 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. +sentence wrap-up. If it still tries to loop after that nudge, the dropped +message is patched to a fallback summary and the turn ends — the user always +sees a closing message. This middleware never sets ``tool_choice``. Thinking-mode providers (Aliyun compatible-mode) reject ``required`` / object ``tool_choice`` with HTTP 400. @@ -23,12 +39,14 @@ from __future__ import annotations import json import logging +import re 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.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langgraph.prebuilt.tool_node import ToolCallRequest from langgraph.runtime import Runtime @@ -42,24 +60,20 @@ TOOL_NAME = "deep_research_report" _WRITE_TOOLS = frozenset({"write_file", "str_replace"}) _READ_TOOLS = frozenset({"read_file", "read_multiple_files"}) _PRESENT_TOOL = "present_files" +_BASH_TOOL = "bash" _OUTPUTS_PREFIX = "/mnt/user-data/outputs" _MAX_TOPIC_CHARS = 500 +_MAX_FOCUS_CHARS = 600 _NUDGE_KWARG = "writing_mode_summary_nudge" -_REJECT_HANDWRITTEN = ( - "写作模式的报告文件必须由 `deep_research_report` 生成,禁止用 write_file / " - "str_replace 手写报告。请立即调用 `deep_research_report`(topic=用户课题原文)。" -) +_FALLBACK_SUMMARY = "写作管线已完成,报告已写入 /mnt/user-data/outputs/report.md 并展示在右侧沙箱,可直接查看。如需调整内容,请在下一条消息中告诉我具体要求。" +_REJECT_HANDWRITTEN = "写作模式的报告文件必须由 `deep_research_report` 生成,禁止用 write_file / str_replace / bash 命令(cat > 重定向、python open(..., 'w')、tee 等)手写报告。请立即调用 `deep_research_report`(topic=用户课题原文)。" _REJECT_AFTER_SUCCESS = ( "写作管线已经完成,报告已写入 /mnt/user-data/outputs/report.md 并展示给用户。" - "禁止再次调用 deep_research_report,也禁止用 write_file / str_replace / " + "禁止再次调用 deep_research_report,也禁止用 write_file / str_replace / bash / " "read_file 读写该报告。请只用 2-3 句话总结核心结论后结束本轮,不要再调用任何文件或写作工具。" ) _SUMMARY_NUDGE = ( - "\n" - "写作管线已经完成,报告已写入 /mnt/user-data/outputs/report.md 并展示给用户。\n" - "请只用 2-3 句话总结核心结论,不要再调用任何工具,不要阅读或重写该文件。\n" - "不要评价材料是否不足,不要计划重写。\n" - "" + "\n写作管线已经完成,报告已写入 /mnt/user-data/outputs/report.md 并展示给用户。\n请只用 2-3 句话总结核心结论,不要再调用任何工具,不要阅读或重写该文件。\n不要评价材料是否不足,不要计划重写。\n" ) @@ -119,11 +133,42 @@ def _is_outputs_markdown_path(path: str) -> bool: return relative == "outputs" or relative.startswith("outputs/") +# bash 手写报告的识别:写入动作(重定向 / tee / python 只写打开 / write_text) +# 的目标本身是交付物 Markdown。纯读取(mkdir、wc、cat 输出)不拦。 +_BASH_MD_PATH = re.compile(r"""['"]([^'"]{0,200}\.md)['"]|(^|[\s;|&<>()=])([^\s;'"&|<>(){},]{0,200}\.md)\b""") +_BASH_MD_REDIRECT = re.compile(r">{1,2}\s*([^\s;|&>]{0,200}\.md)\b") +_BASH_MD_TEE = re.compile(r"\btee\s+(?:-[A-Za-z]+\s+)*([^\s;|&<>]{0,200}\.md)\b") +_BASH_MD_PY_WRITE = re.compile(r"""open\s*\(\s*['"][^'"]{0,200}\.md['"]\s*,\s*['"]w""") + + +def _bash_command_writes_report(command: str) -> bool: + """True 当 bash 命令在向写作模式的 Markdown 交付物写入内容。 + + 实测弱模型被禁用手写工具后会绕道 bash:``cat > report.md << 'EOF'``、 + ``python3 << PYEOF`` + ``open(..., 'w')``、``tee`` 等。按「写入动作的 + 目标(或命令提到的路径)是 outputs Markdown / report.md」判定; + mkdir、wc、cat 读取这类无写入动作的命令不受影响。 + """ + if not command or ".md" not in command: + return False + for pattern in (_BASH_MD_REDIRECT, _BASH_MD_TEE): + if any(_is_outputs_markdown_path(match.group(1)) for match in pattern.finditer(command)): + return True + paths = [quoted or bare or "" for quoted, _, bare in _BASH_MD_PATH.findall(command)] + if not any(_is_outputs_markdown_path(path) for path in paths): + return False + return bool(_BASH_MD_PY_WRITE.search(command)) or "write_text" in command or "write_bytes" in command + + 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)) + name = _tool_name(tool_call) + if name in _WRITE_TOOLS: + return _is_outputs_markdown_path(_tool_path(tool_call)) + if name == _BASH_TOOL: + args = _normalize_args(_tool_field(tool_call, "args", {})) + return _bash_command_writes_report(str(args.get("command") or "")) + return False def is_report_present_call(tool_call: object) -> bool: @@ -184,28 +229,6 @@ def _summary_nudge_message() -> HumanMessage: ) -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 @@ -238,21 +261,58 @@ def _message_text(message: Any) -> str: return str(content or "").strip() +def _is_clarification_result(message: Any) -> bool: + return isinstance(message, ToolMessage) and str(getattr(message, "name", "") or "") == "ask_clarification" + + 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] + """Use the latest visible user text that is not a clarification answer. + + 澄清轮里用户对 ask_clarification 的回答(紧跟着澄清 ToolMessage 的人类 + 消息,如「方向:AI Agent;用途:学术期刊论文」)不是课题本身;拿它当 + topic 会让管线给「回答本身」写报告。向上跳过所有回答,取真正的课题原文 + (最新一条非回答的用户消息)。 + """ + for index in range(len(messages) - 1, -1, -1): + message = messages[index] + if not _is_visible_human(message): + continue + if index > 0 and _is_clarification_result(messages[index - 1]): + continue + 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]: +def extract_report_focus(messages: list[Any], topic: str = "") -> str: + """Join the user's clarification answers as the pipeline's ``focus``. + + 澄清回答(方向/类型/篇幅/语气等)不是课题本身,但正是写作要求; + topic 取课题原文的同时把它们汇总进 focus,管线才能按澄清后的 + 精确主题(如「LLM 的能力边界…」+ 观点评论型 + 3000-5000 字)成稿, + 而不是只拿原始请求一句泛写。 + """ + parts: list[str] = [] + for index, message in enumerate(messages): + if not _is_visible_human(message): + continue + if index == 0 or not _is_clarification_result(messages[index - 1]): + continue + text = " ".join(_message_text(message).split()) + if text and text != topic: + parts.append(text) + return ";".join(parts).strip()[:_MAX_FOCUS_CHARS] + + +def _as_report_call(tool_call: object, topic: str, focus: str = "") -> dict[str, Any]: call_id = str(_tool_field(tool_call, "id") or "") + args: dict[str, Any] = {"topic": topic} + if focus: + args["focus"] = focus return { "name": TOOL_NAME, - "args": {"topic": topic}, + "args": args, "id": call_id, "type": "tool_call", } @@ -267,11 +327,7 @@ def _sync_raw_tool_calls(last: AIMessage, kept: list[Any]) -> dict[str, Any]: 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") - } + 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: @@ -312,12 +368,14 @@ def rewrite_handwritten_report_calls( last: AIMessage, *, topic: str, + focus: 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. + write to ``deep_research_report`` (carrying ``topic`` + ``focus``) 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. @@ -347,12 +405,10 @@ def rewrite_handwritten_report_calls( changed = True if redirected: continue - rewritten.append(_as_report_call(call, topic)) + rewritten.append(_as_report_call(call, topic, focus)) 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) - ): + 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) @@ -371,10 +427,45 @@ def _messages_from_tool_request(request: ToolCallRequest) -> list[Any]: class WritingModeReportMiddleware(AgentMiddleware[AgentState]): - """Start the writing pipeline only when the model begins writing the Markdown file.""" + """The first-report run's report only comes from ``deep_research_report``. + + ``wrap_model_call`` 把 write_file / str_replace / bash 一并从模型绑定中 + 剥离(本中间件只在首报轮挂载)——after_model 的改写只能在整个模型响应 + 结束后触发,任何留在绑定里的写入口都会被弱模型用来先把全文起草进参数 + (write_file 内容或 bash heredoc 命令),流式几十秒前端不可见后才被拦 + 截。bash 是唯一能写文件的执行类工具,剥离后模型没有任何可发起文件写 + 入的工具,交付只能直接调管线;幻觉出的调用仍由 after_model 改写/丢弃、 + wrap_tool_call 拒绝兜底,管线成功后再写会被丢弃并保证一段收尾总结。 + """ state_schema = AgentState + @staticmethod + def _binding_tool_name(tool: Any) -> str: + if isinstance(tool, str): + return tool + return str(getattr(tool, "name", "") or "") + + def _strip_handwrite_tools(self, request: ModelRequest) -> ModelRequest: + """Remove ``write_file`` / ``str_replace`` / ``bash`` from the model binding. + + ``after_model`` 的改写只能在模型整个响应结束后触发,所以任何留在绑定里 + 的写入口都会被弱模型用来先把全文起草进参数(write_file 参数、bash + heredoc 命令),流式输出几十秒且前端不可见后才被拦截。bash 是唯一能 + 写文件的执行类工具,一并剥离后首报轮**不存在可发起文件写入的工具**, + 交付只能直接调 ``deep_research_report``。ToolNode 仍持有全部工具; + 模型幻觉出的调用由 after_model 改写/丢弃、wrap_tool_call 拒绝兜底。 + """ + tools = list(getattr(request, "tools", None) or []) + if not tools: + return request + excluded = _WRITE_TOOLS | {_BASH_TOOL} + removed = sorted({self._binding_tool_name(tool) for tool in tools if self._binding_tool_name(tool) in excluded}) + if not removed: + return request + logger.info("Writing-mode first-report run: removed hand-write tools %s from model binding", removed) + return request.override(tools=[tool for tool in tools if self._binding_tool_name(tool) not in excluded]) + def _redirect_or_stop(self, state: AgentState) -> dict[str, Any] | None: messages = list(state.get("messages") or []) if not messages: @@ -383,26 +474,38 @@ class WritingModeReportMiddleware(AgentMiddleware[AgentState]): if not isinstance(last, AIMessage): return None already_done = pipeline_already_succeeded(messages) + topic = extract_report_topic(messages) patched = rewrite_handwritten_report_calls( last, - topic=extract_report_topic(messages), + topic=topic, + focus=extract_report_focus(messages, topic), 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): + if already_done: + if not (patched.tool_calls or []): + if _already_nudged_summary(messages): + logger.info("Writing-mode pipeline already succeeded; summary nudge ignored, ending turn") + # 模型无视总结提示仍要继续读写:丢弃其调用后补一段兜底 + # 总结再结束,保证用户总能看到收尾消息而不是静默结束。 + if not _message_text(patched): + patched = patched.model_copy(update={"content": _FALLBACK_SUMMARY}) + result["messages"] = [patched] + 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" + else: + redirected_names = sorted({_tool_name(call) for call in getattr(last, "tool_calls", None) or [] if is_handwritten_report_call(call)}) + if redirected_names: logger.info( - "Writing-mode pipeline already succeeded; summary nudge ignored, ending turn" + "Writing-mode: redirected hand-written report call(s) %s to %s", + redirected_names, + TOOL_NAME, ) - 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: @@ -427,6 +530,22 @@ class WritingModeReportMiddleware(AgentMiddleware[AgentState]): def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None: return self._redirect_or_stop(state) + @override + def wrap_model_call( + self, + request: ModelRequest, + handler: Callable[[ModelRequest], ModelResponse], + ) -> ModelCallResult: + return handler(self._strip_handwrite_tools(request)) + + @override + async def awrap_model_call( + self, + request: ModelRequest, + handler: Callable[[ModelRequest], Awaitable[ModelResponse]], + ) -> ModelCallResult: + return await handler(self._strip_handwrite_tools(request)) + @hook_config(can_jump_to=["end", "model"]) @override async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None: @@ -458,6 +577,7 @@ class WritingModeReportMiddleware(AgentMiddleware[AgentState]): __all__ = [ "TOOL_NAME", "WritingModeReportMiddleware", + "extract_report_focus", "extract_report_topic", "is_handwritten_report_call", "is_post_success_loop_call", diff --git a/offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py b/offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py new file mode 100644 index 00000000..a1b8b2aa --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py @@ -0,0 +1,707 @@ +"""写作模式:仅在开始写 md 时改走 deep_research_report,且不强制 tool_choice。""" + +from __future__ import annotations + +from types import SimpleNamespace + +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + +from deerflow.agents.middlewares.writing_mode_report_middleware import ( + TOOL_NAME, + WritingModeReportMiddleware, + extract_report_focus, + extract_report_topic, + is_handwritten_report_call, + is_report_reread_call, + pipeline_already_succeeded, + rewrite_handwritten_report_calls, +) +from deerflow.tools.builtins.deep_research_report_tool import is_writing_mode_report_path + + +def test_only_report_md_is_writing_mode_follow_up() -> None: + assert is_writing_mode_report_path("/mnt/user-data/outputs/report.md") is True + assert is_writing_mode_report_path("report.md") is True + assert is_writing_mode_report_path("/mnt/user-data/outputs/notes.md") is False + assert is_writing_mode_report_path("/mnt/user-data/outputs/analysis.markdown") is False + assert is_writing_mode_report_path("") is False + assert is_writing_mode_report_path(None) is False + + +def test_handwritten_report_detects_outputs_markdown() -> None: + assert is_handwritten_report_call({"name": "write_file", "args": {"path": "/mnt/user-data/outputs/report.md"}, "id": "c1"}) + assert is_handwritten_report_call({"name": "write_file", "args": {"path": "/mnt/user-data/outputs/analysis.md"}, "id": "c1"}) + assert is_handwritten_report_call({"name": "str_replace", "args": {"path": "report.md"}, "id": "c1"}) + assert not is_handwritten_report_call({"name": "write_file", "args": {"path": "/mnt/user-data/workspace/notes.md"}, "id": "c1"}) + assert not is_handwritten_report_call({"name": "web_search", "args": {"query": "课题"}, "id": "c1"}) + + +def test_extract_topic_uses_latest_visible_user_text() -> None: + topic = extract_report_topic( + [ + HumanMessage(content="旧课题"), + HumanMessage(content="hidden", additional_kwargs={"hide_from_ui": True}), + HumanMessage(content="写一份新能源汽车产业报告,重点看产能"), + ] + ) + assert topic.startswith("写一份新能源汽车产业报告") + + +def test_rewrite_write_file_to_pipeline() -> None: + last = AIMessage( + content="", + tool_calls=[ + { + "id": "c1", + "name": "write_file", + "args": {"path": "/mnt/user-data/outputs/report.md", "content": "# 草稿"}, + "type": "tool_call", + }, + { + "id": "c2", + "name": "present_files", + "args": {"path": "/mnt/user-data/outputs/report.md"}, + "type": "tool_call", + }, + ], + ) + + patched = rewrite_handwritten_report_calls(last, topic="新能源汽车") + + assert patched is not None + assert len(patched.tool_calls) == 1 + assert patched.tool_calls[0]["name"] == TOOL_NAME + assert patched.tool_calls[0]["args"] == {"topic": "新能源汽车"} + assert patched.tool_calls[0]["id"] == "c1" + + +def test_rewrite_keeps_existing_pipeline_and_drops_handwritten() -> None: + last = AIMessage( + content="", + tool_calls=[ + { + "id": "c1", + "name": TOOL_NAME, + "args": {"topic": "课题"}, + "type": "tool_call", + }, + { + "id": "c2", + "name": "write_file", + "args": {"path": "/mnt/user-data/outputs/report.md", "content": "x"}, + "type": "tool_call", + }, + ], + ) + + patched = rewrite_handwritten_report_calls(last, topic="课题") + + assert patched is not None + assert [call["name"] for call in patched.tool_calls] == [TOOL_NAME] + + +def test_rewrite_leaves_chat_and_search_alone() -> None: + chat = AIMessage(content="先确认一下研究范围。") + assert rewrite_handwritten_report_calls(chat, topic="课题") is None + + search = AIMessage( + content="", + tool_calls=[ + {"id": "s1", "name": "web_search", "args": {"query": "课题"}, "type": "tool_call"}, + ], + ) + assert rewrite_handwritten_report_calls(search, topic="课题") is None + + pipeline = AIMessage( + content="", + tool_calls=[ + {"id": "c1", "name": TOOL_NAME, "args": {"topic": "课题"}, "type": "tool_call"}, + ], + ) + assert rewrite_handwritten_report_calls(pipeline, topic="课题") is None + + +def test_middleware_allows_chat_before_writing() -> None: + middleware = WritingModeReportMiddleware() + state = { + "messages": [ + HumanMessage(content="先聊聊这个课题"), + AIMessage(content="好的,我们先明确范围。"), + ] + } + assert middleware.after_model(state, runtime=SimpleNamespace()) is None + + +def test_middleware_rewrites_write_file_on_after_model() -> None: + middleware = WritingModeReportMiddleware() + state = { + "messages": [ + HumanMessage(content="写一份新能源汽车报告"), + AIMessage( + content="", + tool_calls=[ + { + "id": "c1", + "name": "write_file", + "args": {"path": "/mnt/user-data/outputs/report.md", "content": "# 报告"}, + "type": "tool_call", + } + ], + ), + ] + } + + update = middleware.after_model(state, runtime=SimpleNamespace()) + + assert update is not None + patched = update["messages"][0] + assert patched.tool_calls[0]["name"] == TOOL_NAME + assert patched.tool_calls[0]["args"]["topic"].startswith("写一份新能源汽车报告") + + +def test_wrap_tool_call_rejects_handwritten_report() -> None: + middleware = WritingModeReportMiddleware() + request = SimpleNamespace( + tool_call={ + "id": "c1", + "name": "write_file", + "args": {"path": "/mnt/user-data/outputs/report.md", "content": "x"}, + } + ) + called = {"handler": False} + + def handler(_request): + called["handler"] = True + return ToolMessage(content="wrote", tool_call_id="c1", name="write_file") + + result = middleware.wrap_tool_call(request, handler) + + assert called["handler"] is False + assert isinstance(result, ToolMessage) + assert result.content.startswith("Error:") + assert TOOL_NAME in result.content + + +def test_pipeline_success_is_detected() -> None: + messages = [ + HumanMessage(content="写一份报告"), + AIMessage( + content="", + tool_calls=[{"id": "c1", "name": TOOL_NAME, "args": {"topic": "课题"}, "type": "tool_call"}], + ), + ToolMessage(content="研究报告已完成:已写入 report.md。", tool_call_id="c1", name=TOOL_NAME), + ] + assert pipeline_already_succeeded(messages) is True + assert pipeline_already_succeeded(messages[:-1]) is False + failed = [ + *messages[:-1], + ToolMessage(content="Error: 深度研究报告生成失败。", tool_call_id="c1", name=TOOL_NAME), + ] + assert pipeline_already_succeeded(failed) is False + + +def test_reread_detects_outputs_markdown() -> None: + assert is_report_reread_call({"name": "read_file", "args": {"path": "/mnt/user-data/outputs/report.md"}, "id": "c1"}) + assert is_report_reread_call({"name": "read_file", "args": {"path": "/mnt/user-data/outputs/ai-agents-paper.md"}, "id": "c1"}) + assert not is_report_reread_call({"name": "read_file", "args": {"path": "/mnt/user-data/workspace/notes.md"}, "id": "c1"}) + assert not is_report_reread_call({"name": "write_file", "args": {"path": "/mnt/user-data/outputs/report.md"}, "id": "c1"}) + + +def test_rewrite_after_success_drops_deliverable_tools() -> None: + last = AIMessage( + content="我再重写一版", + tool_calls=[ + { + "id": "c2", + "name": "write_file", + "args": {"path": "/mnt/user-data/outputs/ai-agents-paper.md", "content": "# 新稿"}, + "type": "tool_call", + }, + { + "id": "c3", + "name": TOOL_NAME, + "args": {"topic": "课题"}, + "type": "tool_call", + }, + ], + ) + + patched = rewrite_handwritten_report_calls(last, topic="课题", pipeline_already_done=True) + + assert patched is not None + assert patched.tool_calls == [] + + +def test_rewrite_after_success_drops_reread() -> None: + last = AIMessage( + content="先看看报告", + tool_calls=[ + { + "id": "c2", + "name": "read_file", + "args": {"path": "/mnt/user-data/outputs/report.md"}, + "type": "tool_call", + } + ], + ) + + patched = rewrite_handwritten_report_calls(last, topic="课题", pipeline_already_done=True) + + assert patched is not None + assert patched.tool_calls == [] + assert patched.content == "" + + +def test_middleware_nudges_summary_after_successful_pipeline() -> None: + middleware = WritingModeReportMiddleware() + state = { + "messages": [ + HumanMessage(content="写一份新能源汽车报告"), + AIMessage( + content="", + tool_calls=[{"id": "c1", "name": TOOL_NAME, "args": {"topic": "课题"}, "type": "tool_call"}], + ), + ToolMessage(content="研究报告已完成:已写入 report.md。", tool_call_id="c1", name=TOOL_NAME), + AIMessage( + content="报告太泛化,我要重写", + tool_calls=[ + { + "id": "c2", + "name": "write_file", + "args": {"path": "/mnt/user-data/outputs/ai-agents-paper.md", "content": "# 重写"}, + "type": "tool_call", + } + ], + ), + ] + } + + update = middleware.after_model(state, runtime=SimpleNamespace()) + + assert update is not None + assert update["jump_to"] == "model" + assert update["messages"][0].tool_calls == [] + assert update["messages"][0].content == "" + nudge = update["messages"][1] + assert nudge.additional_kwargs["hide_from_ui"] is True + assert nudge.additional_kwargs["writing_mode_summary_nudge"] is True + assert "2-3" in nudge.content + + +def test_middleware_ends_turn_after_ignored_summary_nudge() -> None: + middleware = WritingModeReportMiddleware() + state = { + "messages": [ + HumanMessage(content="写一份新能源汽车报告"), + AIMessage( + content="", + tool_calls=[{"id": "c1", "name": TOOL_NAME, "args": {"topic": "课题"}, "type": "tool_call"}], + ), + ToolMessage(content="研究报告已完成:已写入 report.md。", tool_call_id="c1", name=TOOL_NAME), + HumanMessage( + content="请总结", + additional_kwargs={"hide_from_ui": True, "writing_mode_summary_nudge": True}, + ), + AIMessage( + content="", + tool_calls=[ + { + "id": "c2", + "name": "read_file", + "args": {"path": "/mnt/user-data/outputs/report.md"}, + "type": "tool_call", + } + ], + ), + ] + } + + update = middleware.after_model(state, runtime=SimpleNamespace()) + + assert update is not None + assert update["jump_to"] == "end" + assert update["messages"][0].tool_calls == [] + + +def test_wrap_tool_call_rejects_rerun_after_success() -> None: + middleware = WritingModeReportMiddleware() + prior = [ + HumanMessage(content="写一份报告"), + AIMessage( + content="", + tool_calls=[{"id": "c1", "name": TOOL_NAME, "args": {"topic": "课题"}, "type": "tool_call"}], + ), + ToolMessage(content="研究报告已完成:已写入 report.md。", tool_call_id="c1", name=TOOL_NAME), + ] + request = SimpleNamespace( + tool_call={"id": "c2", "name": TOOL_NAME, "args": {"topic": "课题"}}, + state={"messages": prior}, + ) + called = {"handler": False} + + def handler(_request): + called["handler"] = True + return ToolMessage(content="ran", tool_call_id="c2", name=TOOL_NAME) + + result = middleware.wrap_tool_call(request, handler) + + assert called["handler"] is False + assert isinstance(result, ToolMessage) + assert result.content.startswith("Error:") + assert "已经完成" in result.content + assert "请立即调用" not in result.content + + +def test_wrap_tool_call_rejects_reread_after_success() -> None: + middleware = WritingModeReportMiddleware() + prior = [ + HumanMessage(content="写一份报告"), + AIMessage( + content="", + tool_calls=[{"id": "c1", "name": TOOL_NAME, "args": {"topic": "课题"}, "type": "tool_call"}], + ), + ToolMessage(content="研究报告已完成:已写入 report.md。", tool_call_id="c1", name=TOOL_NAME), + ] + request = SimpleNamespace( + tool_call={ + "id": "c2", + "name": "read_file", + "args": {"path": "/mnt/user-data/outputs/report.md"}, + }, + state={"messages": prior}, + ) + called = {"handler": False} + + def handler(_request): + called["handler"] = True + return ToolMessage(content="read", tool_call_id="c2", name="read_file") + + result = middleware.wrap_tool_call(request, handler) + + assert called["handler"] is False + assert isinstance(result, ToolMessage) + assert result.content.startswith("Error:") + assert "已经完成" in result.content + + +def test_wrap_tool_call_allows_unrelated_writes() -> None: + middleware = WritingModeReportMiddleware() + request = SimpleNamespace( + tool_call={ + "id": "c1", + "name": "write_file", + "args": {"path": "/mnt/user-data/workspace/notes.md", "content": "x"}, + } + ) + expected = ToolMessage(content="wrote", tool_call_id="c1", name="write_file") + + result = middleware.wrap_tool_call(request, lambda _request: expected) + + assert result is expected + + +def test_extract_topic_skips_clarification_answers() -> None: + """澄清回答不是课题:向上取真正的写作请求原文。""" + topic = extract_report_topic( + [ + HumanMessage(content="写人工智能领域的论文md"), + AIMessage( + content="", + tool_calls=[ + { + "id": "q1", + "name": "ask_clarification", + "args": {"question": "方向?"}, + "type": "tool_call", + } + ], + ), + ToolMessage(content="需要澄清:方向?", tool_call_id="q1", name="ask_clarification"), + HumanMessage(content="方向:AI Agent;用途:学术期刊论文;篇幅:短篇2000-4000字"), + AIMessage( + content="", + tool_calls=[ + { + "id": "c1", + "name": "write_file", + "args": {"path": "/mnt/user-data/outputs/report.md"}, + "type": "tool_call", + } + ], + ), + ] + ) + assert topic.startswith("写人工智能领域的论文md") + + +def test_bash_heredoc_write_is_redirected_to_pipeline() -> None: + """模型被剥离不了工具时会绕道 bash heredoc 手写报告——同样改写为管线。""" + middleware = WritingModeReportMiddleware() + state = { + "messages": [ + HumanMessage(content="写人工智能领域的论文md"), + AIMessage( + content="", + tool_calls=[ + { + "id": "b1", + "name": "bash", + "args": {"command": "cat > /mnt/user-data/outputs/report.md << 'ENDOFFILE'\n# 报告全文……\nENDOFFILE"}, + "type": "tool_call", + } + ], + ), + ] + } + + update = middleware.after_model(state, runtime=SimpleNamespace()) + + assert update is not None + patched = update["messages"][0] + assert patched.tool_calls[0]["name"] == TOOL_NAME + assert patched.tool_calls[0]["args"]["topic"].startswith("写人工智能领域的论文md") + + +def test_bash_python_open_write_is_redirected_to_pipeline() -> None: + middleware = WritingModeReportMiddleware() + state = { + "messages": [ + HumanMessage(content="写一份新能源汽车报告"), + AIMessage( + content="", + tool_calls=[ + { + "id": "b2", + "name": "bash", + "args": {"command": ("python3 << 'PYEOF'\ncontent = r'''# 报告'''\nwith open('/mnt/user-data/outputs/report.md', 'w', encoding='utf-8') as f:\n f.write(content)\nPYEOF")}, + "type": "tool_call", + } + ], + ), + ] + } + + update = middleware.after_model(state, runtime=SimpleNamespace()) + + assert update is not None + patched = update["messages"][0] + assert patched.tool_calls[0]["name"] == TOOL_NAME + + +def test_bash_readonly_and_non_outputs_commands_are_left_alone() -> None: + """mkdir / wc / cat 读取、非 outputs 的 .md 写入都不是手写报告。""" + middleware = WritingModeReportMiddleware() + for command in ( + "mkdir -p /mnt/user-data/outputs", + "wc -m /mnt/user-data/outputs/report.md", + "cat /mnt/user-data/outputs/report.md", + "echo hi > /tmp/scratch.md", + ): + state = { + "messages": [ + HumanMessage(content="写一份报告"), + AIMessage( + content="", + tool_calls=[{"id": "b3", "name": "bash", "args": {"command": command}, "type": "tool_call"}], + ), + ] + } + assert middleware.after_model(state, runtime=SimpleNamespace()) is None, command + + +def test_bash_write_after_success_is_dropped() -> None: + middleware = WritingModeReportMiddleware() + state = { + "messages": [ + HumanMessage(content="写一份报告"), + AIMessage( + content="", + tool_calls=[{"id": "c1", "name": TOOL_NAME, "args": {"topic": "课题"}, "type": "tool_call"}], + ), + ToolMessage(content="研究报告已完成:基于 3 条资料…", tool_call_id="c1", name=TOOL_NAME), + AIMessage( + content="", + tool_calls=[ + { + "id": "b4", + "name": "bash", + "args": {"command": "echo '# 新版' > /mnt/user-data/outputs/report.md"}, + "type": "tool_call", + } + ], + ), + ] + } + + update = middleware.after_model(state, runtime=SimpleNamespace()) + + assert update is not None + patched = update["messages"][0] + assert patched.tool_calls == [] + nudged = update["messages"][1] + assert getattr(nudged, "additional_kwargs", {}).get("writing_mode_summary_nudge") is True + + +def test_wrap_tool_call_rejects_bash_report_write() -> None: + """漏网到执行层的 bash 手写(如裸 report.md 重定向)也要拒绝并引导走管线。""" + middleware = WritingModeReportMiddleware() + request = SimpleNamespace( + tool_call={ + "id": "b5", + "name": "bash", + "args": {"command": "cat > report.md << 'EOF'\n# x\nEOF"}, + } + ) + called = {"handler": False} + + def handler(_request): + called["handler"] = True + return ToolMessage(content="ran", tool_call_id="b5", name="bash") + + result = middleware.wrap_tool_call(request, handler) + + assert called["handler"] is False + assert isinstance(result, ToolMessage) + assert result.content.startswith("Error:") + assert TOOL_NAME in result.content + + +def test_wrap_model_call_strips_handwrite_tools_from_binding() -> None: + """首报轮从模型绑定中剥离 write_file/str_replace/bash——bash 是唯一能写 + 文件的执行类工具(实测模型会把全文起草进 heredoc 参数流式几十秒后才被 + after_model 拦截),剥离后模型没有任何可发起文件写入的工具。""" + middleware = WritingModeReportMiddleware() + + def make_request(tools): + return SimpleNamespace( + tools=tools, + override=lambda **kwargs: SimpleNamespace(tools=kwargs.get("tools", [])), + ) + + seen: dict[str, list] = {} + + def handler(request): + seen["tools"] = list(request.tools) + return SimpleNamespace() + + middleware.wrap_model_call( + make_request( + [ + SimpleNamespace(name="write_file"), + SimpleNamespace(name="web_search"), + SimpleNamespace(name="str_replace"), + SimpleNamespace(name="bash"), + SimpleNamespace(name=TOOL_NAME), + ] + ), + handler, + ) + + assert [getattr(t, "name", "") for t in seen["tools"]] == ["web_search", TOOL_NAME] + + +def test_wrap_model_call_passes_through_when_no_handwrite_tools() -> None: + middleware = WritingModeReportMiddleware() + request = SimpleNamespace( + tools=[SimpleNamespace(name="web_search"), SimpleNamespace(name=TOOL_NAME)], + override=lambda **kwargs: SimpleNamespace(tools=kwargs.get("tools", [])), + ) + + def handler(passed): + assert passed is request + return "unchanged" + + assert middleware.wrap_model_call(request, handler) == "unchanged" + + +def test_extract_focus_joins_clarification_answers() -> None: + focus = extract_report_focus( + [ + HumanMessage(content="写人工智能领域的论文md"), + AIMessage( + content="", + tool_calls=[{"id": "q1", "name": "ask_clarification", "args": {"question": "方向?"}, "type": "tool_call"}], + ), + ToolMessage(content="需要澄清", tool_call_id="q1", name="ask_clarification"), + HumanMessage(content="主题:LLM的能力边界;类型:观点评论型"), + ], + topic="写人工智能领域的论文md", + ) + assert "LLM的能力边界" in focus + assert "观点评论型" in focus + assert "写人工智能领域的论文md" not in focus + + +def test_rewrite_carries_focus_from_clarification_answers() -> None: + """改写进管线的调用要带上澄清回答(方向/类型/篇幅),否则管线只拿到 + 原始请求一句泛写。""" + middleware = WritingModeReportMiddleware() + state = { + "messages": [ + HumanMessage(content="写人工智能领域的论文md,有不懂的可以问我"), + AIMessage( + content="", + tool_calls=[{"id": "q1", "name": "ask_clarification", "args": {"question": "主题?"}, "type": "tool_call"}], + ), + ToolMessage(content="需要澄清", tool_call_id="q1", name="ask_clarification"), + HumanMessage(content="主题:LLM的能力边界;篇幅:3000-5000字"), + AIMessage( + content="", + tool_calls=[ + { + "id": "c1", + "name": "bash", + "args": {"command": "cat > /mnt/user-data/outputs/report.md << 'EOF'\n# x\nEOF"}, + "type": "tool_call", + } + ], + ), + ] + } + + update = middleware.after_model(state, runtime=SimpleNamespace()) + + assert update is not None + patched = update["messages"][0] + assert patched.tool_calls[0]["name"] == TOOL_NAME + assert patched.tool_calls[0]["args"]["topic"].startswith("写人工智能领域的论文md") + assert "LLM的能力边界" in patched.tool_calls[0]["args"]["focus"] + + +def test_second_offense_after_nudge_gets_fallback_summary() -> None: + """模型无视总结提示仍要继续:丢弃调用后必须留一段兜底总结,不能静默结束。""" + middleware = WritingModeReportMiddleware() + state = { + "messages": [ + HumanMessage(content="写一份新能源汽车报告"), + AIMessage( + content="", + tool_calls=[{"id": "c1", "name": TOOL_NAME, "args": {"topic": "课题"}, "type": "tool_call"}], + ), + ToolMessage(content="研究报告已完成:已写入 report.md。", tool_call_id="c1", name=TOOL_NAME), + HumanMessage( + content="请总结", + additional_kwargs={"hide_from_ui": True, "writing_mode_summary_nudge": True}, + ), + AIMessage( + content="", + tool_calls=[ + { + "id": "c2", + "name": "read_file", + "args": {"path": "/mnt/user-data/outputs/report.md"}, + "type": "tool_call", + } + ], + ), + ] + } + + update = middleware.after_model(state, runtime=SimpleNamespace()) + + assert update is not None + assert update["jump_to"] == "end" + patched = update["messages"][0] + assert patched.tool_calls == [] + assert "report.md" in patched.content + assert "已完成" in patched.content diff --git a/offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py b/offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py new file mode 100644 index 00000000..5b36627f --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py @@ -0,0 +1,166 @@ +"""Unit tests for the AI 改写 prompt builder (app.gateway.routers.writing). + +Focus: the 仿写 (imitate) action injects the reference sample into the prompt, +and existing actions are unaffected. +""" + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from app.gateway.routers.writing import ( + _REFERENCE_MAX_CHARS, + RewriteRequest, + SelectionInfo, + _release_document_rewrite, + _reserve_document_rewrite, + build_rewrite_messages, +) + + +def _req(**kwargs) -> RewriteRequest: + base = { + "selection": SelectionInfo(**{"from": 0, "to": 4, "text": "原始文字"}), + } + base.update(kwargs) + return RewriteRequest(**base) + + +def test_imitate_injects_reference_text(): + body = _req(action="imitate", reference_text="这是一段范文,节奏明快,排比有力。") + action, messages = build_rewrite_messages(body) + + assert action == "imitate" + human = messages[1].content + assert "范文" in human + assert "这是一段范文,节奏明快,排比有力。" in human + assert "原始文字" in human + + +def test_imitate_without_reference_has_no_reference_block(): + body = _req(action="imitate", reference_text=" ") + action, messages = build_rewrite_messages(body) + + assert action == "imitate" + # No reference → the 范文 block is not appended + assert "【范文】" not in messages[1].content + + +def test_imitate_reference_is_truncated(): + long_ref = "甲" * (_REFERENCE_MAX_CHARS + 500) + body = _req(action="imitate", reference_text=long_ref) + _, messages = build_rewrite_messages(body) + + human = messages[1].content + # The reference is capped — the 500 extra chars are dropped + assert human.count("甲") == _REFERENCE_MAX_CHARS + + +def test_polish_action_unaffected_by_reference_field(): + # A stray reference_text on a non-imitate action must be ignored + body = _req(action="polish", reference_text="不应出现的范文") + action, messages = build_rewrite_messages(body) + + assert action == "polish" + assert "范文" not in messages[1].content + assert "不应出现的范文" not in messages[1].content + + +def test_unknown_action_falls_back_to_polish(): + body = _req(action="not-a-real-action") + action, _ = build_rewrite_messages(body) + assert action == "polish" + + +def test_imitate_follow_up_preserves_reference_and_previous_result(): + body = _req( + action="imitate", + reference_text="范文样例", + previous_result="上一版仿写结果", + follow_up_instruction="再正式一点", + ) + _, messages = build_rewrite_messages(body) + + # system, human(original w/ reference), ai(previous), human(follow-up) + assert len(messages) == 4 + assert "范文样例" in messages[1].content + assert messages[2].content == "上一版仿写结果" + assert messages[3].content == "再正式一点" + + +@pytest.mark.asyncio +async def test_full_document_rewrite_reservation_rejects_a_second_live_request(tmp_path: Path): + """Two streams must never generate competing commits for one real file.""" + path = tmp_path / "report.md" + path.write_text("# report", encoding="utf-8") + + assert await _reserve_document_rewrite(path) is True + try: + assert await _reserve_document_rewrite(path) is False + finally: + await _release_document_rewrite(path) + + assert await _reserve_document_rewrite(path) is True + await _release_document_rewrite(path) + + +@pytest.mark.asyncio +async def test_full_document_rewrite_keeps_committed_file_when_undo_snapshot_fails( + monkeypatch, + tmp_path: Path, +): + """Undo persistence is best effort and must not restore a valid rewrite.""" + from app.gateway.routers import writing + + path = tmp_path / "report.md" + path.write_text("# 原文\n\n正文", encoding="utf-8") + + class _Model: + async def astream(self, *_args, **_kwargs): + yield "# 改写后\n\n新正文" + + class _BrokenVersionStore: + async def create(self, **_kwargs): + raise RuntimeError("version store unavailable") + + class _Config: + models: list[object] = [] + + @staticmethod + def get_model_config(_name): + return None + + async def resolve_path(*_args): + return path + + async def current_user(*_args): + return "user-a" + + async def requirements(*_args, **_kwargs): + yield "保留事实。" + + monkeypatch.setattr(writing, "aresolve_thread_virtual_path", resolve_path) + monkeypatch.setattr(writing, "get_current_user", current_user) + monkeypatch.setattr(writing, "create_chat_model", lambda **_kwargs: _Model()) + monkeypatch.setattr(writing, "_astream_rewrite_requirements", requirements) + monkeypatch.setattr(writing, "_extract_chunk_parts", lambda chunk: ("", chunk)) + response = await writing.rewrite_document( + body=writing.DocumentRewriteRequest( + threadId="thread-a", + path="/mnt/user-data/report.md", + instruction="统一表达风格", + ), + request=SimpleNamespace( + _deerflow_test_bypass_auth=True, + app=SimpleNamespace(state=SimpleNamespace(document_rewrite_version_store=_BrokenVersionStore())), + ), + config=_Config(), + ) + + events = "".join([frame async for frame in response.body_iterator]) + + assert path.read_text(encoding="utf-8") == "# 改写后\n\n新正文" + assert "event: committed" in events + assert '"versionSnapshotFailed": true' in events + assert "event: error" not in events