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
This commit is contained in:
1 parent
49c01cb72b
commit
4964967295
12 files changed
+1271
-133
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -101,6 +101,9 @@ const ChatBox: React.FC<{
|
||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -38,7 +38,10 @@ type WriteFileThreadLike = {
|
||||
messages: Array<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
name?: string;
|
||||
content?: unknown;
|
||||
tool_call_id?: string;
|
||||
additional_kwargs?: Record<string, unknown>;
|
||||
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:
|
||||
|
||||
@@ -91,7 +91,8 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
|
||||
current !== null &&
|
||||
current !== virtualUrl &&
|
||||
current !== RESEARCH_REPORT_OUTPUT_PATH &&
|
||||
!isInterceptedWritingReportWrite(currentPath);
|
||||
// research 流存在即本线程跑过写作模式管线,这里恒按写作模式判定。
|
||||
!isInterceptedWritingReportWrite(currentPath, undefined, undefined, true);
|
||||
if (keepUserSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
+7
-10
@@ -795,15 +795,14 @@ def apply_agent_only_prompt_template(
|
||||
# 对话行为照旧;只有开始写 md 时才走 deep_research_report。资料不足也直接开写。
|
||||
writing_mode_section = """<writing_mode>
|
||||
Writing mode is enabled for this run (first report turn — no existing artifact).
|
||||
- HARD RULE: the report file is produced ONLY by the `deep_research_report` tool — never write it yourself with `write_file`, never `present_files` it.
|
||||
- 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.
|
||||
</writing_mode>
|
||||
@@ -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.
|
||||
</writing_mode>
|
||||
"""
|
||||
else:
|
||||
|
||||
+194
-74
@@ -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 = (
|
||||
"<system_reminder>\n"
|
||||
"写作管线已经完成,报告已写入 /mnt/user-data/outputs/report.md 并展示给用户。\n"
|
||||
"请只用 2-3 句话总结核心结论,不要再调用任何工具,不要阅读或重写该文件。\n"
|
||||
"不要评价材料是否不足,不要计划重写。\n"
|
||||
"</system_reminder>"
|
||||
"<system_reminder>\n写作管线已经完成,报告已写入 /mnt/user-data/outputs/report.md 并展示给用户。\n请只用 2-3 句话总结核心结论,不要再调用任何工具,不要阅读或重写该文件。\n不要评价材料是否不足,不要计划重写。\n</system_reminder>"
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user