Explorar o código

fix(agent): 工作流终稿直接交付并修复 thinking 空串 400

run_chapter_workflow 交付正文后结束本轮,外层 Agent 不再复述改写。
tool-assistant 不再回传空 reasoning_content;无法回放时关闭 thinking,避免 DeepSeek 400。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi hai 3 semanas
pai
achega
317408ff2b

+ 14 - 1
src/components/chat/chat-panel.spec.tsx

@@ -78,11 +78,24 @@ describe("chat-panel agent reference integration", () => {
     expect(source).toContain("setReferenceTokensForConversation(drafts, targetConversationId")
   })
 
-  it("keeps chapter-generation replies limited to chapter body", () => {
+  it("keeps fast-mode chapter replies limited to chapter body", () => {
     expect(source).toContain("章节生成、续写或改写任务的最终回复必须只包含章节正文")
     expect(source).toContain("不要输出读取说明、执行总结、完成目标表格、章节结构、后续建议")
   })
 
+  it("tells the model the workflow delivers the chapter body itself", () => {
+    expect(source).toContain("章节正文由 run_chapter_workflow 直接交付给用户")
+    expect(source).toContain("禁止复述、改写、摘要或续写工具已交付的正文")
+    expect(source).toContain("一次只处理一章")
+  })
+
+  it("renders workflow-delivered chapter body without waiting for the model", () => {
+    expect(source).toContain("onFinalContent:")
+    expect(source).toContain("cleanGeneratedChapterContentForDisplay(body)")
+    expect(source).toContain("setStreamingContent(display, capturedConvId)")
+    expect(source).toContain("finalContentDelivered")
+  })
+
   it("uses three AI workflow modes instead of a single deep mode prompt", () => {
     expect(source).toContain("aiWorkflowMode")
     expect(source).toContain("setAiWorkflowMode")

+ 35 - 2
src/components/chat/chat-panel.tsx

@@ -72,7 +72,10 @@ import {
 } from "@/lib/novel/chapter-utils"
 import { buildDeAiSkillSystemPrompt, buildQmQuaiSystemPrompt, injectDeAiDirective } from "@/lib/novel/de-ai-adapter"
 import { loadEffectiveDeAiSkillSafely, resolveAvailableDeAiSkills } from "@/lib/novel/de-ai-skill-library"
-import { cleanGeneratedChapterContentWithTitle } from "@/lib/novel/chapter-content-cleanup"
+import {
+  cleanGeneratedChapterContentForDisplay,
+  cleanGeneratedChapterContentWithTitle,
+} from "@/lib/novel/chapter-content-cleanup"
 import { normalizePath } from "@/lib/path-utils"
 import { refreshProjectState } from "@/lib/project-refresh"
 import {
@@ -336,10 +339,16 @@ function buildChatAgentSystemPrompt(options: {
       // 计划阶段与"只输出正文/必须调 run_chapter_workflow"互斥:同时注入
       // 会让模型在两套矛盾指令之间随机选择,表现为跳过计划直接产出正文。
       lines.push("当前处于章节计划阶段:本轮只输出章节创作计划并等待用户确认,禁止输出章节正文,禁止调用 run_chapter_workflow 等正文生成类工具。")
-    } else {
+    } else if (options.aiWorkflowMode === "fast") {
       lines.push("小说模式下,如果用户要求生成、续写或改写章节,只输出可直接放入章节库的正文。")
       lines.push("章节生成、续写或改写任务的最终回复必须只包含章节正文,不要把工具读取过程、写作计划或执行过程展示给用户。")
       lines.push("不要输出读取说明、执行总结、完成目标表格、章节结构、后续建议、引用来源或 Markdown 表格;章节标题和正文以外的内容都不要输出。")
+    } else {
+      // 走工作流时正文由工具直接交付,再要求模型「只输出正文」会诱导它把
+      // 终稿复述一遍:既多花一次生成,又可能改坏已经定稿的正文。
+      lines.push("章节正文由 run_chapter_workflow 直接交付给用户,工具执行成功后本轮任务即结束。")
+      lines.push("禁止复述、改写、摘要或续写工具已交付的正文,也不要输出读取说明、执行总结、完成目标表格、章节结构、后续建议、引用来源或 Markdown 表格。")
+      lines.push("一次只处理一章:即使用户要求多章,也只调用一次 run_chapter_workflow,交付后结束并让用户再发下一章请求。")
     }
     if (options.includeOutlineFindProtocol) {
       lines.push(buildOutlineFindProtocol(options.targetChapterNumber))
@@ -1482,6 +1491,8 @@ export function ChatPanel() {
       let hasAgentError = false
       let lastAgentError = "生成失败"
       let accumulatedReasoningContent = ""
+      // 终结型工具(run_chapter_workflow)是否已直接交付正文。
+      let finalContentDelivered = false
 
       const markDone = (record?: AgentRunRecord) => {
         updateAgentAssistantMessage(assistantMessage.id, (message) => {
@@ -1920,6 +1931,16 @@ export function ChatPanel() {
           callbacks: {
             onText: (chunk: string) => {
               if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
+              if (finalContentDelivered) {
+                // 交付后模型仍在输出(例如工具报错后续轮),以模型新内容为准,
+                // 否则会把已交付正文和模型输出拼成两份。
+                finalContentDelivered = false
+                useChatStore.getState().setStreamingContent("", capturedConvId)
+                updateAgentAssistantMessage(assistantMessage.id, (message) => ({
+                  ...message,
+                  content: "",
+                }))
+              }
               appendStreamToken(chunk, capturedConvId)
               updateAgentAssistantMessage(assistantMessage.id, (message) => ({
                 ...message,
@@ -1948,6 +1969,18 @@ export function ChatPanel() {
                   agentStages: applyAgentActivityEvent(message.agentStages, event),
                 }))
               },
+              // 章节工作流直接交付终稿:正文立刻落到气泡,不再等外层模型复述。
+              onFinalContent: (body: string) => {
+                if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
+                const display = cleanGeneratedChapterContentForDisplay(body)
+                if (!display) return
+                finalContentDelivered = true
+                useChatStore.getState().setStreamingContent(display, capturedConvId)
+                updateAgentAssistantMessage(assistantMessage.id, (message) => ({
+                  ...message,
+                  content: display,
+                }))
+              },
               onUsage: (usage) => {
                 if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
                 useChatStore.getState().setConversationContextUsage(

+ 2 - 0
src/lib/agent/ai-chat-session.ts

@@ -8,6 +8,7 @@ export interface RunAiChatSessionCallbacks {
   onReasoningToken?: (chunk: string) => void
   onToolEvent?: AgentRunCallbacks["onToolEvent"]
   onActivityEvent?: AgentRunCallbacks["onActivityEvent"]
+  onFinalContent?: AgentRunCallbacks["onFinalContent"]
   onUsage?: AgentRunCallbacks["onUsage"]
   onUserMemoryDecision?: AgentRunCallbacks["onUserMemoryDecision"]
   onDone: () => void
@@ -47,6 +48,7 @@ export async function runAiChatSession(input: RunAiChatSessionInput): Promise<Ag
       onToolError: () => {},
       onToolEvent: input.callbacks.onToolEvent,
       onActivityEvent: input.callbacks.onActivityEvent,
+      onFinalContent: input.callbacks.onFinalContent,
       onUsage: input.callbacks.onUsage,
       onUserMemoryDecision: input.callbacks.onUserMemoryDecision,
       onDone: input.callbacks.onDone,

+ 85 - 0
src/lib/agent/codex-app-server-runner.spec.ts

@@ -54,6 +54,7 @@ function callbacks(): AgentRunCallbacks {
     onToolResult: vi.fn(),
     onToolError: vi.fn(),
     onToolEvent: vi.fn(),
+    onFinalContent: vi.fn(),
     onUsage: vi.fn(),
     onDone: vi.fn(),
     onError: vi.fn(),
@@ -284,6 +285,90 @@ describe("CodexAppServerRunner", () => {
     expect(record.toolCalls[0].status).toBe("error")
   })
 
+  it("交付终稿的 finalizesRun 工具中断 turn 后按成功收尾", async () => {
+    const body = "第240章 归零\n\n陈远的手还压在西线地图上。"
+    const tool: Tool = {
+      name: "run_chapter_workflow",
+      description: "章节工作流",
+      category: "action",
+      permission: "auto",
+      finalizesRun: true,
+      parameters: {},
+      execute: vi.fn(async (_params, _signal, context) => {
+        context?.onFinalContent?.(body)
+        return `章节工作流完成。\n\n最终正文:\n${body}`
+      }),
+    }
+    const registry = new ToolRegistry()
+    registry.register(tool)
+    appServerMock.onTurn = async () => {
+      await appServerMock.handler?.onDynamicToolCall?.({
+        threadId: "thread-1",
+        turnId: "turn-1",
+        callId: "call-workflow",
+        namespace: null,
+        tool: "run_chapter_workflow",
+        arguments: { userRequest: "写第240章" },
+      })
+      envelope("thread/tokenUsage/updated", {
+        threadId: "thread-1",
+        tokenUsage: {
+          last: { inputTokens: 10, outputTokens: 2, totalTokens: 12 },
+          total: { inputTokens: 18, outputTokens: 4, totalTokens: 22 },
+        },
+      })
+      envelope("item/agentMessage/delta", { threadId: "thread-1", delta: "我再把正文抄一遍" })
+      envelope("turn/completed", { threadId: "thread-1", turn: { status: "interrupted" } })
+    }
+    const cb = callbacks()
+
+    const record = await new CodexAppServerRunner().run(config([tool]), registry, messages, cb)
+
+    expect(appServerMock.interrupt).toHaveBeenCalledWith("thread-1", "turn-1")
+    expect(appServerMock.turnNumber).toBe(1)
+    expect(record.finalText).toBe(body)
+    expect(record.usage).toEqual(expect.objectContaining({ totalTokens: 22 }))
+    expect(cb.onFinalContent).toHaveBeenCalledWith(body)
+    expect(cb.onText).not.toHaveBeenCalled()
+    expect(cb.onDone).toHaveBeenCalledOnce()
+    expect(cb.onError).not.toHaveBeenCalled()
+  })
+
+  it("交付终稿后即使 turn 回 failed 也不当作失败", async () => {
+    const tool: Tool = {
+      name: "run_chapter_workflow",
+      description: "章节工作流",
+      category: "action",
+      permission: "auto",
+      finalizesRun: true,
+      parameters: {},
+      execute: vi.fn(async (_params, _signal, context) => {
+        context?.onFinalContent?.("终稿正文")
+        return "章节工作流完成。\n\n最终正文:\n终稿正文"
+      }),
+    }
+    const registry = new ToolRegistry()
+    registry.register(tool)
+    appServerMock.onTurn = async () => {
+      await appServerMock.handler?.onDynamicToolCall?.({
+        threadId: "thread-1",
+        turnId: "turn-1",
+        callId: "call-workflow",
+        namespace: null,
+        tool: "run_chapter_workflow",
+        arguments: {},
+      })
+      envelope("turn/completed", { threadId: "thread-1", turn: { status: "failed", error: { message: "turn interrupted" } } })
+    }
+    const cb = callbacks()
+
+    const record = await new CodexAppServerRunner().run(config([tool]), registry, messages, cb)
+
+    expect(record.finalText).toBe("终稿正文")
+    expect(cb.onDone).toHaveBeenCalledOnce()
+    expect(cb.onError).not.toHaveBeenCalled()
+  })
+
   it("maps AbortSignal cancellation to turn/interrupt", async () => {
     const controller = new AbortController()
     const cb = callbacks()

+ 24 - 0
src/lib/agent/codex-app-server-runner.ts

@@ -117,6 +117,8 @@ export class CodexAppServerRunner {
     let threadId = ""
     let activeTurnId = ""
     let turnText = ""
+    /** 终结型工具已交付的终稿;非空表示本 run 不再需要模型输出。 */
+    let finalDelivery = ""
     let turnUsage: LlmUsage | undefined
     let cumulativeUsage: LlmUsage | undefined
     let turnResolve: ((completion: TurnCompletion) => void) | null = null
@@ -280,6 +282,15 @@ export class CodexAppServerRunner {
             )
             await persistTaskBreakpoint()
           }
+          if (
+            executed.success &&
+            executed.finalContent?.trim() &&
+            registry.get(request.tool)?.finalizesRun
+          ) {
+            // 终稿已交付给用户,本 turn 剩下的模型输出没有价值,直接中断。
+            finalDelivery = executed.finalContent.trim()
+            if (threadId && activeTurnId) void client.interrupt(threadId, activeTurnId)
+          }
           return {
             contentItems: [{
               type: "inputText",
@@ -367,6 +378,19 @@ export class CodexAppServerRunner {
         if (terminalError) void client.interrupt(threadId, activeTurnId)
         const completion = await completionPromise
         activeTurnId = ""
+        if (finalDelivery && !terminalError) {
+          // 交付即收尾:被 interrupt 的 turn 可能回 interrupted 也可能回 failed,
+          // 只要拿到了终稿且不是用户取消,都按成功结束。
+          // usage 缺失时保留已有累计值,避免上下文用量环归零。
+          const deliveredLastUsage = turnUsage as LlmUsage | undefined
+          const deliveredTotalUsage = cumulativeUsage as LlmUsage | undefined
+          if (deliveredLastUsage) record.lastRequestUsage = { ...deliveredLastUsage }
+          if (deliveredTotalUsage) record.usage = { ...deliveredTotalUsage }
+          record.finalText = finalDelivery
+          await clearPersistedBreakpoint()
+          callbacks.onDone()
+          return record
+        }
         if (completion.status === "failed") {
           throw new Error(completion.error || "Codex app-server turn 失败")
         }

+ 98 - 2
src/lib/agent/runner.spec.ts

@@ -344,7 +344,7 @@ describe("AgentRunner", () => {
     expect(result.finalText).toBe("写完了")
   })
 
-  it("always attaches reasoning_content for tool-call assistants even when empty", async () => {
+  it("omits empty reasoning_content on tool-call assistants", async () => {
     const tool: Tool = {
       name: "read_chapter",
       description: "read",
@@ -382,7 +382,7 @@ describe("AgentRunner", () => {
 
     const round2Messages = mockStreamChat.mock.calls[1][1] as AgentMessage[]
     const toolAssistant = round2Messages.find((message) => message.role === "assistant" && message.tool_calls?.length)
-    expect(toolAssistant).toHaveProperty("reasoning_content", "")
+    expect(toolAssistant).not.toHaveProperty("reasoning_content")
   })
 
   it("passes cacheable system content blocks through to the provider layer", async () => {
@@ -1093,6 +1093,102 @@ describe("AgentRunner", () => {
     expect(injectedToolMessage).not.toContain("已压缩给模型使用")
   })
 
+  it("交付终稿的 finalizesRun 工具执行完就结束,不再让模型复述正文", async () => {
+    const body = "第240章 归零\n\n陈远的手还压在西线地图上。"
+    const tool: Tool = {
+      name: "run_chapter_workflow",
+      description: "workflow",
+      category: "action",
+      permission: "auto",
+      executeTimeoutMs: 0,
+      finalizesRun: true,
+      parameters: {},
+      execute: vi.fn(async (_params, _signal, context) => {
+        context?.onFinalContent?.("旧稿:会被覆盖")
+        context?.onFinalContent?.(body)
+        return `章节工作流完成。\n\n最终正文:\n${body}`
+      }),
+    }
+    registry.register(tool)
+
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      cb.onToolCallDelta?.({ index: 0, id: "workflow_final_1", name: "run_chapter_workflow" })
+      cb.onToolCallDelta?.({ index: 0, arguments: '{"userRequest":"写第240章"}' })
+      cb.onDone()
+    })
+
+    const callbacks = {
+      onText: vi.fn(),
+      onToolCall: vi.fn(),
+      onToolResult: vi.fn(),
+      onToolError: vi.fn(),
+      onFinalContent: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+    const result = await runner.run(
+      {
+        maxRounds: 5,
+        tools: [tool],
+        systemPrompt: "",
+        llmConfig: mockLlmConfig,
+        requiredToolsOnce: ["run_chapter_workflow"],
+      },
+      registry,
+      [systemMsg, userMsg],
+      callbacks,
+      undefined,
+    )
+
+    expect(mockStreamChat).toHaveBeenCalledTimes(1)
+    expect(result.roundsUsed).toBe(1)
+    expect(result.finalText).toBe(body)
+    expect(callbacks.onFinalContent.mock.calls.map((call) => call[0])).toEqual(["旧稿:会被覆盖", body])
+    expect(callbacks.onText).not.toHaveBeenCalled()
+    expect(callbacks.onDone).toHaveBeenCalledOnce()
+    expect(callbacks.onError).not.toHaveBeenCalled()
+  })
+
+  it("finalizesRun 工具报错时不短路,仍交回模型续轮", async () => {
+    const tool: Tool = {
+      name: "run_chapter_workflow",
+      description: "workflow",
+      category: "action",
+      permission: "auto",
+      finalizesRun: true,
+      parameters: {},
+      execute: vi.fn(async (_params, _signal, context) => {
+        context?.onFinalContent?.("半成品正文")
+        throw new Error("计划履约复检未通过")
+      }),
+    }
+    registry.register(tool)
+
+    let callCount = 0
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      callCount += 1
+      if (callCount === 1) {
+        cb.onToolCallDelta?.({ index: 0, id: "workflow_final_2", name: "run_chapter_workflow" })
+        cb.onToolCallDelta?.({ index: 0, arguments: "{}" })
+        cb.onDone()
+        return
+      }
+      cb.onToken("工作流失败了")
+      cb.onDone()
+    })
+
+    const result = await runner.run(
+      { maxRounds: 3, tools: [tool], systemPrompt: "", llmConfig: mockLlmConfig },
+      registry,
+      [systemMsg, userMsg],
+      { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
+      undefined,
+    )
+
+    expect(mockStreamChat).toHaveBeenCalledTimes(2)
+    expect(result.finalText).toBe("工作流失败了")
+  })
+
   it("每轮模型请求保留任务契约并压缩内部工作消息", async () => {
     const untrimmed = [
       { role: "system" as const, content: "系统规则".repeat(120) },

+ 21 - 5
src/lib/agent/runner.ts

@@ -314,11 +314,11 @@ export class AgentRunner {
           toolsEnabled: Boolean(openaiTools),
         })
         if (missingRequired.length > 0) {
-          if (roundText.trim() || roundReasoningContent) {
+          if (roundText.trim() || roundReasoningContent.trim()) {
             workingMessages.push({
               role: "assistant",
               content: roundText || "",
-              reasoning_content: roundReasoningContent,
+              ...(roundReasoningContent.trim() ? { reasoning_content: roundReasoningContent } : {}),
             })
           }
           workingMessages.push({
@@ -343,13 +343,12 @@ export class AgentRunner {
       }
 
       // Add assistant message with tool calls.
-      // DeepSeek/Kimi thinking mode requires reasoning_content on every
-      // tool-call assistant message in subsequent rounds — even "".
+      // 只回传非空思考:DeepSeek thinking 把空串当成没传,会 400。
       const assistantMsg: AgentMessage = {
         role: "assistant",
         content: roundText || "",
         tool_calls: toolCalls,
-        reasoning_content: roundReasoningContent,
+        ...(roundReasoningContent.trim() ? { reasoning_content: roundReasoningContent } : {}),
       }
       logReasoningReplay("agent.round.tool_assistant", {
         round: round + 1,
@@ -361,6 +360,7 @@ export class AgentRunner {
       workingMessages.push(assistantMsg)
 
       // Execute each tool call
+      let deliveredFinalContent = ""
       for (const tc of toolCalls) {
         const toolName = tc.function.name
 
@@ -389,6 +389,13 @@ export class AgentRunner {
         )
         record.toolCalls.push(executed.record)
         await saveToolProgress()
+        if (
+          executed.success &&
+          executed.finalContent?.trim() &&
+          registry.get(toolName)?.finalizesRun
+        ) {
+          deliveredFinalContent = executed.finalContent.trim()
+        }
         workingMessages.push({
           role: "tool",
           content: evidenceLedger.format(toolName, params, executed.responseText),
@@ -397,6 +404,15 @@ export class AgentRunner {
         })
       }
 
+      // 终结型工具已把终稿交付给用户,再让模型复述一遍只会拖时间并可能改坏正文。
+      if (deliveredFinalContent) {
+        finalText = deliveredFinalContent
+        record.finalText = finalText
+        await clearPersistedBreakpoint()
+        callbacks.onDone()
+        return record
+      }
+
       // Continue loop
       if (signal?.aborted) {
         for (const tc of record.toolCalls) {

+ 17 - 1
src/lib/agent/tool-executor.ts

@@ -25,6 +25,8 @@ export interface ExecuteAgentToolResult {
   record: AgentRunRecord["toolCalls"][number]
   responseText: string
   success: boolean
+  /** finalizesRun 工具通过 onFinalContent 交付的终稿,取最后一次交付。 */
+  finalContent?: string
 }
 
 export async function executeAgentTool(
@@ -70,15 +72,24 @@ export async function executeAgentTool(
     return { record, responseText: result, success: false }
   }
 
+  let deliveredFinalContent = ""
+  let acceptFinalContent = true
   const executionContext = {
     callId: call.id,
     toolName: call.name,
     onToolEvent: callbacks.onToolEvent,
     onActivityEvent: callbacks.onActivityEvent,
     onRequestTrace: callbacks.onRequestTrace,
+    onFinalContent: (content: string) => {
+      if (!acceptFinalContent) return
+      deliveredFinalContent = content
+      callbacks.onFinalContent?.(content)
+    },
   }
   const permission = tool.permission ?? (tool.category === "write" ? "confirm" : "auto")
   if (permission === "confirm") {
+    // 预览阶段不是真正执行,即使复用 execute 也不得对外交付终稿。
+    acceptFinalContent = false
     try {
       const previewFn = tool.generatePreview ?? tool.execute
       const preview = await withToolTimeout(
@@ -151,7 +162,12 @@ export async function executeAgentTool(
       result,
       timestamp: record.finishedAt,
     })
-    return { record, responseText: result, success: true }
+    return {
+      record,
+      responseText: result,
+      success: true,
+      ...(deliveredFinalContent ? { finalContent: deliveredFinalContent } : {}),
+    }
   } catch (error) {
     const result = `错误: ${error instanceof Error ? error.message : String(error)}`
     record.status = signal?.aborted ? "cancelled" : "error"

+ 35 - 0
src/lib/agent/tools/run-chapter-workflow.spec.ts

@@ -382,4 +382,39 @@ describe("createRunChapterWorkflowTool", () => {
     expect(result).toContain("执行状态:已返修")
     expect(result).toContain("最终正文")
   })
+
+  it("以终结型工具身份把终稿交付给会话,履约修复后以最后一次为准", async () => {
+    const runDeepChapterGeneration = vi.fn(async (_input, callbacks) => {
+      callbacks.onFinalContent?.("  去AI味后的正文  ")
+      callbacks.onFinalContent?.("履约修复后的正文")
+      callbacks.onFinalContent?.("   ")
+      return {
+        finalContent: "履约修复后的正文",
+        taskBrief: "任务书",
+        draftContent: "初稿",
+        reviewResults: [],
+        revised: true,
+      }
+    })
+    const onFinalContent = vi.fn()
+    const tool = createRunChapterWorkflowTool({
+      projectPath: "E:/Novel",
+      llmConfig,
+      aiWorkflowMode: "strict",
+      runDeepChapterGeneration,
+    })
+
+    expect(tool.finalizesRun).toBe(true)
+
+    await tool.execute(
+      { intent: "write_chapter", userRequest: "生成第240章" },
+      undefined,
+      { callId: "workflow-final", toolName: "run_chapter_workflow", onFinalContent },
+    )
+
+    expect(onFinalContent.mock.calls.map((call) => call[0])).toEqual([
+      "去AI味后的正文",
+      "履约修复后的正文",
+    ])
+  })
 })

+ 9 - 1
src/lib/agent/tools/run-chapter-workflow.ts

@@ -82,11 +82,13 @@ export function createRunChapterWorkflowTool(options: RunChapterWorkflowToolOpti
     description: [
       "运行小说章节写作工作流。用于生成、续写、改写或润色章节。",
       "调用后会读取项目上下文、生成写作任务书、生成正文,并按当前模式执行审稿、返修和去AI味。",
-      "最终返回可直接交付给用户的章节正文;保存到项目文件仍需要写入工具和用户确认。",
+      "正文由本工具直接交付给用户,调用成功后本轮任务即结束:不要复述、改写或补充正文。",
+      "一次调用只处理一章;保存到项目文件仍需要写入工具和用户确认。",
     ].join("\n"),
     category: "action",
     permission: "auto",
     executeTimeoutMs: 0,
+    finalizesRun: true,
     parameters: {
       intent: {
         type: "string",
@@ -145,6 +147,12 @@ export function createRunChapterWorkflowTool(options: RunChapterWorkflowToolOpti
               toolCallId: event.toolCallId ?? parentCallId,
             })
           },
+          // 终稿直接交付给会话,避免外层模型再复述一遍正文。
+          // 履约修复会再次触发,后一次覆盖前一次。
+          onFinalContent: (content) => {
+            const body = content.trim()
+            if (body) context?.onFinalContent?.(body)
+          },
           onRequestTrace: context?.onRequestTrace,
         },
         undefined,

+ 12 - 0
src/lib/agent/types.ts

@@ -20,6 +20,11 @@ export interface ToolExecutionContext {
   onToolEvent?: (event: AgentToolEvent) => void
   onActivityEvent?: (event: AgentActivityEvent) => void
   onRequestTrace?: (trace: LlmRequestCacheTrace) => void
+  /**
+   * 工具直接向用户交付终稿。多次调用按覆盖处理,最后一次为准。
+   * 仅 finalizesRun 工具需要调用。
+   */
+  onFinalContent?: (content: string) => void
 }
 
 export interface Tool {
@@ -29,6 +34,11 @@ export interface Tool {
   permission?: ToolPermission
   /** 0 表示不使用通用工具超时,适用于内部有阶段进度和取消信号的长流程工具。 */
   executeTimeoutMs?: number
+  /**
+   * 该工具经 onFinalContent 自行交付终稿;成功交付后 runner 立即结束本 run,
+   * 不再让模型复述或改写(见 AgentRunner / CodexAppServerRunner 的交付短路)。
+   */
+  finalizesRun?: boolean
   parameters: Record<string, ToolParameter>
   execute(params: Record<string, unknown>, signal?: AbortSignal, context?: ToolExecutionContext): Promise<string>
   generatePreview?: (params: Record<string, unknown>, signal?: AbortSignal, context?: ToolExecutionContext) => Promise<string>
@@ -136,6 +146,8 @@ export interface AgentRunCallbacks {
   onToolError: (callId: string, error: string) => void
   onToolEvent?: (event: AgentToolEvent) => void
   onActivityEvent?: (event: AgentActivityEvent) => void
+  /** finalizesRun 工具交付的终稿,按覆盖处理。 */
+  onFinalContent?: (content: string) => void
   /** Usage for the current/latest provider request. */
   onUsage?: (usage: LlmUsage) => void
   onRequestTrace?: (trace: LlmRequestCacheTrace) => void

+ 43 - 0
src/lib/llm-client.ts

@@ -25,6 +25,12 @@ import {
 } from "./chat-request-budget"
 import { RESPONSE_RESERVE_FRAC, planLlmRequestBudget } from "./context-budget"
 import { mergeLlmUsageSnapshot, type LlmUsage } from "./llm-usage"
+import {
+  hasUnreplayableToolAssistantReasoning,
+  isReasoningDisabled,
+  stripEmptyReasoningContent,
+  withReasoningDisabled,
+} from "./reasoning-retry"
 import { applyGlobalUserMemoryToMessages } from "./user-memory/request-integration"
 import type { UserMemoryDecision } from "./user-memory/decision-trace"
 import {
@@ -267,6 +273,19 @@ export async function streamChat(
       const { max_tokens: _ignored, ...rest } = requestOverrides ?? {}
       return rest
     })()
+  // thinking 开着却回传不了上一轮 tool-assistant 的思考时,空串/缺字段都会 400。
+  // 先关 thinking 再发,比带着 "" 去撞接口更干净。
+  if (
+    !isReasoningDisabled(runtimeConfig, effectiveRequestOverrides) &&
+    hasUnreplayableToolAssistantReasoning(budgetedMessages)
+  ) {
+    logReasoningReplay("request.disable_thinking_unreplayable", {
+      model: runtimeConfig.model,
+      ...summarizeReasoningReplayRisk(budgetedMessages),
+    })
+    effectiveRequestOverrides = withReasoningDisabled(effectiveRequestOverrides)
+    budgetedMessages = stripEmptyReasoningContent(budgetedMessages)
+  }
   const { onToken, onDone, onError } = callbacks
   const decoder = new TextDecoder()
 
@@ -616,6 +635,29 @@ export async function streamChat(
           return
         }
       }
+      if (
+        !httpRetrySucceeded &&
+        isReasoningContentRequiredError(errorDetail) &&
+        !isReasoningDisabled(runtimeConfig, effectiveRequestOverrides)
+      ) {
+        effectiveRequestOverrides = withReasoningDisabled(effectiveRequestOverrides)
+        budgetedMessages = stripEmptyReasoningContent(budgetedMessages)
+        prefixDescriptor = await buildLlmRequestPrefixDescriptor(
+          runtimeConfig,
+          budgetedMessages,
+          effectiveRequestOverrides,
+        )
+        requestInit = buildRequestInit(budgetedMessages, effectiveRequestOverrides)
+        try {
+          response = await sendRequest(requestInit)
+        } catch (err) {
+          onError(err instanceof Error ? err : new Error(String(err)))
+          return
+        }
+        if (response.ok) {
+          httpRetrySucceeded = true
+        }
+      }
       if (
         !httpRetrySucceeded &&
         response.status === 404 &&
@@ -783,6 +825,7 @@ export async function streamChat(
       const REASONING_DIAGNOSTIC_THRESHOLD = 200
       if (
         contentCharsEmitted === 0 &&
+        toolCallDeltaCount === 0 &&
         reasoningCharsObserved >= REASONING_DIAGNOSTIC_THRESHOLD
       ) {
         finishRequestTrace(activeRequestTrace, "error", streamUsage)

+ 129 - 0
src/lib/llm-client.usage.spec.ts

@@ -140,6 +140,135 @@ describe("streamChat usage", () => {
     }))
   })
 
+  it("does not treat reasoning plus tool calls as a reasoning-only failure", async () => {
+    const thinking = "先列出大纲和章节再决定怎么写。".repeat(20)
+    expect(thinking.length).toBeGreaterThan(200)
+    const encoder = new TextEncoder()
+    const body = new ReadableStream<Uint8Array>({
+      start(controller) {
+        controller.enqueue(encoder.encode([
+          `data: {"choices":[{"delta":{"reasoning_content":${JSON.stringify(thinking)}}}]}`,
+          'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"list_outlines","arguments":"{}"}}]}}]}',
+          "data: [DONE]",
+          "",
+        ].join("\n")))
+        controller.close()
+      },
+    })
+    mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
+    const onError = vi.fn()
+    const onToolCallDelta = vi.fn()
+    const onDone = vi.fn()
+
+    await streamChat(config, [{ role: "user", content: "写第45章" }], {
+      onToken: vi.fn(),
+      onToolCallDelta,
+      onDone,
+      onError,
+    })
+
+    expect(onToolCallDelta).toHaveBeenCalled()
+    expect(onDone).toHaveBeenCalledOnce()
+    expect(onError).not.toHaveBeenCalled()
+  })
+
+  it("disables thinking and drops empty reasoning before a tool-follow-up request", async () => {
+    mocks.fetch.mockResolvedValue(new Response([
+      'data: {"choices":[{"delta":{"content":"继续"}}]}',
+      "data: [DONE]",
+      "",
+    ].join("\n"), { status: 200 }))
+    const deepseekConfig: LlmConfig = {
+      ...config,
+      provider: "custom",
+      model: "deepseek/deepseek-v4-flash",
+      customEndpoint: "https://api.deepseek.com/v1",
+      reasoning: { mode: "high" },
+    }
+
+    await streamChat(deepseekConfig, [
+      { role: "user", content: "写第45章" },
+      {
+        role: "assistant",
+        content: "",
+        tool_calls: [{
+          id: "call_1",
+          type: "function",
+          function: { name: "list_outlines", arguments: "{}" },
+        }],
+        reasoning_content: "",
+      },
+      { role: "tool", content: "大纲列表", tool_call_id: "call_1", name: "list_outlines" },
+    ], {
+      onToken: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    })
+
+    const request = mocks.fetch.mock.calls[0][1] as RequestInit
+    const body = JSON.parse(String(request.body)) as {
+      thinking?: { type: string }
+      messages: Array<{ reasoning_content?: string }>
+    }
+    expect(body.thinking).toEqual({ type: "disabled" })
+    expect(body.messages[1]).not.toHaveProperty("reasoning_content")
+  })
+
+  it("retries a reasoning_content 400 once with thinking disabled", async () => {
+    const encoder = new TextEncoder()
+    mocks.fetch
+      .mockResolvedValueOnce(new Response(
+        JSON.stringify({
+          error: {
+            message: "The reasoning_content in the thinking mode must be passed back to the API.",
+            type: "invalid_request_error",
+          },
+        }),
+        { status: 400 },
+      ))
+      .mockResolvedValueOnce(new Response(new ReadableStream<Uint8Array>({
+        start(controller) {
+          controller.enqueue(encoder.encode([
+            'data: {"choices":[{"delta":{"content":"已继续"}}]}',
+            "data: [DONE]",
+            "",
+          ].join("\n")))
+          controller.close()
+        },
+      }), { status: 200 }))
+
+    const deepseekConfig: LlmConfig = {
+      ...config,
+      provider: "custom",
+      model: "deepseek/deepseek-v4-flash",
+      customEndpoint: "https://api.deepseek.com/v1",
+      reasoning: { mode: "high" },
+    }
+    const onToken = vi.fn()
+    const onError = vi.fn()
+
+    await streamChat(deepseekConfig, [
+      { role: "user", content: "写第45章" },
+      {
+        role: "assistant",
+        content: "先读大纲",
+        reasoning_content: "看起来像思考但接口仍拒收",
+      },
+    ], {
+      onToken,
+      onDone: vi.fn(),
+      onError,
+    })
+
+    expect(mocks.fetch).toHaveBeenCalledTimes(2)
+    const retryBody = JSON.parse(String((mocks.fetch.mock.calls[1][1] as RequestInit).body)) as {
+      thinking?: { type: string }
+    }
+    expect(retryBody.thinking).toEqual({ type: "disabled" })
+    expect(onToken).toHaveBeenCalledWith("已继续")
+    expect(onError).not.toHaveBeenCalled()
+  })
+
   it("发送前按 token 预算裁剪并保持系统与当前请求非空", async () => {
     mocks.fetch.mockResolvedValue(new Response([
       'data: {"choices":[{"delta":{"content":"完成"}}]}',

+ 20 - 2
src/lib/llm-providers.spec.ts

@@ -63,7 +63,7 @@ describe("llm provider reasoning options", () => {
     expect(body).not.toHaveProperty("thinking")
   })
 
-  it("replays assistant reasoning_content including empty string", () => {
+  it("omits empty assistant reasoning_content instead of replaying an empty string", () => {
     const body = getProviderConfig(customConfig()).buildBody([
       { role: "user", content: "写第一章" },
       {
@@ -79,7 +79,25 @@ describe("llm provider reasoning options", () => {
       { role: "tool", content: "章节内容", tool_call_id: "call_1", name: "read_chapter" },
     ]) as { messages: Array<{ reasoning_content?: string }> }
 
-    expect(body.messages[1]?.reasoning_content).toBe("")
+    expect(body.messages[1]).not.toHaveProperty("reasoning_content")
+  })
+
+  it("replays non-empty assistant reasoning_content", () => {
+    const body = getProviderConfig(customConfig()).buildBody([
+      { role: "user", content: "写第一章" },
+      {
+        role: "assistant",
+        content: "",
+        tool_calls: [{
+          id: "call_1",
+          type: "function",
+          function: { name: "read_chapter", arguments: "{}" },
+        }],
+        reasoning_content: "先读章节",
+      },
+    ]) as { messages: Array<{ reasoning_content?: string }> }
+
+    expect(body.messages[1]?.reasoning_content).toBe("先读章节")
   })
 
   it("sends reasoning_effort for explicit custom OpenAI-compatible reasoning mode", () => {

+ 1 - 1
src/lib/llm-providers.ts

@@ -519,7 +519,7 @@ function buildOpenAiBody(
     ...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
     ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
     ...(m.name ? { name: m.name } : {}),
-    ...(m.reasoning_content !== undefined ? { reasoning_content: m.reasoning_content } : {}),
+    ...(m.reasoning_content?.trim() ? { reasoning_content: m.reasoning_content } : {}),
   }))
   const body: Record<string, unknown> = { messages: translated, stream: true, ...stripWireAgnosticOverrides(overrides) }
   if (overrides?.tools && overrides.tools.length > 0) {

+ 48 - 0
src/lib/novel/chapter-content-cleanup.spec.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from "vitest"
 
 import {
+  cleanGeneratedChapterContentForDisplay,
   cleanGeneratedChapterContentForSave,
   cleanGeneratedChapterContentWithTitle,
   isPlausibleChapterTitleLine,
@@ -59,4 +60,51 @@ describe("cleanGeneratedChapterContentForSave", () => {
   ])("保存时移除章节标题:%s", (content) => {
     expect(cleanGeneratedChapterContentForSave(content)).toBe("正文内容。")
   })
+
+  it.each([
+    "正文:\n\n雨落在旧宅门前。",
+    "正文:\n雨落在旧宅门前。",
+    "【正文】\n\n雨落在旧宅门前。",
+    "**正文**\n\n雨落在旧宅门前。",
+    "以下是正文:\n\n雨落在旧宅门前。",
+    "以下为本章正文\n\n雨落在旧宅门前。",
+    "正文如下:\n\n雨落在旧宅门前。",
+  ])("剥掉开头的正文标签:%s", (content) => {
+    expect(cleanGeneratedChapterContentForSave(content)).toBe("雨落在旧宅门前。")
+  })
+
+  it("标签挡在章节标题前时仍能提取标题", () => {
+    expect(cleanGeneratedChapterContentWithTitle("正文:\n第12章 夜雨归人\n\n雨落在旧宅门前。"))
+      .toEqual({
+        title: "第12章 夜雨归人",
+        content: "雨落在旧宅门前。",
+      })
+  })
+
+  it("不误删以「正文」开头的叙述句", () => {
+    expect(cleanGeneratedChapterContentForSave("正文里写着他的名字。\n\n他合上书。"))
+      .toBe("正文里写着他的名字。\n\n他合上书。")
+  })
+})
+
+describe("cleanGeneratedChapterContentForDisplay", () => {
+  it("保留章节标题行的 Markdown 形态并剥掉正文标签", () => {
+    expect(cleanGeneratedChapterContentForDisplay("正文:\n# 第12章 夜雨归人\n\n雨落在旧宅门前。"))
+      .toBe("# 第12章 夜雨归人\n\n雨落在旧宅门前。")
+  })
+
+  it("标题原本是纯文本时不添加 Markdown 标记", () => {
+    expect(cleanGeneratedChapterContentForDisplay("第12章 夜雨归人\n\n雨落在旧宅门前。"))
+      .toBe("第12章 夜雨归人\n\n雨落在旧宅门前。")
+  })
+
+  it("不裁剪正文结尾的对白,避免把台词当成助手话术", () => {
+    const body = "他抬起头。\n\n「如果你愿意,我也可以继续等下一章的答案。」\n\n门外脚步声停了。"
+    expect(cleanGeneratedChapterContentForDisplay(body)).toBe(body)
+    expect(cleanGeneratedChapterContentForSave(body)).toBe("他抬起头。")
+  })
+
+  it("清洗后为空时退回原文,避免出现空气泡", () => {
+    expect(cleanGeneratedChapterContentForDisplay("正文:")).toBe("正文:")
+  })
 })

+ 60 - 10
src/lib/novel/chapter-content-cleanup.ts

@@ -76,6 +76,27 @@ function extractLeadingTitle(lines: string[]): { lines: string[]; title: string
   return { lines, title: null }
 }
 
+/**
+ * 独占一行的正文标签,如「正文:」「以下是本章正文」「【正文】」「**正文**」。
+ * 必须整行匹配,避免误删以「正文」开头的叙述句。
+ */
+const LEADING_BODY_LABEL_RE =
+  /^(?:#{1,6}\s*)?(?:\*\*|__)?\s*[【[]?\s*(?:以下(?:是|为)\s*)?(?:本章)?正文(?:如下)?\s*[\]】]?\s*[::]?\s*(?:\*\*|__)?$/
+
+/** 剥掉开头的正文标签行(含其后空行)。 */
+function stripLeadingBodyLabel(lines: string[]): string[] {
+  let index = 0
+
+  while (index < lines.length && !lines[index].trim()) index += 1
+
+  while (index < lines.length && LEADING_BODY_LABEL_RE.test(lines[index].trim())) {
+    index += 1
+    while (index < lines.length && !lines[index].trim()) index += 1
+  }
+
+  return index > 0 ? lines.slice(index) : lines
+}
+
 function stripLeadingMeta(lines: string[]): string[] {
   let index = 0
 
@@ -99,7 +120,8 @@ function stripLeadingMeta(lines: string[]): string[] {
     index += 1
   }
 
-  return lines.slice(index)
+  // 标签也可能出现在章节标题之后,例如「第240章 归零 / 正文:」。
+  return stripLeadingBodyLabel(lines.slice(index))
 }
 
 function stripTrailingAssistantOffer(lines: string[]): string[] {
@@ -123,18 +145,19 @@ export interface CleanedChapterContent {
   title: string | null
 }
 
-/**
- * 清理生成的章节内容,同时提取标题。
- * 返回对象包含:
- * - content: 清理后的纯正文(移除已提取的标题行)
- * - title: 提取到的标题文字(如 "第3章 初入江湖"),如果没有则为 null
- */
-export function cleanGeneratedChapterContentWithTitle(content: string): CleanedChapterContent {
+function cleanChapterContentCore(
+  content: string,
+  options: { dropTrailingOffer: boolean },
+): CleanedChapterContent {
   const withoutThinking = stripThinkingBlocks(content).replace(/\r\n?/g, "\n")
   const withoutCitations = stripCitationSyntax(withoutThinking)
-  const allLines = withoutCitations.split("\n")
+  // 标签先剥,否则「正文:」挡在前面会让章节标题识别不到。
+  const allLines = stripLeadingBodyLabel(withoutCitations.split("\n"))
   const { lines: linesWithoutTitle, title } = extractLeadingTitle(allLines)
-  const cleanedLines = stripTrailingAssistantOffer(stripLeadingMeta(linesWithoutTitle))
+  const strippedLines = stripLeadingMeta(linesWithoutTitle)
+  const cleanedLines = options.dropTrailingOffer
+    ? stripTrailingAssistantOffer(strippedLines)
+    : strippedLines
 
   const cleanedContent = cleanedLines
     .join("\n")
@@ -159,6 +182,33 @@ export function cleanGeneratedChapterContentWithTitle(content: string): CleanedC
   }
 }
 
+/**
+ * 清理生成的章节内容,同时提取标题。
+ * 返回对象包含:
+ * - content: 清理后的纯正文(移除已提取的标题行)
+ * - title: 提取到的标题文字(如 "第3章 初入江湖"),如果没有则为 null
+ */
+export function cleanGeneratedChapterContentWithTitle(content: string): CleanedChapterContent {
+  return cleanChapterContentCore(content, { dropTrailingOffer: true })
+}
+
+/**
+ * 清理章节正文用于会话内展示:与保存共用同一套剥离规则,但
+ * - 保留开头的章节标题行(保存路径会把标题拆成独立字段);
+ * - 不做结尾「要不要我继续」裁剪,避免正文里的对白被当成助手话术截断。
+ */
+export function cleanGeneratedChapterContentForDisplay(content: string): string {
+  const { content: body, title } = cleanChapterContentCore(content, { dropTrailingOffer: false })
+  if (!body.trim()) return content.trim()
+  if (!title) return body
+  // 章节草稿要求首行是「# 第X章 标题」,按原样保留标题行的 Markdown 形态。
+  const originalTitleLine = content
+    .split("\n")
+    .map((line) => line.trim())
+    .find((line) => line.replace(/^#{1,6}\s*/, "") === title)
+  return `${originalTitleLine ?? title}\n\n${body}`
+}
+
 /**
  * 清理生成的章节内容用于保存。
  * 保持向后兼容:返回纯字符串(去掉标题行)。

+ 59 - 0
src/lib/reasoning-retry.spec.ts

@@ -0,0 +1,59 @@
+import { describe, expect, it } from "vitest"
+import type { ChatMessage } from "./llm-providers"
+import {
+  hasUnreplayableToolAssistantReasoning,
+  isReasoningDisabled,
+  stripEmptyReasoningContent,
+  withReasoningDisabled,
+} from "./reasoning-retry"
+
+const toolAssistant = (reasoning?: string): ChatMessage => ({
+  role: "assistant",
+  content: "",
+  tool_calls: [{
+    id: "call_1",
+    type: "function",
+    function: { name: "list_outlines", arguments: "{}" },
+  }],
+  ...(reasoning !== undefined ? { reasoning_content: reasoning } : {}),
+})
+
+describe("reasoning-retry", () => {
+  it("treats missing or empty tool-assistant reasoning as unreplayable", () => {
+    expect(hasUnreplayableToolAssistantReasoning([
+      { role: "user", content: "写第45章" },
+      toolAssistant(),
+    ])).toBe(true)
+    expect(hasUnreplayableToolAssistantReasoning([
+      { role: "user", content: "写第45章" },
+      toolAssistant(""),
+    ])).toBe(true)
+    expect(hasUnreplayableToolAssistantReasoning([
+      { role: "user", content: "写第45章" },
+      toolAssistant("   "),
+    ])).toBe(true)
+    expect(hasUnreplayableToolAssistantReasoning([
+      { role: "user", content: "写第45章" },
+      toolAssistant("先列大纲"),
+    ])).toBe(false)
+    expect(hasUnreplayableToolAssistantReasoning([
+      { role: "user", content: "写第45章" },
+      { role: "assistant", content: "直接写" },
+    ])).toBe(false)
+  })
+
+  it("strips empty reasoning_content and keeps real thinking", () => {
+    const stripped = stripEmptyReasoningContent([
+      toolAssistant(""),
+      toolAssistant("先列大纲"),
+    ])
+    expect(stripped[0]).not.toHaveProperty("reasoning_content")
+    expect(stripped[1]?.reasoning_content).toBe("先列大纲")
+  })
+
+  it("withReasoningDisabled marks the next request as thinking off", () => {
+    expect(isReasoningDisabled({ reasoning: { mode: "high" } })).toBe(false)
+    const overrides = withReasoningDisabled({ reasoning: { mode: "high" } })
+    expect(isReasoningDisabled({ reasoning: { mode: "high" } }, overrides)).toBe(true)
+  })
+})

+ 20 - 1
src/lib/reasoning-retry.ts

@@ -1,8 +1,27 @@
 import type { LlmConfig, ReasoningConfig } from "@/stores/wiki-store"
-import type { RequestOverrides } from "./llm-providers"
+import type { ChatMessage, RequestOverrides } from "./llm-providers"
 
 const REASONING_ONLY_RESPONSE_RE = /模型只输出了[\s\S]*思考内容[\s\S]*没有输出正文/
 
+function isToolAssistant(message: ChatMessage): boolean {
+  return message.role === "assistant" && (message.tool_calls?.length ?? 0) > 0
+}
+
+/** DeepSeek thinking 把空串当成没回传;缺字段或空字段都无法续轮。 */
+export function hasUnreplayableToolAssistantReasoning(messages: ChatMessage[]): boolean {
+  return messages.some((message) => isToolAssistant(message) && !message.reasoning_content?.trim())
+}
+
+export function stripEmptyReasoningContent(messages: ChatMessage[]): ChatMessage[] {
+  return messages.map((message) => {
+    if (message.reasoning_content === undefined || message.reasoning_content.trim()) {
+      return message
+    }
+    const { reasoning_content: _dropped, ...rest } = message
+    return rest
+  })
+}
+
 export function isReasoningOnlyResponseError(error: Error): boolean {
   return REASONING_ONLY_RESPONSE_RE.test(error.message)
 }