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_engine.py
|
||||||
!offline-backend-20260512/backend/tests/test_workflow_runs.py
|
!offline-backend-20260512/backend/tests/test_workflow_runs.py
|
||||||
!offline-backend-20260512/backend/tests/test_workflow_concurrency.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
|
deploy
|
||||||
.history
|
.history
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ const ChatBox: React.FC<{
|
|||||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
|
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
|
||||||
|
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
|
||||||
|
const writingModeEnabled = !!settings.context.writing_mode;
|
||||||
const artifactDirectoryModeEnabled =
|
const artifactDirectoryModeEnabled =
|
||||||
settings.display.artifactDirectoryModeEnabled ?? false;
|
settings.display.artifactDirectoryModeEnabled ?? false;
|
||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
@@ -392,7 +395,7 @@ const ChatBox: React.FC<{
|
|||||||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
|
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
|
||||||
) {
|
) {
|
||||||
const newest = nextArtifacts[nextArtifacts.length - 1]!;
|
const newest = nextArtifacts[nextArtifacts.length - 1]!;
|
||||||
if (shouldAutoOpenWritingArtifact(newest, thread.messages)) {
|
if (shouldAutoOpenWritingArtifact(newest, thread.messages, writingModeEnabled)) {
|
||||||
selectArtifact(newest);
|
selectArtifact(newest);
|
||||||
setArtifactsOpen(true);
|
setArtifactsOpen(true);
|
||||||
setSideTab("document");
|
setSideTab("document");
|
||||||
@@ -429,6 +432,7 @@ const ChatBox: React.FC<{
|
|||||||
availableArtifacts,
|
availableArtifacts,
|
||||||
disableArtifacts,
|
disableArtifacts,
|
||||||
thread.messages,
|
thread.messages,
|
||||||
|
writingModeEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Live sandbox: open as soon as any in-flight write_file has a path or
|
// Live sandbox: open as soon as any in-flight write_file has a path or
|
||||||
@@ -439,8 +443,8 @@ const ChatBox: React.FC<{
|
|||||||
() =>
|
() =>
|
||||||
disableArtifacts || !thread.isLoading
|
disableArtifacts || !thread.isLoading
|
||||||
? null
|
? null
|
||||||
: latestInFlightWriteFilePreview(thread.messages ?? []),
|
: latestInFlightWriteFilePreview(thread.messages ?? [], writingModeEnabled),
|
||||||
[disableArtifacts, thread.isLoading, thread.messages],
|
[disableArtifacts, thread.isLoading, thread.messages, writingModeEnabled],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!streamingWritePreview) return;
|
if (!streamingWritePreview) return;
|
||||||
|
|||||||
@@ -101,8 +101,14 @@ export function MessageGroup({
|
|||||||
const [showLastThinking, setShowLastThinking] = useState(
|
const [showLastThinking, setShowLastThinking] = useState(
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||||
);
|
);
|
||||||
const steps = useMemo(() => convertToSteps(messages), [messages]);
|
|
||||||
const [settings] = useLocalSettings();
|
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 { skills } = useSkills();
|
||||||
const skillDisplayMap = useMemo(
|
const skillDisplayMap = useMemo(
|
||||||
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||||
@@ -849,7 +855,7 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
|||||||
|
|
||||||
type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
||||||
|
|
||||||
function convertToSteps(messages: Message[]): CoTStep[] {
|
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||||
const steps: CoTStep[] = [];
|
const steps: CoTStep[] = [];
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (message.type === "ai") {
|
if (message.type === "ai") {
|
||||||
@@ -879,7 +885,7 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
|||||||
tool_call.name === "read_multiple_files" ||
|
tool_call.name === "read_multiple_files" ||
|
||||||
tool_call.name === "present_files"
|
tool_call.name === "present_files"
|
||||||
) &&
|
) &&
|
||||||
isInterceptedWritingReportWrite(writePath, message.id, messages)
|
isInterceptedWritingReportWrite(writePath, message.id, messages, writingMode)
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,27 @@ test("latestInFlightWriteFilePreview keeps markdown when a later json write is i
|
|||||||
assert.match(preview?.artifactPath ?? "", /report\.md/);
|
assert.match(preview?.artifactPath ?? "", /report\.md/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("latestInFlightWriteFilePreview skips intercepted custom outputs markdown", () => {
|
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([
|
const preview = latestInFlightWriteFilePreview([
|
||||||
{
|
{
|
||||||
id: "m1",
|
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", () => {
|
test("latestInFlightWriteFilePreview skips report.md while the first-turn pipeline is active", () => {
|
||||||
const preview = latestInFlightWriteFilePreview([
|
const preview = latestInFlightWriteFilePreview(
|
||||||
{ type: "human", content: "写一篇论文" },
|
[
|
||||||
{
|
{ type: "human", content: "写一篇论文" },
|
||||||
id: "m-pipe",
|
{
|
||||||
type: "ai",
|
id: "m-pipe",
|
||||||
tool_calls: [{ id: "p1", name: "deep_research_report", args: { topic: "课题" } }],
|
type: "ai",
|
||||||
},
|
tool_calls: [{ id: "p1", name: "deep_research_report", args: { topic: "课题" } }],
|
||||||
{
|
},
|
||||||
id: "m-write",
|
{
|
||||||
type: "ai",
|
id: "m-write",
|
||||||
tool_calls: [
|
type: "ai",
|
||||||
{
|
tool_calls: [
|
||||||
id: "w1",
|
{
|
||||||
name: "write_file",
|
id: "w1",
|
||||||
args: {
|
name: "write_file",
|
||||||
path: "/mnt/user-data/outputs/report.md",
|
args: {
|
||||||
content: "# 我自己写\n",
|
path: "/mnt/user-data/outputs/report.md",
|
||||||
|
content: "# 我自己写\n",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
]);
|
true,
|
||||||
|
);
|
||||||
assert.equal(preview, null);
|
assert.equal(preview, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("latestInFlightWriteFilePreview still opens the injected research stub", () => {
|
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([
|
const preview = latestInFlightWriteFilePreview([
|
||||||
{
|
{
|
||||||
id: "research-report-j1",
|
id: "m1",
|
||||||
type: "ai",
|
type: "ai",
|
||||||
tool_calls: [
|
tool_calls: [
|
||||||
{
|
{ id: "w1", name: "write_file", args: { content: "# 没有路径的流式草稿\n" } },
|
||||||
id: "research-report-j1-write",
|
|
||||||
name: "write_file",
|
|
||||||
args: {
|
|
||||||
path: "/mnt/user-data/outputs/report.md",
|
|
||||||
content: "# 报告\n",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert.ok(preview);
|
assert.ok(preview);
|
||||||
assert.match(preview?.artifactPath ?? "", /report\.md/);
|
assert.match(preview?.artifactPath ?? "", /\.md/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => {
|
test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => {
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ type WriteFileThreadLike = {
|
|||||||
messages: Array<{
|
messages: Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
content?: unknown;
|
||||||
tool_call_id?: string;
|
tool_call_id?: string;
|
||||||
|
additional_kwargs?: Record<string, unknown>;
|
||||||
tool_calls?: Array<{
|
tool_calls?: Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -95,6 +98,7 @@ export function isWriteFileInFlight(
|
|||||||
*/
|
*/
|
||||||
export function latestInFlightWriteFilePreview(
|
export function latestInFlightWriteFilePreview(
|
||||||
messages: WriteFileThreadLike["messages"],
|
messages: WriteFileThreadLike["messages"],
|
||||||
|
writingMode = false,
|
||||||
): { artifactPath: string; previewKey: string } | null {
|
): { artifactPath: string; previewKey: string } | null {
|
||||||
const thread = { messages };
|
const thread = { messages };
|
||||||
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||||
@@ -125,8 +129,9 @@ export function latestInFlightWriteFilePreview(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Writing mode rewrites custom outputs/*.md to deep_research_report.
|
// Writing mode rewrites custom outputs/*.md to deep_research_report.
|
||||||
// Opening that filename would 404-poll until report.md lands.
|
// Opening that filename would 404-poll until report.md lands. Normal
|
||||||
if (isInterceptedWritingReportWrite(sourcePath, message.id, messages)) {
|
// mode never skips: the write is a real deliverable, not a rewrite.
|
||||||
|
if (isInterceptedWritingReportWrite(sourcePath, message.id, messages, writingMode)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// JSON (e.g. agentfx task-reports.json) is too large to live-preview:
|
// JSON (e.g. agentfx task-reports.json) is too large to live-preview:
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
|
|||||||
current !== null &&
|
current !== null &&
|
||||||
current !== virtualUrl &&
|
current !== virtualUrl &&
|
||||||
current !== RESEARCH_REPORT_OUTPUT_PATH &&
|
current !== RESEARCH_REPORT_OUTPUT_PATH &&
|
||||||
!isInterceptedWritingReportWrite(currentPath);
|
// research 流存在即本线程跑过写作模式管线,这里恒按写作模式判定。
|
||||||
|
!isInterceptedWritingReportWrite(currentPath, undefined, undefined, true);
|
||||||
if (keepUserSelection) {
|
if (keepUserSelection) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,18 +9,33 @@ import {
|
|||||||
|
|
||||||
test("custom outputs markdown is always intercepted except the research stub", () => {
|
test("custom outputs markdown is always intercepted except the research stub", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isInterceptedWritingReportWrite("/mnt/user-data/outputs/paper.md"),
|
isInterceptedWritingReportWrite("/mnt/user-data/outputs/paper.md", undefined, undefined, true),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isInterceptedWritingReportWrite(
|
isInterceptedWritingReportWrite(
|
||||||
"/mnt/user-data/outputs/report.md",
|
"/mnt/user-data/outputs/report.md",
|
||||||
"research-report-j1",
|
"research-report-j1",
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
),
|
),
|
||||||
false,
|
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", () => {
|
test("report.md is intercepted for the whole first-report turn", () => {
|
||||||
const beforePipeline = [{ type: "human", content: "写一篇论文" }];
|
const beforePipeline = [{ type: "human", content: "写一篇论文" }];
|
||||||
assert.equal(isWritingFirstReportTurn(beforePipeline), true);
|
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",
|
"/mnt/user-data/outputs/report.md",
|
||||||
"m1",
|
"m1",
|
||||||
beforePipeline,
|
beforePipeline,
|
||||||
|
true,
|
||||||
),
|
),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -42,7 +58,7 @@ test("report.md is intercepted for the whole first-report turn", () => {
|
|||||||
];
|
];
|
||||||
assert.equal(isWritingFirstReportTurn(inFlight), true);
|
assert.equal(isWritingFirstReportTurn(inFlight), true);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m2", inFlight),
|
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m2", inFlight, true),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,7 +72,7 @@ test("report.md is intercepted for the whole first-report turn", () => {
|
|||||||
];
|
];
|
||||||
assert.equal(isWritingFirstReportTurn(succeeded), true);
|
assert.equal(isWritingFirstReportTurn(succeeded), true);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m3", succeeded),
|
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m3", succeeded, true),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -70,9 +86,27 @@ test("sandbox auto-open waits for report.md on the first-report turn", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
assert.equal(
|
assert.equal(
|
||||||
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/research-notes.md", firstTurn),
|
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/research-notes.md", firstTurn, true),
|
||||||
false,
|
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(
|
assert.equal(
|
||||||
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/report.md", firstTurn),
|
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/report.md", firstTurn),
|
||||||
true,
|
true,
|
||||||
@@ -95,7 +129,7 @@ test("a later user turn releases report.md live edits", () => {
|
|||||||
];
|
];
|
||||||
assert.equal(isWritingFirstReportTurn(followUp), false);
|
assert.equal(isWritingFirstReportTurn(followUp), false);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m4", followUp),
|
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m4", followUp, true),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -67,12 +67,19 @@ export function isWritingFirstReportTurn(
|
|||||||
* Hand-written outputs/*.md writes the backend rewrites or drops:
|
* Hand-written outputs/*.md writes the backend rewrites or drops:
|
||||||
* custom filenames always, and `report.md` while the first-turn pipeline is
|
* custom filenames always, and `report.md` while the first-turn pipeline is
|
||||||
* in-flight or just succeeded. The injected research stub stays visible.
|
* 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(
|
export function isInterceptedWritingReportWrite(
|
||||||
path: string | null | undefined,
|
path: string | null | undefined,
|
||||||
messageId?: string | null,
|
messageId?: string | null,
|
||||||
messages?: readonly WritingPipelineMessage[] | null,
|
messages?: readonly WritingPipelineMessage[] | null,
|
||||||
|
writingMode = false,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
if (!writingMode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (typeof messageId === "string" && messageId.startsWith("research-report-")) {
|
if (typeof messageId === "string" && messageId.startsWith("research-report-")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -85,18 +92,26 @@ export function isInterceptedWritingReportWrite(
|
|||||||
return isWritingFirstReportTurn(messages);
|
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(
|
export function shouldAutoOpenWritingArtifact(
|
||||||
path: string | null | undefined,
|
path: string | null | undefined,
|
||||||
messages?: readonly WritingPipelineMessage[] | null,
|
messages?: readonly WritingPipelineMessage[] | null,
|
||||||
|
writingMode = false,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!path) {
|
if (!path) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!writingMode) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (isWritingFirstReportTurn(messages)) {
|
if (isWritingFirstReportTurn(messages)) {
|
||||||
return isWritingModeReportPath(path);
|
return isWritingModeReportPath(path);
|
||||||
}
|
}
|
||||||
return !isInterceptedWritingReportWrite(path, undefined, messages);
|
return !isInterceptedWritingReportWrite(path, undefined, messages, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveWritingModeReportPath(
|
export function resolveWritingModeReportPath(
|
||||||
|
|||||||
+7
-10
@@ -795,15 +795,14 @@ def apply_agent_only_prompt_template(
|
|||||||
# 对话行为照旧;只有开始写 md 时才走 deep_research_report。资料不足也直接开写。
|
# 对话行为照旧;只有开始写 md 时才走 deep_research_report。资料不足也直接开写。
|
||||||
writing_mode_section = """<writing_mode>
|
writing_mode_section = """<writing_mode>
|
||||||
Writing mode is enabled for this run (first report turn — no existing artifact).
|
Writing mode is enabled for this run (first report turn — no existing artifact).
|
||||||
- HARD RULE: the report file is produced ONLY by the `deep_research_report` tool — never write it yourself with `write_file`, never `present_files` it.
|
- HARD RULE: the report file is produced ONLY by the `deep_research_report` tool — 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:
|
- 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)
|
The tool reuses the conversation's collected materials and writes the full report (general-analysis structure with references)
|
||||||
to `/mnt/user-data/outputs/report.md`, presented automatically.
|
to `/mnt/user-data/outputs/report.md`, presented automatically.
|
||||||
- If harvested materials are thin, still call `deep_research_report` once and let it write immediately.
|
- 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.
|
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.
|
- 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.
|
- After the tool returns successfully: do NOT call `read_file`, `write_file`, `str_replace`, or `present_files` on the report. Reply briefly (2-3 sentences) summarizing the core conclusions. Do not rewrite even if you think materials were thin.
|
||||||
- Only if the tool fails, tell the user and offer to retry.
|
- Only if the tool fails, tell the user and offer to retry.
|
||||||
</writing_mode>
|
</writing_mode>
|
||||||
@@ -1479,20 +1478,18 @@ def apply_prompt_template(
|
|||||||
Writing mode is enabled for this run (first report turn — no existing artifact).
|
Writing mode is enabled for this run (first report turn — no existing artifact).
|
||||||
|
|
||||||
**Mandatory Output Contract**
|
**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`
|
- 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.
|
||||||
(or any file-writing tool) to create the report, and do not call `present_files` for it.
|
|
||||||
- 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.
|
- 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:
|
- 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, copied faithfully.
|
- `topic`: the user's subject/thesis — the refined one settled in the clarifications, not the raw first message.
|
||||||
- `focus`: the user's extra requirements (length, tone, must-cover points), if any.
|
- `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
|
- The tool reuses the materials already collected in this conversation, then writes the full
|
||||||
report (general-analysis structure with a numbered reference section) to `/mnt/user-data/outputs/report.md`, presented automatically.
|
report (general-analysis structure with a numbered reference section) to `/mnt/user-data/outputs/report.md`, presented automatically.
|
||||||
- If harvested materials are thin, still call `deep_research_report` once and let it start the writing pipeline immediately.
|
- 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.
|
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.
|
- 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.
|
- 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>
|
</writing_mode>
|
||||||
"""
|
"""
|
||||||
else:
|
else:
|
||||||
|
|||||||
+194
-74
@@ -1,19 +1,35 @@
|
|||||||
"""Redirect writing-mode Markdown deliverables onto ``deep_research_report``.
|
"""Redirect writing-mode Markdown deliverables onto ``deep_research_report``.
|
||||||
|
|
||||||
Writing mode still allows normal chat, search, and clarification. The pipeline
|
Writing mode still allows normal chat, search, and clarification. The report
|
||||||
starts only when the model begins writing the report file (``write_file`` /
|
only comes from the pipeline, and no hand-write path can even start:
|
||||||
``str_replace`` of ``report.md`` or any Markdown under
|
|
||||||
``/mnt/user-data/outputs/``): those calls are rewritten to
|
1. ``wrap_model_call`` removes ``write_file`` / ``str_replace`` / ``bash``
|
||||||
``deep_research_report``. Sibling ``present_files`` of the same report are
|
from the model binding for the whole first-report run. ``after_model``
|
||||||
dropped because the pipeline presents the file itself.
|
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
|
After the pipeline has succeeded once in this thread, further
|
||||||
``deep_research_report`` / hand-written report writes / ``read_file`` of the
|
``deep_research_report`` / hand-written report writes / ``read_file`` of the
|
||||||
report are dropped. The first time that happens, a hidden summary nudge is
|
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
|
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.
|
sentence wrap-up. If it still tries to loop after that nudge, the dropped
|
||||||
Ending immediately on the first rewrite attempt left the model's
|
message is patched to a fallback summary and the turn ends — the user always
|
||||||
"I will rewrite this" text as the visible summary.
|
sees a closing message.
|
||||||
|
|
||||||
This middleware never sets ``tool_choice``. Thinking-mode providers (Aliyun
|
This middleware never sets ``tool_choice``. Thinking-mode providers (Aliyun
|
||||||
compatible-mode) reject ``required`` / object ``tool_choice`` with HTTP 400.
|
compatible-mode) reject ``required`` / object ``tool_choice`` with HTTP 400.
|
||||||
@@ -23,12 +39,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import Any, override
|
from typing import Any, override
|
||||||
|
|
||||||
from langchain.agents import AgentState
|
from langchain.agents import AgentState
|
||||||
from langchain.agents.middleware import AgentMiddleware, hook_config
|
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 langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||||
from langgraph.prebuilt.tool_node import ToolCallRequest
|
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||||
from langgraph.runtime import Runtime
|
from langgraph.runtime import Runtime
|
||||||
@@ -42,24 +60,20 @@ TOOL_NAME = "deep_research_report"
|
|||||||
_WRITE_TOOLS = frozenset({"write_file", "str_replace"})
|
_WRITE_TOOLS = frozenset({"write_file", "str_replace"})
|
||||||
_READ_TOOLS = frozenset({"read_file", "read_multiple_files"})
|
_READ_TOOLS = frozenset({"read_file", "read_multiple_files"})
|
||||||
_PRESENT_TOOL = "present_files"
|
_PRESENT_TOOL = "present_files"
|
||||||
|
_BASH_TOOL = "bash"
|
||||||
_OUTPUTS_PREFIX = "/mnt/user-data/outputs"
|
_OUTPUTS_PREFIX = "/mnt/user-data/outputs"
|
||||||
_MAX_TOPIC_CHARS = 500
|
_MAX_TOPIC_CHARS = 500
|
||||||
|
_MAX_FOCUS_CHARS = 600
|
||||||
_NUDGE_KWARG = "writing_mode_summary_nudge"
|
_NUDGE_KWARG = "writing_mode_summary_nudge"
|
||||||
_REJECT_HANDWRITTEN = (
|
_FALLBACK_SUMMARY = "写作管线已完成,报告已写入 /mnt/user-data/outputs/report.md 并展示在右侧沙箱,可直接查看。如需调整内容,请在下一条消息中告诉我具体要求。"
|
||||||
"写作模式的报告文件必须由 `deep_research_report` 生成,禁止用 write_file / "
|
_REJECT_HANDWRITTEN = "写作模式的报告文件必须由 `deep_research_report` 生成,禁止用 write_file / str_replace / bash 命令(cat > 重定向、python open(..., 'w')、tee 等)手写报告。请立即调用 `deep_research_report`(topic=用户课题原文)。"
|
||||||
"str_replace 手写报告。请立即调用 `deep_research_report`(topic=用户课题原文)。"
|
|
||||||
)
|
|
||||||
_REJECT_AFTER_SUCCESS = (
|
_REJECT_AFTER_SUCCESS = (
|
||||||
"写作管线已经完成,报告已写入 /mnt/user-data/outputs/report.md 并展示给用户。"
|
"写作管线已经完成,报告已写入 /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 句话总结核心结论后结束本轮,不要再调用任何文件或写作工具。"
|
"read_file 读写该报告。请只用 2-3 句话总结核心结论后结束本轮,不要再调用任何文件或写作工具。"
|
||||||
)
|
)
|
||||||
_SUMMARY_NUDGE = (
|
_SUMMARY_NUDGE = (
|
||||||
"<system_reminder>\n"
|
"<system_reminder>\n写作管线已经完成,报告已写入 /mnt/user-data/outputs/report.md 并展示给用户。\n请只用 2-3 句话总结核心结论,不要再调用任何工具,不要阅读或重写该文件。\n不要评价材料是否不足,不要计划重写。\n</system_reminder>"
|
||||||
"写作管线已经完成,报告已写入 /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/")
|
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:
|
def is_handwritten_report_call(tool_call: object) -> bool:
|
||||||
"""True when the model is about to hand-write the writing-mode Markdown file."""
|
"""True when the model is about to hand-write the writing-mode Markdown file."""
|
||||||
if _tool_name(tool_call) not in _WRITE_TOOLS:
|
name = _tool_name(tool_call)
|
||||||
return False
|
if name in _WRITE_TOOLS:
|
||||||
return _is_outputs_markdown_path(_tool_path(tool_call))
|
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:
|
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:
|
def _report_tool_succeeded(message: ToolMessage) -> bool:
|
||||||
if str(getattr(message, "name", "") or "") != TOOL_NAME:
|
if str(getattr(message, "name", "") or "") != TOOL_NAME:
|
||||||
return False
|
return False
|
||||||
@@ -238,21 +261,58 @@ def _message_text(message: Any) -> str:
|
|||||||
return str(content or "").strip()
|
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:
|
def extract_report_topic(messages: list[Any]) -> str:
|
||||||
"""Use the latest visible user text as ``deep_research_report.topic``."""
|
"""Use the latest visible user text that is not a clarification answer.
|
||||||
for message in reversed(messages):
|
|
||||||
if _is_visible_human(message):
|
澄清轮里用户对 ask_clarification 的回答(紧跟着澄清 ToolMessage 的人类
|
||||||
text = " ".join(_message_text(message).split())
|
消息,如「方向:AI Agent;用途:学术期刊论文」)不是课题本身;拿它当
|
||||||
if text:
|
topic 会让管线给「回答本身」写报告。向上跳过所有回答,取真正的课题原文
|
||||||
return text[:_MAX_TOPIC_CHARS]
|
(最新一条非回答的用户消息)。
|
||||||
|
"""
|
||||||
|
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 "当前课题"
|
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 "")
|
call_id = str(_tool_field(tool_call, "id") or "")
|
||||||
|
args: dict[str, Any] = {"topic": topic}
|
||||||
|
if focus:
|
||||||
|
args["focus"] = focus
|
||||||
return {
|
return {
|
||||||
"name": TOOL_NAME,
|
"name": TOOL_NAME,
|
||||||
"args": {"topic": topic},
|
"args": args,
|
||||||
"id": call_id,
|
"id": call_id,
|
||||||
"type": "tool_call",
|
"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 {})
|
additional_kwargs = dict(getattr(last, "additional_kwargs", {}) or {})
|
||||||
raw_calls = additional_kwargs.get("tool_calls")
|
raw_calls = additional_kwargs.get("tool_calls")
|
||||||
kept_ids = _kept_call_ids(kept)
|
kept_ids = _kept_call_ids(kept)
|
||||||
rewritten_by_id = {
|
rewritten_by_id = {str(_tool_field(call, "id") or ""): call for call in kept if _tool_field(call, "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:
|
if isinstance(raw_calls, list) and raw_calls:
|
||||||
synced: list[Any] = []
|
synced: list[Any] = []
|
||||||
for item in raw_calls:
|
for item in raw_calls:
|
||||||
@@ -312,12 +368,14 @@ def rewrite_handwritten_report_calls(
|
|||||||
last: AIMessage,
|
last: AIMessage,
|
||||||
*,
|
*,
|
||||||
topic: str,
|
topic: str,
|
||||||
|
focus: str = "",
|
||||||
pipeline_already_done: bool = False,
|
pipeline_already_done: bool = False,
|
||||||
) -> AIMessage | None:
|
) -> AIMessage | None:
|
||||||
"""Rewrite or drop report deliverable tool calls.
|
"""Rewrite or drop report deliverable tool calls.
|
||||||
|
|
||||||
Before the pipeline has succeeded: rewrite the first hand-written report
|
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 /
|
After it has succeeded: drop every pipeline / hand-written / present /
|
||||||
report-reread call so the model cannot start another writing loop.
|
report-reread call so the model cannot start another writing loop.
|
||||||
@@ -347,12 +405,10 @@ def rewrite_handwritten_report_calls(
|
|||||||
changed = True
|
changed = True
|
||||||
if redirected:
|
if redirected:
|
||||||
continue
|
continue
|
||||||
rewritten.append(_as_report_call(call, topic))
|
rewritten.append(_as_report_call(call, topic, focus))
|
||||||
redirected = True
|
redirected = True
|
||||||
continue
|
continue
|
||||||
if is_report_present_call(call) and (
|
if is_report_present_call(call) and (has_pipeline or redirected or any(is_handwritten_report_call(item) for item in tool_calls)):
|
||||||
has_pipeline or redirected or any(is_handwritten_report_call(item) for item in tool_calls)
|
|
||||||
):
|
|
||||||
changed = True
|
changed = True
|
||||||
continue
|
continue
|
||||||
rewritten.append(call)
|
rewritten.append(call)
|
||||||
@@ -371,10 +427,45 @@ def _messages_from_tool_request(request: ToolCallRequest) -> list[Any]:
|
|||||||
|
|
||||||
|
|
||||||
class WritingModeReportMiddleware(AgentMiddleware[AgentState]):
|
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
|
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:
|
def _redirect_or_stop(self, state: AgentState) -> dict[str, Any] | None:
|
||||||
messages = list(state.get("messages") or [])
|
messages = list(state.get("messages") or [])
|
||||||
if not messages:
|
if not messages:
|
||||||
@@ -383,26 +474,38 @@ class WritingModeReportMiddleware(AgentMiddleware[AgentState]):
|
|||||||
if not isinstance(last, AIMessage):
|
if not isinstance(last, AIMessage):
|
||||||
return None
|
return None
|
||||||
already_done = pipeline_already_succeeded(messages)
|
already_done = pipeline_already_succeeded(messages)
|
||||||
|
topic = extract_report_topic(messages)
|
||||||
patched = rewrite_handwritten_report_calls(
|
patched = rewrite_handwritten_report_calls(
|
||||||
last,
|
last,
|
||||||
topic=extract_report_topic(messages),
|
topic=topic,
|
||||||
|
focus=extract_report_focus(messages, topic),
|
||||||
pipeline_already_done=already_done,
|
pipeline_already_done=already_done,
|
||||||
)
|
)
|
||||||
if patched is None:
|
if patched is None:
|
||||||
return None
|
return None
|
||||||
result: dict[str, Any] = {"messages": [patched]}
|
result: dict[str, Any] = {"messages": [patched]}
|
||||||
if already_done and not (patched.tool_calls or []):
|
if already_done:
|
||||||
if _already_nudged_summary(messages):
|
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(
|
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
|
return result
|
||||||
|
|
||||||
def _reject_loop_tool(self, request: ToolCallRequest) -> ToolMessage | None:
|
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:
|
def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
|
||||||
return self._redirect_or_stop(state)
|
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"])
|
@hook_config(can_jump_to=["end", "model"])
|
||||||
@override
|
@override
|
||||||
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
|
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
|
||||||
@@ -458,6 +577,7 @@ class WritingModeReportMiddleware(AgentMiddleware[AgentState]):
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"TOOL_NAME",
|
"TOOL_NAME",
|
||||||
"WritingModeReportMiddleware",
|
"WritingModeReportMiddleware",
|
||||||
|
"extract_report_focus",
|
||||||
"extract_report_topic",
|
"extract_report_topic",
|
||||||
"is_handwritten_report_call",
|
"is_handwritten_report_call",
|
||||||
"is_post_success_loop_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