Переглянути джерело

fix(agent): 修复必调工作流兜底与思考泄露

为章节必调工具增加确定性兜底、履约诊断与失败强制重试。

完整解析流式 tool_calls,并过滤 Gemini 去 AI 味结果中的思考摘要。
darknessomi 1 місяць тому
батько
коміт
1d586f4870

+ 24 - 4
src/components/chat/chat-panel.spec.tsx

@@ -96,6 +96,25 @@ describe("chat-panel agent reference integration", () => {
     expect(source).toContain("finalContentDelivered")
   })
 
+  it("offers a deterministic workflow retry and reuses the failed assistant bubble", () => {
+    expect(source).toContain('label: "强制重试"')
+    expect(source).toContain("forceRequiredToolsImmediately: true")
+    expect(source).toContain("retryAssistantMessageId: assistantMessage.id")
+    expect(source).toContain("workflowMode: sessionWorkflowMode")
+    expect(source).toContain("planExecuteActive,")
+    expect(source).toContain("resetAssistantMessageForRequiredToolRetry")
+    expect(source).toContain("message.id !== sendOptions?.retryAssistantMessageId")
+    expect(source).toContain("message.id !== retryUserMessageId")
+    expect(source).toContain("if (!targetConversationId && !retryAssistantMessage)")
+  })
+
+  it("rejects duplicate or stale workflow retries without appending messages", () => {
+    expect(source).toContain("原失败消息已失效,无法强制重试")
+    expect(source).toContain("该会话正在强制重试,请勿重复点击")
+    expect(source).toContain("required-workflow-retry-running")
+    expect(source).toContain("startConversationRun(capturedConvId, runId)")
+  })
+
   it("uses three AI workflow modes instead of a single deep mode prompt", () => {
     expect(source).toContain("aiWorkflowMode")
     expect(source).toContain("setAiWorkflowMode")
@@ -172,7 +191,8 @@ describe("chat-panel agent reference integration", () => {
   })
 
   it("injects requiredToolsOnce for non-fast chapter writing via resolveRequiredToolsOnce", () => {
-    expect(source).toContain('import { resolveRequiredToolsOnce } from "@/lib/agent/required-tools-gate"')
+    expect(source).toContain('} from "@/lib/agent/required-tools-gate"')
+    expect(source).toContain("resolveRequiredToolsOnce,")
     expect(source).toContain("resolveRequiredToolsOnce({")
     expect(source).toContain("...(requiredToolsOnce ? { requiredToolsOnce } : {})")
     expect(source).toContain("必须调用 run_chapter_workflow 工具;未调用前禁止输出章节终稿正文。")
@@ -240,7 +260,7 @@ describe("chat-panel agent reference integration", () => {
       (planExecuteActiveMatch?.index ?? 0),
       (planExecuteActiveMatch?.index ?? 0) + 200,
     )
-    expect(planExecuteActiveLine).toContain("aiWorkflowMode !== \"fast\"")
+    expect(planExecuteActiveLine).toContain("sessionWorkflowMode !== \"fast\"")
     expect(planExecuteActiveLine).toContain("planExecuteEnabled")
     expect(planExecuteActiveLine).toContain("planExecutionFollowup")
   })
@@ -439,7 +459,7 @@ describe("chat-panel chapter plan confirm integration (Stage C)", () => {
 
   it("disables Plan Execute protocol for confirmed plan follow-up messages and fast mode", () => {
     expect(source).toContain("isChapterPlanExecutionFollowup")
-    expect(source).toContain("aiWorkflowMode !== \"fast\" && planExecuteEnabled && !planExecutionFollowup")
+    expect(source).toContain("sessionWorkflowMode !== \"fast\" && planExecuteEnabled && !planExecutionFollowup")
     expect(source).toContain("planExecuteEnabled: planExecuteActive")
   })
 
@@ -521,7 +541,7 @@ describe("chat-panel post-write check integration (Stage D)", () => {
   it("places Stage D after result protocol and before finishTrace", () => {
     const traceBlockIndex = source.indexOf("if (contextTrace && effectiveTaskRoute) {")
     expect(traceBlockIndex).toBeGreaterThan(-1)
-    const traceBlock = source.slice(traceBlockIndex, traceBlockIndex + 5000)
+    const traceBlock = source.slice(traceBlockIndex, traceBlockIndex + 9000)
     const protocolIndex = traceBlock.indexOf("buildResultProtocolTrace")
     const stageDIndex = traceBlock.indexOf("=== Stage D: 写后剧情自检 ===")
     const finishIndex = traceBlock.indexOf('finishTrace(contextTrace, "done")')

+ 200 - 41
src/components/chat/chat-panel.tsx

@@ -39,7 +39,10 @@ import {
 } from "@/lib/reference/providers"
 import type { ReferenceToken } from "@/lib/reference/types"
 import { runAiChatSession } from "@/lib/agent/ai-chat-session"
-import { resolveRequiredToolsOnce } from "@/lib/agent/required-tools-gate"
+import {
+  RequiredToolFallbackError,
+  resolveRequiredToolsOnce,
+} from "@/lib/agent/required-tools-gate"
 import { runDraftReviewSkill } from "@/lib/agent/skills/draft-review-skill"
 import { useDraftReviewStore } from "@/stores/draft-review-store"
 import { ToolRegistry } from "@/lib/agent/registry"
@@ -425,6 +428,53 @@ const SIMULATION_INTENTS = new Set([
   "character_interview",
 ])
 
+interface ChatSendOptions {
+  /** Internal recovery path for a failed mandatory chapter workflow. */
+  forceRequiredToolsImmediately?: boolean
+  /** Existing assistant bubble to reset and reuse instead of appending messages. */
+  retryAssistantMessageId?: string
+  /** Preserve the execution policy selected for the failed request. */
+  workflowMode?: AiWorkflowMode
+  planExecuteActive?: boolean
+}
+
+type ChatSend = (
+  text: string,
+  tokens?: ReferenceToken[],
+  displayText?: string,
+  planBlueprint?: string,
+  targetConversationId?: string,
+  options?: ChatSendOptions,
+) => Promise<void>
+
+function isRequiredChapterWorkflowFailure(error: Error | null): boolean {
+  if (!error) return false
+  if (error instanceof RequiredToolFallbackError) {
+    return error.toolName === "run_chapter_workflow"
+  }
+  // Preserve retryability when an Error crosses a serialization boundary.
+  return (
+    error.name === "RequiredToolFallbackError"
+    && error.message.includes("run_chapter_workflow")
+  )
+}
+
+function resetAssistantMessageForRequiredToolRetry(message: DisplayMessage): DisplayMessage {
+  return {
+    ...message,
+    content: "",
+    reasoning_content: "",
+    agentToolCalls: [],
+    agentStages: [],
+    isAgentRunning: true,
+    discarded: false,
+    references: undefined,
+    contextTrace: undefined,
+    contextHubSnapshot: undefined,
+    chapterRef: undefined,
+  }
+}
+
 function appendAgentChatMessages(conversationId: string, content: string, tokens: ReferenceToken[]) {
   const now = Date.now()
   const userMessage: DisplayMessage = {
@@ -1134,7 +1184,7 @@ export function ChatPanel() {
   }>>({})
   const pendingChapterPlan = activeConversationId ? pendingChapterPlans[activeConversationId] : undefined
   const chapterPlanResolversRef = useRef<Record<string, (action: "confirm" | "skip" | "cancel" | { modify: string }) => void>>({})
-  const handleSendRef = useRef<(text: string, tokens?: ReferenceToken[], displayText?: string, planBlueprint?: string, targetConversationId?: string) => Promise<void>>(() => Promise.resolve())
+  const handleSendRef = useRef<ChatSend>(() => Promise.resolve())
   const lastWritingTaskRouteRef = useRef<Record<string, TaskRouteResult>>({})
 
   const closeChapterPlanDialog = useCallback(
@@ -1369,12 +1419,21 @@ export function ChatPanel() {
   // 切换会话时不再中断后台生成——每个会话独立运行
 
   const handleSend = useCallback(
-    async (text: string, tokens: ReferenceToken[] = [], displayText?: string, planBlueprint?: string, targetConversationId?: string) => {
+    async (
+      text: string,
+      tokens: ReferenceToken[] = [],
+      displayText?: string,
+      planBlueprint?: string,
+      targetConversationId?: string,
+      sendOptions?: ChatSendOptions,
+    ) => {
       const plainText = text.trim()
       const userVisibleText = (displayText ?? plainText).trim()
       const planExecutionFollowup = isChapterPlanExecutionFollowup(plainText)
-      const planExecuteActive =
-        aiWorkflowMode !== "fast" && planExecuteEnabled && !planExecutionFollowup
+      const sessionWorkflowMode = sendOptions?.workflowMode ?? aiWorkflowMode
+      const planExecuteActive = sendOptions?.planExecuteActive ?? (
+        sessionWorkflowMode !== "fast" && planExecuteEnabled && !planExecutionFollowup
+      )
       setDeAiSkillWarningMessage("")
       // 新一轮对话清空上一轮的章节保存提示,避免「已保存为第X章」残留在新消息下方
       setChapterSaveStatus("")
@@ -1403,10 +1462,45 @@ export function ChatPanel() {
       const capturedConvId = convId
       const storeState = useChatStore.getState()
       const activeConv = storeState.conversations.find((conversation) => conversation.id === capturedConvId)
+      if (
+        sendOptions?.retryAssistantMessageId
+        && storeState.runStates[capturedConvId]?.status === "running"
+      ) {
+        toast.info("该会话正在强制重试,请勿重复点击", {
+          dedupeKey: `required-workflow-retry-running:${capturedConvId}`,
+        })
+        return
+      }
+      const retryAssistantIndex = sendOptions?.retryAssistantMessageId
+        ? storeState.messages.findIndex((message) => message.id === sendOptions.retryAssistantMessageId)
+        : -1
+      const retryAssistantCandidate = retryAssistantIndex >= 0
+        ? storeState.messages[retryAssistantIndex]
+        : undefined
+      const retryAssistantMessage = retryAssistantCandidate
+        && retryAssistantCandidate.conversationId === capturedConvId
+        && retryAssistantCandidate.role === "assistant"
+        && !retryAssistantCandidate.isAgentRunning
+        && retryAssistantCandidate.content.includes("出错:")
+        ? retryAssistantCandidate
+        : undefined
+      const retryUserMessageId = retryAssistantMessage
+        ? [...storeState.messages.slice(0, retryAssistantIndex)].reverse().find((message) => (
+            message.conversationId === capturedConvId && message.role === "user"
+          ))?.id
+        : undefined
+      if (sendOptions?.retryAssistantMessageId && !retryAssistantMessage) {
+        toast.info("原失败消息已失效,无法强制重试", {
+          dedupeKey: `required-workflow-retry-stale:${capturedConvId}:${sendOptions.retryAssistantMessageId}`,
+        })
+        return
+      }
       const activeConvMessages = storeState.messages
         .filter((message) => (
           message.conversationId === capturedConvId &&
           (message.role === "user" || message.role === "assistant") &&
+          message.id !== sendOptions?.retryAssistantMessageId &&
+          message.id !== retryUserMessageId &&
           !message.discarded &&
           !message.isAgentRunning
         ))
@@ -1454,6 +1548,11 @@ export function ChatPanel() {
       const runId = crypto.randomUUID()
       if (!useChatStore.getState().startConversationRun(capturedConvId, runId)) {
         setDeAiSkillWarningMessage(concurrencyLimitReason)
+        if (sendOptions?.retryAssistantMessageId) {
+          toast.info(concurrencyLimitReason, {
+            dedupeKey: `required-workflow-retry-running:${capturedConvId}`,
+          })
+        }
         return
       }
 
@@ -1465,8 +1564,13 @@ export function ChatPanel() {
           )
         : undefined
 
-      const { assistantMessage } = appendAgentChatMessages(capturedConvId, userVisibleText || plainText, tokens)
-      if (!targetConversationId) {
+      const assistantMessage = retryAssistantMessage
+        ? resetAssistantMessageForRequiredToolRetry(retryAssistantMessage)
+        : appendAgentChatMessages(capturedConvId, userVisibleText || plainText, tokens).assistantMessage
+      if (retryAssistantMessage) {
+        updateAgentAssistantMessage(retryAssistantMessage.id, () => assistantMessage)
+      }
+      if (!targetConversationId && !retryAssistantMessage) {
         setConversationInputDraft(capturedConvId, "")
         setFallbackReferenceText("")
         setReferenceTokensByConversation((drafts) =>
@@ -1491,7 +1595,7 @@ export function ChatPanel() {
       let taskDirective = ""
       let goldenDirective = ""
       let prePluginResult: PrePluginChainResult | null = null
-      const shouldRunNovelPrePluginChain = novelMode && (aiWorkflowMode !== "fast" || planExecuteActive)
+      const shouldRunNovelPrePluginChain = novelMode && (sessionWorkflowMode !== "fast" || planExecuteActive)
       const explicitSkills = collectExplicitSkills(
         availableAgentSkills,
         plainText,
@@ -1503,6 +1607,7 @@ export function ChatPanel() {
       void shouldRunNovelPrePluginChain
       let hasAgentError = false
       let lastAgentError = "生成失败"
+      let lastAgentErrorObject: Error | null = null
       let accumulatedReasoningContent = ""
       // 终结型工具(run_chapter_workflow)是否已直接交付正文。
       let finalContentDelivered = false
@@ -1541,6 +1646,7 @@ export function ChatPanel() {
       const markError = (error: Error) => {
         hasAgentError = true
         lastAgentError = error.message || "生成失败"
+        lastAgentErrorObject = error
         updateAgentAssistantMessage(assistantMessage.id, (message) => {
           const settledTools = settleRunningAgentToolCalls(message.agentToolCalls, "error")
           const rawContent = message.content ?? ""
@@ -1562,6 +1668,35 @@ export function ChatPanel() {
         })
       }
 
+      const showRunErrorToast = (error: Error) => {
+        const action = isRequiredChapterWorkflowFailure(error)
+          ? {
+              label: "强制重试",
+              onClick: () => {
+                void handleSendRef.current(
+                  plainText,
+                  tokens,
+                  displayText,
+                  planBlueprint,
+                  capturedConvId,
+                  {
+                    forceRequiredToolsImmediately: true,
+                    retryAssistantMessageId: assistantMessage.id,
+                    workflowMode: sessionWorkflowMode,
+                    planExecuteActive,
+                  },
+                )
+              },
+            }
+          : undefined
+        toast.error(error.message || "生成失败", {
+          title: "AI 会话生成失败",
+          persistent: true,
+          dedupeKey: `chat-run-failed:${capturedConvId}:${error.message || "生成失败"}`,
+          ...(action ? { action } : {}),
+        })
+      }
+
       const finishAgentSession = (callback?: () => void) => {
         streamSessionGuardRef.current.finish(capturedConvId, sessionId, () => {
           callback?.()
@@ -1599,7 +1734,7 @@ export function ChatPanel() {
         novelMode,
         mode,
         chatEditModeEnabled,
-        aiWorkflowMode,
+        aiWorkflowMode: sessionWorkflowMode,
         planExecuteEnabled: planExecuteActive,
         projectName: project?.name,
         bindingTitle: activeBinding?.framework.title,
@@ -1607,7 +1742,7 @@ export function ChatPanel() {
         // pre-plugin 会注入找纲协议;仅在不会跑 pre-plugin 的章节写作路径由这里注入一次
         includeOutlineFindProtocol:
           shouldIncludeOutlineFindProtocol(effectiveTaskRoute?.intent) &&
-          !(novelMode && (aiWorkflowMode !== "fast" || planExecuteActive)),
+          !(novelMode && (sessionWorkflowMode !== "fast" || planExecuteActive)),
       })
 
       if (novelMode && effectiveTaskRoute) {
@@ -1660,7 +1795,7 @@ export function ChatPanel() {
               novelMode,
               taskRoute: effectiveTaskRoute,
               effectiveTaskRoute,
-              aiWorkflowMode,
+              aiWorkflowMode: sessionWorkflowMode,
               planExecuteEnabled: planExecuteActive,
               availableSkills: availableAgentSkills,
               selectedSkills: explicitSkills,
@@ -1695,7 +1830,7 @@ export function ChatPanel() {
           stageId: "task_understanding",
           kind: "analysis",
           title: "当前执行路线",
-          content: buildWorkflowRouteActivityContent(aiWorkflowMode, planExecuteActive, effectiveTaskRoute),
+          content: buildWorkflowRouteActivityContent(sessionWorkflowMode, planExecuteActive, effectiveTaskRoute),
           timestamp: now,
         })
         const skillEvent = createAgentActivityEvent({
@@ -1869,26 +2004,29 @@ export function ChatPanel() {
           ],
         })
       }
-      if (planBlueprint) {
-        const workflowTool = agentRegistry.get("run_chapter_workflow")
-        if (workflowTool) {
-          sessionRegistry.register({
-            ...workflowTool,
-            execute: (params, signal, context) => workflowTool.execute({
-              ...params,
-              planBlueprint: typeof params.planBlueprint === "string" && params.planBlueprint.trim()
-                ? params.planBlueprint
-                : planBlueprint,
-            }, signal, context),
-          })
-        }
+      const workflowTool = agentRegistry.get("run_chapter_workflow")
+      if (workflowTool) {
+        sessionRegistry.register({
+          ...workflowTool,
+          execute: (params, signal, context) => workflowTool.execute({
+            ...params,
+            workflowMode: sessionWorkflowMode,
+            ...(planBlueprint
+              ? {
+                  planBlueprint: typeof params.planBlueprint === "string" && params.planBlueprint.trim()
+                    ? params.planBlueprint
+                    : planBlueprint,
+                }
+              : {}),
+          }, signal, context),
+        })
       }
 
       try {
         const requiredToolsOnce = resolveRequiredToolsOnce({
           novelMode,
           intent: effectiveTaskRoute?.intent,
-          mode: aiWorkflowMode,
+          mode: sessionWorkflowMode,
           planExecuteActive,
           enabledToolNames: prePluginResult?.enabledToolNames,
         })
@@ -1931,6 +2069,9 @@ export function ChatPanel() {
             projectPath,
             taskGoal: plainText,
             ...(requiredToolsOnce ? { requiredToolsOnce } : {}),
+            ...(sendOptions?.forceRequiredToolsImmediately
+              ? { forceRequiredToolsImmediately: true }
+              : {}),
             requestOverrides: {
               ...agentConfig.requestOverrides,
               userMemorySurface: "ai-chat",
@@ -2068,14 +2209,36 @@ export function ChatPanel() {
             || record.finalText
             || ""
           : ""
+        if (contextTrace && effectiveTaskRoute && record.requiredToolDiagnostics) {
+          const diagnosticTraceInfo = buildInitialContextTraceInfo(effectiveTaskRoute, prePluginResult, {
+            workflowMode: sessionWorkflowMode,
+            contextHub: contextHubResult?.stats,
+          })
+          contextTrace = setContextInfo(contextTrace, {
+            ...diagnosticTraceInfo,
+            requiredToolDiagnostics: record.requiredToolDiagnostics,
+          })
+        }
+        if (hasAgentError && contextTrace) {
+          contextTrace = finishTrace(contextTrace, "error", lastAgentError)
+          updateAgentAssistantMessage(assistantMessage.id, (message) => ({
+            ...message,
+            contextTrace,
+          }))
+        }
         finishAgentSession(() => {
           if (!hasAgentError) {
             if (contextTrace && effectiveTaskRoute) {
               const traceInfo = buildInitialContextTraceInfo(effectiveTaskRoute, prePluginResult, {
-                workflowMode: aiWorkflowMode,
+                workflowMode: sessionWorkflowMode,
                 contextHub: contextHubResult?.stats,
               })
-              contextTrace = setContextInfo(contextTrace, traceInfo)
+              contextTrace = setContextInfo(contextTrace, {
+                ...traceInfo,
+                ...(record.requiredToolDiagnostics
+                  ? { requiredToolDiagnostics: record.requiredToolDiagnostics }
+                  : {}),
+              })
               const storeStateForValidation = useChatStore.getState()
               const lastAssistantForValidation = storeStateForValidation.messages.find(
                 (m) => m.id === assistantMessage.id && m.role === "assistant",
@@ -2098,7 +2261,10 @@ export function ChatPanel() {
               if (finalContent) {
                 const protocolTrace = buildResultProtocolTrace("chapter", finalContent)
                 chapterProtocolValid = protocolTrace.valid
-                contextTrace = setContextInfo(contextTrace, { ...traceInfo, resultProtocol: protocolTrace })
+                contextTrace = setContextInfo(contextTrace, {
+                  ...contextTrace.contextInfo!,
+                  resultProtocol: protocolTrace,
+                })
               }
               // === Stage D: 写后剧情自检 ===
               // 仅对 write_chapter / continue_chapter 任务触发,避免对普通对话误触发
@@ -2197,11 +2363,7 @@ export function ChatPanel() {
         }
         if (hasAgentError) {
           useChatStore.getState().failConversationRun(capturedConvId, lastAgentError, runId)
-          toast.error(lastAgentError, {
-            title: "AI 会话生成失败",
-            persistent: true,
-            dedupeKey: `chat-run-failed:${capturedConvId}:${lastAgentError}`,
-          })
+          showRunErrorToast(lastAgentErrorObject ?? new Error(lastAgentError))
         } else {
           useChatStore.getState().finishConversationRun(
             capturedConvId,
@@ -2273,7 +2435,8 @@ export function ChatPanel() {
       } catch (error) {
         if (controller.signal.aborted) return
         if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
-        const errorMessage = error instanceof Error ? error.message : String(error)
+        const resolvedError = error instanceof Error ? error : new Error(String(error))
+        const errorMessage = resolvedError.message
         const partialContent = useChatStore.getState().streamingContents[capturedConvId] ?? ""
         finishAgentSession(() => {
           if (partialContent) {
@@ -2284,14 +2447,10 @@ export function ChatPanel() {
             }))
           }
           if (contextTrace) contextTrace = finishTrace(contextTrace, "error", errorMessage)
-          markError(error instanceof Error ? error : new Error(String(error)))
+          markError(resolvedError)
         })
         useChatStore.getState().failConversationRun(capturedConvId, errorMessage, runId)
-        toast.error(errorMessage, {
-          title: "AI 会话生成失败",
-          persistent: true,
-          dedupeKey: `chat-run-failed:${capturedConvId}:${errorMessage}`,
-        })
+        showRunErrorToast(resolvedError)
       }
     },
     [

+ 47 - 0
src/components/chat/context-trace-panel.spec.tsx

@@ -5,6 +5,53 @@ import type { ContextTrace } from "@/lib/agent/context-trace"
 import type { ContextHubSnapshotRef } from "@/lib/context-hub/types"
 
 describe("ContextTracePanel selected skills", () => {
+  it("renders provider, model, finish reason, all tool calls and fallback status", () => {
+    const trace: ContextTrace = {
+      id: "trace-required-workflow",
+      startedAt: 1,
+      finishedAt: 5,
+      status: "error",
+      toolCalls: [],
+      contextInfo: {
+        intent: "write_chapter",
+        confidence: 1,
+        routeSource: "default",
+        loadedSources: [],
+        blockedSources: [],
+        retrievalHits: [],
+        trimmedSections: [],
+        requiredToolDiagnostics: {
+          requiredTools: ["run_chapter_workflow"],
+          satisfiedTools: [],
+          missingTools: ["run_chapter_workflow"],
+          fallbackAttempted: true,
+          fallbackTool: "run_chapter_workflow",
+          fallbackStatus: "error",
+          fallbackError: "正文为空",
+          provider: "custom",
+          model: "deepseek-chat",
+          reasoningMode: "enabled",
+          roundsUsed: 2,
+          finishReasons: ["tool_calls", "stop"],
+          observedToolCalls: [
+            { round: 1, index: 0, name: "read_outline" },
+            { round: 1, index: 1, name: "run_chapter_workflow" },
+          ],
+        },
+      },
+    }
+
+    const html = renderToStaticMarkup(<ContextTracePanel trace={trace} />)
+
+    expect(html).toContain("必调工作流诊断")
+    expect(html).toContain("deepseek-chat")
+    expect(html).toContain("tool_calls、stop")
+    expect(html).toContain("工具调用(2)")
+    expect(html).toContain("read_outline")
+    expect(html).toContain("run_chapter_workflow")
+    expect(html).toContain("正文为空")
+  })
+
   it("renders local cache and token composition without claiming a provider hit", () => {
     const trace: ContextTrace = {
       id: "trace-context-hub",

+ 58 - 0
src/components/chat/context-trace-panel.tsx

@@ -427,6 +427,64 @@ function OverviewTab({
         value={ROUTE_SOURCE_LABELS[contextInfo.routeSource] || contextInfo.routeSource}
       />
 
+      {contextInfo.requiredToolDiagnostics && (
+        <>
+          <div className="my-1 h-px bg-border/60" />
+          <div className="py-2">
+            <div className="mb-2 flex items-center gap-2">
+              <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300">
+                <ShieldAlert className="h-3.5 w-3.5" />
+              </div>
+              <div className="text-[11px] font-medium text-foreground">必调工作流诊断</div>
+            </div>
+            <div className="ml-9 grid gap-1 rounded-md border bg-background px-2 py-2 text-[11px] text-muted-foreground">
+              <div>Provider:<span className="text-foreground">{contextInfo.requiredToolDiagnostics.provider}</span></div>
+              <div>Model:<span className="break-all text-foreground">{contextInfo.requiredToolDiagnostics.model}</span></div>
+              <div>Reasoning:<span className="text-foreground">{contextInfo.requiredToolDiagnostics.reasoningMode}</span></div>
+              <div>模型轮次:<span className="text-foreground">{contextInfo.requiredToolDiagnostics.roundsUsed}</span></div>
+              <div>
+                finish_reason:
+                <span className="text-foreground">
+                  {contextInfo.requiredToolDiagnostics.finishReasons.length > 0
+                    ? contextInfo.requiredToolDiagnostics.finishReasons.join("、")
+                    : "未提供"}
+                </span>
+              </div>
+              <div>
+                工具调用({contextInfo.requiredToolDiagnostics.observedToolCalls.length}):
+                <span className="break-all text-foreground">
+                  {contextInfo.requiredToolDiagnostics.observedToolCalls.length > 0
+                    ? contextInfo.requiredToolDiagnostics.observedToolCalls
+                        .map((call) => `#${call.round}.${call.index} ${call.name ?? "名称未完成"}`)
+                        .join("、")
+                    : "无"}
+                </span>
+              </div>
+              <div>
+                自动兜底:
+                <span className="text-foreground">
+                  {contextInfo.requiredToolDiagnostics.fallbackAttempted
+                    ? `${contextInfo.requiredToolDiagnostics.fallbackTool ?? "未知工具"} / ${contextInfo.requiredToolDiagnostics.fallbackStatus ?? "unknown"}`
+                    : contextInfo.requiredToolDiagnostics.fallbackStatus === "unavailable"
+                      ? "不可用"
+                      : "未触发"}
+                </span>
+              </div>
+              {contextInfo.requiredToolDiagnostics.missingTools.length > 0 && (
+                <div className="text-amber-700 dark:text-amber-300">
+                  缺失工具:{contextInfo.requiredToolDiagnostics.missingTools.join("、")}
+                </div>
+              )}
+              {contextInfo.requiredToolDiagnostics.fallbackError && (
+                <div className="break-words text-red-600 dark:text-red-400">
+                  兜底失败:{contextInfo.requiredToolDiagnostics.fallbackError}
+                </div>
+              )}
+            </div>
+          </div>
+        </>
+      )}
+
       {currentHubSnapshot && (
         <>
           <div className="my-1 h-px bg-border/60" />

+ 23 - 8
src/components/layout/preview-panel.tsx

@@ -22,6 +22,7 @@ import { TextTransformPreviewDialog } from "@/components/novel/text-transform-pr
 import { DeAiSkillOptionsPanel } from "@/components/skill-library/de-ai-skill-picker"
 import { useDeAiSkillOptions } from "@/components/skill-library/use-de-ai-skill-options"
 import { buildDeAiRewriteMessages } from "@/lib/novel/de-ai-adapter"
+import { filterDeAiOutput } from "@/lib/novel/de-ai-output"
 import {
   loadDeAiSkillConfig,
   resolveEffectiveDeAiSkill,
@@ -67,6 +68,19 @@ const SnapshotViewer = lazy(async () => {
   return { default: mod.SnapshotViewer }
 })
 
+function extractDeAiResult(content: string): string {
+  return extractDeAiChapterText(filterDeAiOutput(content))
+}
+
+function finishDeAiTaskResult(taskId: string, content: string): void {
+  const candidate = extractDeAiResult(content)
+  if (candidate.trim()) {
+    useDeAiTaskStore.getState().finishTask(taskId, candidate)
+    return
+  }
+  useDeAiTaskStore.getState().failTask(taskId, "去AI味未返回正文")
+}
+
 function inferEditorMode(path: string): "read" | "edit" {
   const normalized = path.replace(/\\/g, "/")
   if (normalized.includes("/wiki/chapters/") || normalized.includes("/wiki/outlines/")) {
@@ -1019,7 +1033,7 @@ export function PreviewPanel() {
           },
           onDone: () => {
             doneCalled = true
-            useDeAiTaskStore.getState().finishTask(taskId, extractDeAiChapterText(result))
+            finishDeAiTaskResult(taskId, result)
           },
           onError: (error) => {
             doneCalled = true
@@ -1030,11 +1044,7 @@ export function PreviewPanel() {
       )
       // 兜底:streamChat 正常返回但未调用 onDone/onError 时,用 result 完成
       if (!doneCalled) {
-        if (result.trim()) {
-          useDeAiTaskStore.getState().finishTask(taskId, extractDeAiChapterText(result))
-        } else {
-          useDeAiTaskStore.getState().failTask(taskId, "去AI味未返回内容")
-        }
+        finishDeAiTaskResult(taskId, result)
       }
     } catch (err) {
       console.error("去AI味处理失败:", err)
@@ -1084,10 +1094,15 @@ export function PreviewPanel() {
           },
           onDone: () => {
             if (selectedFileRef.current !== actionFile) return
+            const candidate = action === "de-ai" ? filterDeAiOutput(result) : result
+            if (!candidate.trim()) {
+              toast.error(`${actionLabel}失败:模型未返回正文`)
+              return
+            }
             setSelectionTransformAction(action)
             setSelectionTransformSelection(selection)
             setSelectionTransformSourceContent(selection.text)
-            setSelectionTransformCandidateContent(result)
+            setSelectionTransformCandidateContent(candidate)
             setSelectionTransformSkillName(action === "de-ai" ? skillName ?? "" : "")
             setSelectionTransformOpen(true)
           },
@@ -1787,7 +1802,7 @@ export function PreviewPanel() {
                   {
                     onToken: (token) => { result += token },
                     onDone: () => {
-                      useDeAiTaskStore.getState().finishTask(chapterId, extractDeAiChapterText(result))
+                      finishDeAiTaskResult(chapterId, result)
                     },
                     onError: (error) => {
                       useDeAiTaskStore.getState().failTask(chapterId, error.message ?? String(error))

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

@@ -96,6 +96,46 @@ describe("CodexAppServerRunner", () => {
     })
   })
 
+  it("forceRequiredToolsImmediately executes the shared deterministic fallback without starting app-server", async () => {
+    const tool: Tool = {
+      name: "run_chapter_workflow",
+      description: "workflow",
+      category: "action",
+      finalizesRun: true,
+      buildRequiredToolFallbackParams: ({ taskGoal }) => ({ userRequest: taskGoal }),
+      parameters: {},
+      execute: vi.fn(async (_params, _signal, context) => {
+        context?.onFinalContent?.("Codex 共用兜底正文")
+        return "ok"
+      }),
+    }
+    const registry = new ToolRegistry()
+    registry.register(tool)
+    const cb = callbacks()
+
+    const result = await new CodexAppServerRunner().run(
+      config([tool], {
+        taskGoal: "写第8章",
+        requiredToolsOnce: ["run_chapter_workflow"],
+        forceRequiredToolsImmediately: true,
+      }),
+      registry,
+      messages,
+      cb,
+    )
+
+    expect(appServerMock.call).not.toHaveBeenCalled()
+    expect(tool.execute).toHaveBeenCalledWith(
+      { userRequest: "写第8章" },
+      undefined,
+      expect.any(Object),
+    )
+    expect(result.finalText).toBe("Codex 共用兜底正文")
+    expect(result.requiredToolDiagnostics?.fallbackStatus).toBe("success")
+    expect(cb.onDone).toHaveBeenCalledOnce()
+    expect(cb.onError).not.toHaveBeenCalled()
+  })
+
   it("publishes QMAI dynamic tools, executes a read tool, and returns the final record", async () => {
     const tool: Tool = {
       name: "read_outline",

+ 113 - 8
src/lib/agent/codex-app-server-runner.ts

@@ -17,6 +17,10 @@ import {
   missingRequiredToolsOnce,
 } from "./required-tools-gate"
 import { executeAgentTool } from "./tool-executor"
+import {
+  executeRequiredToolFallback,
+  isRequiredToolExecutionFulfilled,
+} from "./required-tool-fallback"
 import { withWritingWakeLock } from "../writing-wake-lock"
 import { ToolEvidenceLedger } from "./tool-evidence-ledger"
 import { DEFAULT_TOOL_RESULT_CONTEXT_LIMIT } from "./tool-result"
@@ -141,6 +145,23 @@ export class CodexAppServerRunner {
     const projectPath = config.projectPath
     const latestUserContent = [...messages].reverse().find((message) => message.role === "user")?.content
     const taskGoalText = config.taskGoal || (latestUserContent ? messageContentText(latestUserContent) : "") || "未命名任务"
+    const requiredTools = [...new Set((config.requiredToolsOnce ?? []).filter((name) => name.trim()))]
+    const satisfiedRequiredTools = new Set<string>()
+    let fallbackConvergenceChecked = false
+    if (requiredTools.length > 0) {
+      record.requiredToolDiagnostics = {
+        requiredTools,
+        satisfiedTools: [],
+        missingTools: [...requiredTools],
+        fallbackAttempted: false,
+        provider: config.llmConfig.provider,
+        model: config.modelId?.trim() || config.llmConfig.model,
+        reasoningMode: config.requestOverrides?.reasoning?.mode ?? config.llmConfig.reasoning?.mode ?? "auto",
+        roundsUsed: 0,
+        finishReasons: [],
+        observedToolCalls: [],
+      }
+    }
     let taskBreakpoint: TaskBreakpoint | null = projectPath
       ? createTaskBreakpoint({ taskGoal: taskGoalText, currentStage: "agent_round_1" })
       : null
@@ -161,8 +182,71 @@ export class CodexAppServerRunner {
       }
     }
 
+    const refreshRequiredToolDiagnostics = () => {
+      const diagnostics = record.requiredToolDiagnostics
+      if (!diagnostics) return
+      diagnostics.satisfiedTools = [...satisfiedRequiredTools]
+      diagnostics.missingTools = requiredTools.filter((name) => !satisfiedRequiredTools.has(name))
+    }
+    const missingRequiredTools = () => missingRequiredToolsOnce({
+      requiredToolsOnce: requiredTools,
+      availableToolNames: config.tools.map((tool) => tool.name),
+      calledToolNames: satisfiedRequiredTools,
+      toolsEnabled: config.tools.length > 0,
+    })
+    const attemptRequiredToolFallback = async (): Promise<"success" | "error" | "unavailable"> => {
+      if (fallbackConvergenceChecked) return "unavailable"
+      fallbackConvergenceChecked = true
+      const missing = missingRequiredTools()
+      refreshRequiredToolDiagnostics()
+      if (missing.length === 0) return "success"
+      const fallback = await executeRequiredToolFallback({
+        missingTools: missing,
+        taskGoal: taskGoalText,
+        registry,
+        callbacks,
+        record,
+        signal,
+      })
+      const diagnostics = record.requiredToolDiagnostics
+      if (!fallback.attempted) {
+        if (diagnostics) diagnostics.fallbackStatus = "unavailable"
+        return "unavailable"
+      }
+      if (diagnostics) {
+        diagnostics.fallbackAttempted = true
+        diagnostics.fallbackTool = fallback.toolName
+      }
+      if (fallback.error) {
+        if (diagnostics) {
+          diagnostics.fallbackStatus = "error"
+          diagnostics.fallbackError = fallback.error.message
+        }
+        refreshRequiredToolDiagnostics()
+        await clearPersistedBreakpoint()
+        callbacks.onError(fallback.error)
+        return "error"
+      }
+      fallback.satisfiedTools.forEach((name) => satisfiedRequiredTools.add(name))
+      refreshRequiredToolDiagnostics()
+      if (diagnostics) diagnostics.fallbackStatus = "success"
+      if (fallback.finalContent) {
+        finalDelivery = fallback.finalContent
+        record.finalText = fallback.finalContent
+        await clearPersistedBreakpoint()
+        callbacks.onDone()
+        return "success"
+      }
+      return missingRequiredTools().length === 0 ? "success" : "unavailable"
+    }
+
     if (taskBreakpoint) await persistTaskBreakpoint()
 
+    if (config.forceRequiredToolsImmediately && requiredTools.length > 0) {
+      const outcome = await attemptRequiredToolFallback()
+      if (outcome === "success" || outcome === "error") return record
+    }
+
     const taskContract: AgentMessage = {
       role: "system",
       content: `## 任务契约\n初始任务目标:${taskGoalText.slice(0, 1800)}\n执行过程中不得因历史裁剪丢失该目标;当前用户新要求优先。`,
@@ -240,6 +324,14 @@ export class CodexAppServerRunner {
 
       unregister = client.registerThread(threadId, {
         onDynamicToolCall: async (request) => {
+          const diagnostics = record.requiredToolDiagnostics
+          if (diagnostics) {
+            diagnostics.observedToolCalls.push({
+              round: Math.max(1, record.roundsUsed),
+              index: diagnostics.observedToolCalls.length,
+              name: request.tool,
+            })
+          }
           if (!request.arguments || typeof request.arguments !== "object" || Array.isArray(request.arguments)) {
             const message = `错误: 工具 ${request.tool} 的参数必须是 JSON 对象`
             const now = Date.now()
@@ -282,6 +374,11 @@ export class CodexAppServerRunner {
             signal,
           )
           record.toolCalls.push(executed.record)
+          const registeredTool = registry.get(request.tool)
+          if (isRequiredToolExecutionFulfilled(registeredTool, executed)) {
+            satisfiedRequiredTools.add(request.tool)
+            refreshRequiredToolDiagnostics()
+          }
           if (taskBreakpoint) {
             const usedTools = taskBreakpoint.usedTools.includes(request.tool)
               ? taskBreakpoint.usedTools
@@ -294,9 +391,9 @@ export class CodexAppServerRunner {
             await persistTaskBreakpoint()
           }
           if (
-            executed.success &&
+            executed.record.status === "done" &&
             executed.finalContent?.trim() &&
-            registry.get(request.tool)?.finalizesRun
+            registeredTool?.finalizesRun
           ) {
             // 终稿已交付给用户,本 turn 剩下的模型输出没有价值,直接中断。
             finalDelivery = executed.finalContent.trim()
@@ -370,6 +467,9 @@ export class CodexAppServerRunner {
       for (let round = 0; round < Math.max(1, config.maxRounds); round += 1) {
         if (signal?.aborted) throw new Error("操作已取消")
         record.roundsUsed = round + 1
+        if (record.requiredToolDiagnostics) {
+          record.requiredToolDiagnostics.roundsUsed = record.roundsUsed
+        }
         turnText = ""
         turnUsage = undefined
         const completionPromise = new Promise<TurnCompletion>((resolve, reject) => {
@@ -414,12 +514,8 @@ export class CodexAppServerRunner {
           record.usage = cumulativeUsage ? { ...cumulativeUsage } : { ...completedUsage }
         }
 
-        const missing = missingRequiredToolsOnce({
-          requiredToolsOnce: config.requiredToolsOnce,
-          availableToolNames: config.tools.map((tool) => tool.name),
-          calledToolNames: record.toolCalls.map((call) => call.name),
-          toolsEnabled: config.tools.length > 0,
-        })
+        const missing = missingRequiredTools()
+        refreshRequiredToolDiagnostics()
         if (missing.length === 0) {
           record.finalText = turnText
           if (turnText) callbacks.onText(turnText)
@@ -427,6 +523,8 @@ export class CodexAppServerRunner {
           callbacks.onDone()
           return record
         }
+        const fallbackOutcome = await attemptRequiredToolFallback()
+        if (fallbackOutcome === "success" || fallbackOutcome === "error") return record
         if (round >= Math.max(1, config.maxRounds) - 1) {
           await clearPersistedBreakpoint()
           throw new RequiredToolsNotCalledError(missing)
@@ -438,6 +536,13 @@ export class CodexAppServerRunner {
         }]
       }
 
+      const missingAtLimit = missingRequiredTools()
+      refreshRequiredToolDiagnostics()
+      if (missingAtLimit.length > 0) {
+        const fallbackOutcome = await attemptRequiredToolFallback()
+        if (fallbackOutcome === "success" || fallbackOutcome === "error") return record
+        throw new RequiredToolsNotCalledError(missingAtLimit)
+      }
       throw new Error(`Agent 已达到最大调用轮次(${config.maxRounds}),请尝试减少引用内容或拆分任务`)
     } catch (error) {
       const resolved = error instanceof Error ? error : new Error(String(error))

+ 2 - 1
src/lib/agent/context-trace.ts

@@ -1,6 +1,6 @@
 import type { NovelTaskIntent } from "@/lib/novel/task-router"
 import type { DataSourceCategory, RouteSource } from "@/lib/novel/classification"
-import type { ToolCallStatus } from "./types"
+import type { RequiredToolRunDiagnostics, ToolCallStatus } from "./types"
 import type { AiWorkflowMode } from "./workflow-mode"
 import type { SkillKind, SkillMode, SkillStage } from "@/lib/novel/skill-library"
 import type { CapabilityKind, CapabilityPermission } from "./capabilities/types"
@@ -122,6 +122,7 @@ export interface ClassificationVersionInfo {
   trimmedSections: string[]
   contextBudget?: TraceContextBudget
   contextHub?: ContextHubStats
+    requiredToolDiagnostics?: RequiredToolRunDiagnostics
     resultProtocol?: TraceResultProtocol
     postWriteCheck?: PostWriteCheck
     fallbackReason?: string

+ 104 - 0
src/lib/agent/required-tool-fallback.ts

@@ -0,0 +1,104 @@
+import type { ToolRegistry } from "./registry"
+import type { AgentRunCallbacks, AgentRunRecord, Tool } from "./types"
+import { executeAgentTool, type ExecuteAgentToolResult } from "./tool-executor"
+import { RequiredToolFallbackError } from "./required-tools-gate"
+
+export interface RequiredToolFallbackResult {
+  attempted: boolean
+  toolName?: string
+  satisfiedTools: string[]
+  finalContent?: string
+  error?: RequiredToolFallbackError
+}
+
+/** Shared fulfillment policy for model-selected and deterministic tool calls. */
+export function isRequiredToolExecutionFulfilled(
+  tool: Tool | undefined,
+  executed: ExecuteAgentToolResult,
+): boolean {
+  return Boolean(
+    tool
+    && executed.record.status === "done"
+    && (!tool.finalizesRun || executed.finalContent?.trim()),
+  )
+}
+
+export async function executeRequiredToolFallback(input: {
+  missingTools: string[]
+  taskGoal: string
+  registry: ToolRegistry
+  callbacks: AgentRunCallbacks
+  record: AgentRunRecord
+  signal?: AbortSignal
+}): Promise<RequiredToolFallbackResult> {
+  const satisfiedTools: string[] = []
+
+  for (const toolName of input.missingTools) {
+    const tool = input.registry.get(toolName)
+    if (!tool?.buildRequiredToolFallbackParams) continue
+
+    let params: Record<string, unknown>
+    try {
+      params = tool.buildRequiredToolFallbackParams({ taskGoal: input.taskGoal })
+    } catch (error) {
+      const detail = error instanceof Error ? error.message : String(error)
+      return {
+        attempted: true,
+        toolName,
+        satisfiedTools,
+        error: new RequiredToolFallbackError(toolName, `无法构造兜底参数:${detail}`),
+      }
+    }
+
+    const executed = await executeAgentTool(
+      {
+        id: `required_fallback:${toolName}:${Date.now()}`,
+        name: toolName,
+        arguments: params,
+      },
+      input.registry,
+      input.callbacks,
+      input.signal,
+    )
+    input.record.toolCalls.push(executed.record)
+
+    if (executed.record.status !== "done") {
+      return {
+        attempted: true,
+        toolName,
+        satisfiedTools,
+        error: new RequiredToolFallbackError(toolName, executed.responseText),
+      }
+    }
+
+    const finalContent = executed.finalContent?.trim()
+    if (tool.finalizesRun && !finalContent) {
+      return {
+        attempted: true,
+        toolName,
+        satisfiedTools,
+        error: new RequiredToolFallbackError(toolName, "工具执行完成,但没有交付终稿正文"),
+      }
+    }
+    if (!isRequiredToolExecutionFulfilled(tool, executed)) {
+      return {
+        attempted: true,
+        toolName,
+        satisfiedTools,
+        error: new RequiredToolFallbackError(toolName, "工具未达到必调履约条件"),
+      }
+    }
+
+    satisfiedTools.push(toolName)
+    if (finalContent) {
+      return {
+        attempted: true,
+        toolName,
+        satisfiedTools,
+        finalContent,
+      }
+    }
+  }
+
+  return { attempted: false, satisfiedTools }
+}

+ 9 - 0
src/lib/agent/required-tools-gate.spec.ts

@@ -1,5 +1,6 @@
 import { describe, expect, it } from "vitest"
 import {
+  RequiredToolFallbackError,
   RequiredToolsNotCalledError,
   buildRequiredToolNudgeMessage,
   missingRequiredToolsOnce,
@@ -81,6 +82,14 @@ describe("required-tools-gate", () => {
     expect(err.message).toContain("run_chapter_workflow")
     expect(err.missingTools).toEqual(["run_chapter_workflow"])
   })
+
+  it("RequiredToolFallbackError preserves the workflow and underlying reason", () => {
+    const err = new RequiredToolFallbackError("run_chapter_workflow", "正文为空")
+    expect(err.name).toBe("RequiredToolFallbackError")
+    expect(err.toolName).toBe("run_chapter_workflow")
+    expect(err.message).toContain("正文为空")
+    expect(err.message).not.toContain("模型未调用")
+  })
 })
 
 describe("resolveRequiredToolsOnce", () => {

+ 10 - 0
src/lib/agent/required-tools-gate.ts

@@ -47,6 +47,16 @@ export class RequiredToolsNotCalledError extends Error {
   }
 }
 
+export class RequiredToolFallbackError extends Error {
+  readonly toolName: string
+
+  constructor(toolName: string, detail: string) {
+    super(`必选工作流执行失败(${toolName}):${detail}`)
+    this.name = "RequiredToolFallbackError"
+    this.toolName = toolName
+  }
+}
+
 export interface ResolveRequiredToolsOnceInput {
   novelMode: boolean
   intent?: string | null

+ 319 - 0
src/lib/agent/runner.spec.ts

@@ -211,6 +211,325 @@ describe("AgentRunner", () => {
     expect(tool.execute).not.toHaveBeenCalled()
   })
 
+  it("deterministically executes a mandatory finalizing tool when the model returns prose", async () => {
+    const tool: Tool = {
+      name: "run_chapter_workflow",
+      description: "workflow",
+      category: "action",
+      finalizesRun: true,
+      buildRequiredToolFallbackParams: ({ taskGoal }) => ({ userRequest: taskGoal }),
+      parameters: {},
+      execute: vi.fn(async (_params, _signal, context) => {
+        context?.onFinalContent?.("第1章\n\n确定性兜底正文")
+        return "章节工作流完成"
+      }),
+    }
+    registry.register(tool)
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      cb.onToken("模型绕过工作流直出正文")
+      cb.onFinishReason?.("stop")
+      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: 2,
+      tools: [tool],
+      systemPrompt: "",
+      llmConfig: mockLlmConfig,
+      taskGoal: "写第1章",
+      requiredToolsOnce: ["run_chapter_workflow"],
+    }, registry, [systemMsg, userMsg], callbacks)
+
+    expect(tool.execute).toHaveBeenCalledWith(
+      { userRequest: "写第1章" },
+      undefined,
+      expect.any(Object),
+    )
+    expect(callbacks.onText).not.toHaveBeenCalled()
+    expect(callbacks.onFinalContent).toHaveBeenCalledWith("第1章\n\n确定性兜底正文")
+    expect(callbacks.onDone).toHaveBeenCalledOnce()
+    expect(callbacks.onError).not.toHaveBeenCalled()
+    expect(result.finalText).toBe("第1章\n\n确定性兜底正文")
+    expect(result.requiredToolDiagnostics).toEqual(expect.objectContaining({
+      satisfiedTools: ["run_chapter_workflow"],
+      missingTools: [],
+      fallbackAttempted: true,
+      fallbackStatus: "success",
+      finishReasons: ["stop"],
+    }))
+  })
+
+  it("executes run_chapter_workflow when it is the second parallel DeepSeek tool call", async () => {
+    const readTool: Tool = {
+      name: "read_outline",
+      description: "read outline",
+      category: "read",
+      parameters: {},
+      execute: vi.fn(async () => "卷纲"),
+    }
+    const workflowTool: Tool = {
+      name: "run_chapter_workflow",
+      description: "workflow",
+      category: "action",
+      finalizesRun: true,
+      buildRequiredToolFallbackParams: ({ taskGoal }) => ({ userRequest: taskGoal }),
+      parameters: {},
+      execute: vi.fn(async (_params, _signal, context) => {
+        context?.onFinalContent?.("第二个工具交付的正文")
+        return "ok"
+      }),
+    }
+    registry.register(readTool)
+    registry.register(workflowTool)
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      cb.onToolCallDelta?.({ index: 0, id: "parallel-read", name: "read_outline" })
+      cb.onToolCallDelta?.({ index: 1, id: "parallel-workflow", name: "run_chapter_workflow" })
+      cb.onToolCallDelta?.({ index: 0, arguments: "{}" })
+      cb.onToolCallDelta?.({ index: 1, arguments: '{"userRequest":"写第45章"}' })
+      cb.onFinishReason?.("tool_calls")
+      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: 2,
+      tools: [readTool, workflowTool],
+      systemPrompt: "",
+      llmConfig: mockLlmConfig,
+      taskGoal: "写第45章",
+      requiredToolsOnce: ["run_chapter_workflow"],
+    }, registry, [systemMsg, userMsg], callbacks)
+
+    expect(result.toolCalls.map((call) => call.name)).toEqual([
+      "read_outline",
+      "run_chapter_workflow",
+    ])
+    expect(workflowTool.execute).toHaveBeenCalledOnce()
+    expect(result.finalText).toBe("第二个工具交付的正文")
+    expect(result.requiredToolDiagnostics?.fallbackAttempted).toBe(false)
+    expect(result.requiredToolDiagnostics?.observedToolCalls).toEqual([
+      { round: 1, index: 0, name: "read_outline" },
+      { round: 1, index: 1, name: "run_chapter_workflow" },
+    ])
+  })
+
+  it("does not treat malformed tool arguments as fulfillment and falls back with trusted params", async () => {
+    const tool: Tool = {
+      name: "run_chapter_workflow",
+      description: "workflow",
+      category: "action",
+      finalizesRun: true,
+      buildRequiredToolFallbackParams: ({ taskGoal }) => ({ userRequest: taskGoal }),
+      parameters: {},
+      execute: vi.fn(async (_params, _signal, context) => {
+        context?.onFinalContent?.("合法正文")
+        return "ok"
+      }),
+    }
+    registry.register(tool)
+    mockStreamChat
+      .mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+        cb.onToolCallDelta?.({ index: 0, id: "bad-args", name: "run_chapter_workflow" })
+        cb.onToolCallDelta?.({ index: 0, arguments: "{bad json" })
+        cb.onDone()
+      })
+      .mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+        cb.onToken("仍然直出")
+        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: 3,
+      tools: [tool],
+      systemPrompt: "",
+      llmConfig: mockLlmConfig,
+      taskGoal: "续写第2章",
+      requiredToolsOnce: ["run_chapter_workflow"],
+    }, registry, [systemMsg, userMsg], callbacks)
+
+    expect(callbacks.onToolError).toHaveBeenCalledWith("bad-args", expect.stringContaining("不是合法 JSON"))
+    expect(tool.execute).toHaveBeenCalledOnce()
+    expect(tool.execute).toHaveBeenCalledWith(
+      { userRequest: "续写第2章" },
+      undefined,
+      expect.any(Object),
+    )
+    expect(result.toolCalls.map((call) => call.status)).toEqual(["error", "done"])
+    expect(result.requiredToolDiagnostics?.satisfiedTools).toEqual(["run_chapter_workflow"])
+  })
+
+  it("runs the required fallback outside the ordinary round budget", async () => {
+    const readTool: Tool = {
+      name: "read_outline",
+      description: "read",
+      category: "read",
+      parameters: {},
+      execute: vi.fn(async () => "大纲"),
+    }
+    const workflowTool: Tool = {
+      name: "run_chapter_workflow",
+      description: "workflow",
+      category: "action",
+      finalizesRun: true,
+      buildRequiredToolFallbackParams: ({ taskGoal }) => ({ userRequest: taskGoal }),
+      parameters: {},
+      execute: vi.fn(async (_params, _signal, context) => {
+        context?.onFinalContent?.("预算外兜底正文")
+        return "ok"
+      }),
+    }
+    registry.register(readTool)
+    registry.register(workflowTool)
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      cb.onToolCallDelta?.({ index: 0, id: "read-1", name: "read_outline", arguments: "{}" })
+      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: 1,
+      tools: [readTool, workflowTool],
+      systemPrompt: "",
+      llmConfig: mockLlmConfig,
+      taskGoal: "写第3章",
+      requiredToolsOnce: ["run_chapter_workflow"],
+    }, registry, [systemMsg, userMsg], callbacks)
+
+    expect(result.roundsUsed).toBe(1)
+    expect(readTool.execute).toHaveBeenCalledOnce()
+    expect(workflowTool.execute).toHaveBeenCalledOnce()
+    expect(result.finalText).toBe("预算外兜底正文")
+    expect(callbacks.onError).not.toHaveBeenCalled()
+  })
+
+  it("forceRequiredToolsImmediately bypasses the outer model and reports empty final delivery", async () => {
+    const tool: Tool = {
+      name: "run_chapter_workflow",
+      description: "workflow",
+      category: "action",
+      finalizesRun: true,
+      buildRequiredToolFallbackParams: ({ taskGoal }) => ({ userRequest: taskGoal }),
+      parameters: {},
+      execute: vi.fn(async () => "执行完成但没有交付正文"),
+    }
+    registry.register(tool)
+    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: 3,
+      tools: [tool],
+      systemPrompt: "",
+      llmConfig: mockLlmConfig,
+      taskGoal: "写第4章",
+      requiredToolsOnce: ["run_chapter_workflow"],
+      forceRequiredToolsImmediately: true,
+    }, registry, [systemMsg, userMsg], callbacks)
+
+    expect(mockStreamChat).not.toHaveBeenCalled()
+    expect(callbacks.onDone).not.toHaveBeenCalled()
+    expect(callbacks.onError).toHaveBeenCalledWith(expect.objectContaining({
+      name: "RequiredToolFallbackError",
+      message: expect.stringContaining("没有交付终稿正文"),
+    }))
+    expect(result.requiredToolDiagnostics?.fallbackStatus).toBe("error")
+  })
+
+  it("does not count approval_required as successful required-tool fulfillment", async () => {
+    const tool: Tool = {
+      name: "required_action",
+      description: "requires confirmation",
+      category: "write",
+      permission: "confirm",
+      buildRequiredToolFallbackParams: () => ({ value: "trusted" }),
+      parameters: {},
+      execute: vi.fn(async () => "preview only"),
+    }
+    registry.register(tool)
+    const callbacks = {
+      onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(),
+      onDone: vi.fn(), onError: vi.fn(),
+    }
+
+    const result = await runner.run({
+      maxRounds: 2,
+      tools: [tool],
+      systemPrompt: "",
+      llmConfig: mockLlmConfig,
+      taskGoal: "执行动作",
+      requiredToolsOnce: ["required_action"],
+      forceRequiredToolsImmediately: true,
+    }, registry, [systemMsg, userMsg], callbacks)
+
+    expect(mockStreamChat).not.toHaveBeenCalled()
+    expect(result.toolCalls[0]?.status).toBe("approval_required")
+    expect(result.requiredToolDiagnostics?.satisfiedTools).toEqual([])
+    expect(callbacks.onError).toHaveBeenCalledWith(expect.objectContaining({
+      name: "RequiredToolFallbackError",
+    }))
+  })
+
+  it("surfaces the underlying required workflow execution failure", async () => {
+    const tool: Tool = {
+      name: "run_chapter_workflow",
+      description: "workflow",
+      category: "action",
+      finalizesRun: true,
+      buildRequiredToolFallbackParams: ({ taskGoal }) => ({ userRequest: taskGoal }),
+      parameters: {},
+      execute: vi.fn(async () => {
+        throw new Error("DeepSeek 下游生成超时")
+      }),
+    }
+    registry.register(tool)
+    const callbacks = {
+      onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(),
+      onDone: vi.fn(), onError: vi.fn(),
+    }
+
+    const result = await runner.run({
+      maxRounds: 2,
+      tools: [tool],
+      systemPrompt: "",
+      llmConfig: mockLlmConfig,
+      taskGoal: "写第5章",
+      requiredToolsOnce: ["run_chapter_workflow"],
+      forceRequiredToolsImmediately: true,
+    }, registry, [systemMsg, userMsg], callbacks)
+
+    expect(result.requiredToolDiagnostics?.fallbackError).toContain("DeepSeek 下游生成超时")
+    expect(callbacks.onError).toHaveBeenCalledWith(expect.objectContaining({
+      name: "RequiredToolFallbackError",
+      message: expect.stringContaining("DeepSeek 下游生成超时"),
+    }))
+  })
+
   it("does not block no-tool finals when requiredToolsOnce is unset", async () => {
     const tool: Tool = {
       name: "run_chapter_workflow",

+ 158 - 15
src/lib/agent/runner.ts

@@ -26,7 +26,11 @@ import {
   buildRequiredToolNudgeMessage,
   missingRequiredToolsOnce,
 } from "./required-tools-gate"
-import { executeAgentTool } from "./tool-executor"
+import { executeAgentTool, rejectAgentToolCall } from "./tool-executor"
+import {
+  executeRequiredToolFallback,
+  isRequiredToolExecutionFulfilled,
+} from "./required-tool-fallback"
 import { CodexAppServerRunner } from "./codex-app-server-runner"
 import { withWritingWakeLock } from "../writing-wake-lock"
 
@@ -83,6 +87,23 @@ export class AgentRunner {
       messageContentText([...messages].reverse().find((m) => m.role === "user")?.content ?? "") ||
       "未命名任务"
     const taskContract = `## 任务契约\n初始任务目标:${taskGoal.slice(0, 1800)}\n执行过程中不得因历史裁剪丢失该目标;当前用户新要求优先。`
+    const requiredTools = [...new Set((config.requiredToolsOnce ?? []).filter((name) => name.trim()))]
+    const satisfiedRequiredTools = new Set<string>()
+    let fallbackConvergenceChecked = false
+    if (requiredTools.length > 0) {
+      record.requiredToolDiagnostics = {
+        requiredTools,
+        satisfiedTools: [],
+        missingTools: [...requiredTools],
+        fallbackAttempted: false,
+        provider: config.llmConfig.provider,
+        model: config.modelId?.trim() || config.llmConfig.model,
+        reasoningMode: config.requestOverrides?.reasoning?.mode ?? config.llmConfig.reasoning?.mode ?? "auto",
+        roundsUsed: 0,
+        finishReasons: [],
+        observedToolCalls: [],
+      }
+    }
     const contractInsertIndex = workingMessages.findIndex((message) => message.role !== "system")
     workingMessages.splice(contractInsertIndex < 0 ? workingMessages.length : contractInsertIndex, 0, {
       role: "system",
@@ -114,12 +135,82 @@ export class AgentRunner {
       }
     }
 
+    const refreshRequiredToolDiagnostics = () => {
+      const diagnostics = record.requiredToolDiagnostics
+      if (!diagnostics) return
+      diagnostics.satisfiedTools = [...satisfiedRequiredTools]
+      diagnostics.missingTools = requiredTools.filter((name) => !satisfiedRequiredTools.has(name))
+    }
+
+    const missingRequiredTools = () => missingRequiredToolsOnce({
+      requiredToolsOnce: requiredTools,
+      availableToolNames: config.tools.map((tool) => tool.name),
+      calledToolNames: satisfiedRequiredTools,
+      toolsEnabled: config.tools.length > 0,
+    })
+
+    const attemptRequiredToolFallback = async (): Promise<"success" | "error" | "unavailable"> => {
+      if (fallbackConvergenceChecked) return "unavailable"
+      fallbackConvergenceChecked = true
+      const missing = missingRequiredTools()
+      refreshRequiredToolDiagnostics()
+      if (missing.length === 0) return "success"
+
+      const fallback = await executeRequiredToolFallback({
+        missingTools: missing,
+        taskGoal,
+        registry,
+        callbacks: { ...callbacks, onRequestTrace },
+        record,
+        signal,
+      })
+      const diagnostics = record.requiredToolDiagnostics
+      if (!fallback.attempted) {
+        if (diagnostics) diagnostics.fallbackStatus = "unavailable"
+        return "unavailable"
+      }
+      if (diagnostics) {
+        diagnostics.fallbackAttempted = true
+        diagnostics.fallbackTool = fallback.toolName
+      }
+      if (fallback.error) {
+        if (diagnostics) {
+          diagnostics.fallbackStatus = "error"
+          diagnostics.fallbackError = fallback.error.message
+        }
+        refreshRequiredToolDiagnostics()
+        await clearPersistedBreakpoint()
+        callbacks.onError(fallback.error)
+        return "error"
+      }
+
+      fallback.satisfiedTools.forEach((name) => satisfiedRequiredTools.add(name))
+      refreshRequiredToolDiagnostics()
+      if (diagnostics) diagnostics.fallbackStatus = "success"
+      if (fallback.finalContent) {
+        finalText = fallback.finalContent
+        record.finalText = finalText
+        await clearPersistedBreakpoint()
+        callbacks.onDone()
+        return "success"
+      }
+      return missingRequiredTools().length === 0 ? "success" : "unavailable"
+    }
+
     if (taskBreakpoint) {
       await persistTaskBreakpoint()
     }
 
+    if (config.forceRequiredToolsImmediately && requiredTools.length > 0) {
+      const outcome = await attemptRequiredToolFallback()
+      if (outcome === "success" || outcome === "error") return record
+    }
+
     for (let round = 0; round < maxRounds; round++) {
       record.roundsUsed = round + 1
+      if (record.requiredToolDiagnostics) {
+        record.requiredToolDiagnostics.roundsUsed = record.roundsUsed
+      }
 
       if (signal?.aborted) {
         for (const tc of record.toolCalls) {
@@ -155,6 +246,27 @@ export class AgentRunner {
         },
         onToolCallDelta: (delta: ToolCallDelta) => {
           toolCallDeltas.push(delta)
+          const diagnostics = record.requiredToolDiagnostics
+          if (diagnostics) {
+            const existing = diagnostics.observedToolCalls.find(
+              (item) => item.round === round + 1 && item.index === delta.index,
+            )
+            if (existing) {
+              if (delta.name) existing.name = delta.name
+            } else {
+              diagnostics.observedToolCalls.push({
+                round: round + 1,
+                index: delta.index,
+                ...(delta.name ? { name: delta.name } : {}),
+              })
+            }
+          }
+        },
+        onFinishReason: (reason: string) => {
+          const finishReasons = record.requiredToolDiagnostics?.finishReasons
+          if (finishReasons && finishReasons[finishReasons.length - 1] !== reason) {
+            finishReasons.push(reason)
+          }
         },
         onUsage: (usage) => {
           roundUsage = mergeLlmUsageSnapshot(roundUsage, usage)
@@ -319,12 +431,15 @@ export class AgentRunner {
 
       if (toolCalls.length === 0) {
         const missingRequired = missingRequiredToolsOnce({
-          requiredToolsOnce: config.requiredToolsOnce,
+          requiredToolsOnce: requiredTools,
           availableToolNames: config.tools.map((tool) => tool.name),
-          calledToolNames: record.toolCalls.map((call) => call.name),
+          calledToolNames: satisfiedRequiredTools,
           toolsEnabled: Boolean(openaiTools),
         })
+        refreshRequiredToolDiagnostics()
         if (missingRequired.length > 0) {
+          const fallbackOutcome = await attemptRequiredToolFallback()
+          if (fallbackOutcome === "success" || fallbackOutcome === "error") return record
           if (roundText.trim() || roundReasoningContent.trim()) {
             workingMessages.push({
               role: "assistant",
@@ -388,22 +503,41 @@ export class AgentRunner {
           await persistTaskBreakpoint()
         }
 
-        const params = (() => {
-          try { return JSON.parse(tc.function.arguments || "{}") }
-          catch { return {} }
-        })()
-        const executed = await executeAgentTool(
-          { id: tc.id, name: toolName, arguments: params } satisfies ToolCall,
-          registry,
-          { ...callbacks, onRequestTrace },
-          signal,
-        )
+        let params: Record<string, unknown> = {}
+        let argumentError = ""
+        try {
+          const parsed = JSON.parse(tc.function.arguments || "{}")
+          if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+            argumentError = "工具参数必须是 JSON 对象"
+          } else {
+            params = parsed as Record<string, unknown>
+          }
+        } catch (error) {
+          argumentError = `工具参数不是合法 JSON:${error instanceof Error ? error.message : String(error)}`
+        }
+        const executed = argumentError
+          ? rejectAgentToolCall(
+              { id: tc.id, name: toolName },
+              `错误: ${argumentError}`,
+              callbacks,
+            )
+          : await executeAgentTool(
+              { id: tc.id, name: toolName, arguments: params } satisfies ToolCall,
+              registry,
+              { ...callbacks, onRequestTrace },
+              signal,
+            )
         record.toolCalls.push(executed.record)
         await saveToolProgress()
+        const registeredTool = registry.get(toolName)
+        if (isRequiredToolExecutionFulfilled(registeredTool, executed)) {
+          satisfiedRequiredTools.add(toolName)
+          refreshRequiredToolDiagnostics()
+        }
         if (
-          executed.success &&
+          executed.record.status === "done" &&
           executed.finalContent?.trim() &&
-          registry.get(toolName)?.finalizesRun
+          registeredTool?.finalizesRun
         ) {
           deliveredFinalContent = executed.finalContent.trim()
         }
@@ -445,6 +579,15 @@ export class AgentRunner {
     }
 
     // Exceeded max rounds
+    const missingAtLimit = missingRequiredTools()
+    refreshRequiredToolDiagnostics()
+    if (missingAtLimit.length > 0) {
+      const fallbackOutcome = await attemptRequiredToolFallback()
+      if (fallbackOutcome === "success" || fallbackOutcome === "error") return record
+      await clearPersistedBreakpoint()
+      callbacks.onError(new RequiredToolsNotCalledError(missingAtLimit))
+      return record
+    }
     callbacks.onError(new Error(`Agent 已达到最大调用轮次(${maxRounds}),请尝试减少引用内容或拆分任务`))
     return record
   }

+ 36 - 0
src/lib/agent/tool-executor.ts

@@ -29,6 +29,42 @@ export interface ExecuteAgentToolResult {
   finalContent?: string
 }
 
+export function rejectAgentToolCall(
+  call: Pick<ToolCall, "id" | "name">,
+  message: string,
+  callbacks: AgentRunCallbacks,
+): ExecuteAgentToolResult {
+  const timestamp = Date.now()
+  const params: Record<string, unknown> = {}
+  const record: AgentRunRecord["toolCalls"][number] = {
+    id: call.id,
+    name: call.name,
+    params,
+    result: message,
+    status: "error",
+    startedAt: timestamp,
+    finishedAt: timestamp,
+  }
+  callbacks.onToolCall({ id: call.id, name: call.name, arguments: params })
+  callbacks.onToolEvent?.({
+    type: "call_started",
+    callId: call.id,
+    name: call.name,
+    params,
+    timestamp,
+  })
+  callbacks.onToolError(call.id, message)
+  callbacks.onToolEvent?.({
+    type: "error",
+    callId: call.id,
+    name: call.name,
+    params,
+    result: message,
+    timestamp,
+  })
+  return { record, responseText: message, success: false }
+}
+
 export async function executeAgentTool(
   call: ToolCall,
   registry: ToolRegistry,

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

@@ -12,6 +12,19 @@ const llmConfig: LlmConfig = {
 }
 
 describe("createRunChapterWorkflowTool", () => {
+  it("rejects an empty userRequest before starting the workflow", async () => {
+    const runDeepChapterGeneration = vi.fn()
+    const tool = createRunChapterWorkflowTool({
+      projectPath: "E:/Novel",
+      llmConfig,
+      aiWorkflowMode: "standard",
+      runDeepChapterGeneration,
+    })
+
+    await expect(tool.execute({ userRequest: "   " })).resolves.toContain("缺少 userRequest")
+    expect(runDeepChapterGeneration).not.toHaveBeenCalled()
+  })
+
   it("wraps deep chapter generation as an auto action tool", async () => {
     const runDeepChapterGeneration = vi.fn(async (_input, callbacks) => {
       callbacks.onWorkflowEvent?.({
@@ -41,6 +54,9 @@ describe("createRunChapterWorkflowTool", () => {
     expect(tool.name).toBe("run_chapter_workflow")
     expect(tool.category).toBe("action")
     expect(tool.permission).toBe("auto")
+    expect(tool.buildRequiredToolFallbackParams?.({ taskGoal: "生成第3章" })).toEqual({
+      userRequest: "生成第3章",
+    })
 
     const result = await tool.execute({
       intent: "write_chapter",

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

@@ -93,6 +93,9 @@ export function createRunChapterWorkflowTool(options: RunChapterWorkflowToolOpti
     permission: "auto",
     executeTimeoutMs: 0,
     finalizesRun: true,
+    buildRequiredToolFallbackParams: ({ taskGoal }) => ({
+      userRequest: taskGoal,
+    }),
     parameters: {
       intent: {
         type: "string",

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

@@ -39,6 +39,12 @@ export interface Tool {
    * 不再让模型复述或改写(见 AgentRunner / CodexAppServerRunner 的交付短路)。
    */
   finalizesRun?: boolean
+  /**
+   * Build trusted arguments when this tool is mandatory but the model fails
+   * to call it. Only explicitly approved deterministic fallback entrypoints
+   * should implement this hook.
+   */
+  buildRequiredToolFallbackParams?: (input: { taskGoal: string }) => Record<string, unknown>
   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>
@@ -75,6 +81,24 @@ export interface AgentConfig {
    * 缺则拒绝无 tool 终稿并续轮(见 AgentRunner required-tools gate)。
    */
   requiredToolsOnce?: string[]
+  /** Internal retry policy: execute deterministic required-tool fallbacks before asking the model. */
+  forceRequiredToolsImmediately?: boolean
+}
+
+export interface RequiredToolRunDiagnostics {
+  requiredTools: string[]
+  satisfiedTools: string[]
+  missingTools: string[]
+  fallbackAttempted: boolean
+  fallbackTool?: string
+  fallbackStatus?: "success" | "error" | "unavailable"
+  fallbackError?: string
+  provider: LlmConfig["provider"]
+  model: string
+  reasoningMode: string
+  roundsUsed: number
+  finishReasons: string[]
+  observedToolCalls: Array<{ round: number; index: number; name?: string }>
 }
 
 export interface AgentToolEvent {
@@ -191,6 +215,8 @@ export interface AgentRunRecord {
   omittedRequestTraceCount?: number
   /** Memory decision from the first LLM round that applied user memory. */
   userMemoryDecision?: import("@/lib/user-memory/decision-trace").UserMemoryDecision | null
+  /** Required-tool convergence and sanitized provider/tool-selection diagnostics. */
+  requiredToolDiagnostics?: RequiredToolRunDiagnostics
 }
 
 export const DEFAULT_MAX_ROUNDS = 15

+ 29 - 23
src/lib/llm-client.ts

@@ -50,6 +50,8 @@ export interface StreamCallbacks {
   onReasoningToken?: (token: string) => void
   /** 工具调用流式 delta,用于累积 tool_calls */
   onToolCallDelta?: (delta: { index: number; id?: string; name?: string; arguments?: string }) => void
+  /** Provider reported finish reason for the current response. */
+  onFinishReason?: (reason: string) => void
   onUsage?: (usage: LlmUsage) => void
   /** Sanitized request-level timing/cache trace; never contains prompt text or credentials. */
   onRequestTrace?: (trace: LlmRequestCacheTrace) => void
@@ -130,11 +132,13 @@ function waitForRetry(ms: number, signal?: AbortSignal): Promise<boolean> {
   })
 }
 
-function parseToolCallDeltaFromLine(line: string): { index: number; id?: string; name?: string; arguments?: string } | null {
+export function parseToolCallDeltasFromLine(
+  line: string,
+): Array<{ index: number; id?: string; name?: string; arguments?: string }> {
   const trimmed = line.trim()
-  if (!trimmed.startsWith("data: ")) return null
+  if (!trimmed.startsWith("data: ")) return []
   const data = trimmed.slice(6).trim()
-  if (data === "[DONE]") return null
+  if (data === "[DONE]") return []
   try {
     const parsed = JSON.parse(data) as {
       choices?: Array<{
@@ -147,18 +151,19 @@ function parseToolCallDeltaFromLine(line: string): { index: number; id?: string;
         }
       }>
     }
-    const toolCall = parsed.choices?.[0]?.delta?.tool_calls?.[0]
-    if (toolCall === undefined) return null
-    return {
+    const toolCalls = parsed.choices?.[0]?.delta?.tool_calls ?? []
+    return toolCalls.map((toolCall) => ({
       index: toolCall.index ?? 0,
-      id: toolCall.id,
-      name: toolCall.function?.name,
-      arguments: toolCall.function?.arguments,
-    }
+      ...(toolCall.id !== undefined ? { id: toolCall.id } : {}),
+      ...(toolCall.function?.name !== undefined ? { name: toolCall.function.name } : {}),
+      ...(toolCall.function?.arguments !== undefined
+        ? { arguments: toolCall.function.arguments }
+        : {}),
+    }))
   } catch {
     // A malformed SSE line is not fatal: skip it and keep the stream alive.
     // The only error reachable here is JSON.parse's SyntaxError.
-    return null
+    return []
   }
 }
 
@@ -754,7 +759,10 @@ async function streamChatHeld(
     }
     const recordFinishReason = (line: string) => {
       const reason = providerConfig.parseFinishReason(line)
-      if (reason) finishReason = reason
+      if (reason) {
+        finishReason = reason
+        callbacks.onFinishReason?.(reason)
+      }
     }
     const recordReasoning = (line: string) => {
       const reasoningParts = extractReasoningTextFromLine(line)
@@ -778,17 +786,16 @@ async function streamChatHeld(
             // reasoning_content and tool_calls on the same SSE line.
             reasoningCharsObserved += countReasoningCharsInLine(trimmed)
             recordReasoning(trimmed)
-            const toolDelta = parseToolCallDeltaFromLine(trimmed)
-            if (toolDelta) {
+            const toolDeltas = parseToolCallDeltasFromLine(trimmed)
+            for (const toolDelta of toolDeltas) {
               markFirstResponse(activeRequestTrace)
               toolCallDeltaCount += 1
               callbacks.onToolCallDelta?.(toolDelta)
-            } else {
-              const token = providerConfig.parseStream(trimmed)
-              if (token !== null) {
-                if (token) markFirstResponse(activeRequestTrace)
-                recordToken(token)
-              }
+            }
+            const token = providerConfig.parseStream(trimmed)
+            if (token !== null) {
+              if (token) markFirstResponse(activeRequestTrace)
+              recordToken(token)
             }
           }
           break
@@ -806,12 +813,11 @@ async function streamChatHeld(
           // reasoning_content and tool_calls on the same SSE line.
           reasoningCharsObserved += countReasoningCharsInLine(trimmed)
           recordReasoning(trimmed)
-          const toolDelta = parseToolCallDeltaFromLine(trimmed)
-          if (toolDelta) {
+          const toolDeltas = parseToolCallDeltasFromLine(trimmed)
+          for (const toolDelta of toolDeltas) {
             markFirstResponse(activeRequestTrace)
             toolCallDeltaCount += 1
             callbacks.onToolCallDelta?.(toolDelta)
-            continue
           }
           const token = providerConfig.parseStream(trimmed)
           if (token !== null) {

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

@@ -140,6 +140,42 @@ describe("streamChat usage", () => {
     }))
   })
 
+  it("preserves content and every parallel tool call from one DeepSeek SSE event", async () => {
+    const encoder = new TextEncoder()
+    const body = new ReadableStream<Uint8Array>({
+      start(controller) {
+        controller.enqueue(encoder.encode([
+          'data: {"choices":[{"delta":{"content":"准备执行","reasoning_content":"先读纲再进工作流","tool_calls":[{"index":0,"id":"call_read","function":{"name":"read_outline","arguments":"{\\"name\\":\\"卷纲\\"}"}},{"index":1,"id":"call_workflow","function":{"name":"run_chapter_workflow","arguments":"{\\"userRequest\\":\\"写第45章\\"}"}}]},"finish_reason":"tool_calls"}]}',
+          "data: [DONE]",
+          "",
+        ].join("\n")))
+        controller.close()
+      },
+    })
+    mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
+    const onToken = vi.fn()
+    const onReasoningToken = vi.fn()
+    const onToolCallDelta = vi.fn()
+    const onFinishReason = vi.fn()
+
+    await streamChat(config, [{ role: "user", content: "写第45章" }], {
+      onToken,
+      onReasoningToken,
+      onToolCallDelta,
+      onFinishReason,
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    })
+
+    expect(onToken).toHaveBeenCalledWith("准备执行")
+    expect(onReasoningToken).toHaveBeenCalledWith("先读纲再进工作流")
+    expect(onToolCallDelta.mock.calls.map(([delta]) => delta)).toEqual([
+      expect.objectContaining({ index: 0, id: "call_read", name: "read_outline" }),
+      expect.objectContaining({ index: 1, id: "call_workflow", name: "run_chapter_workflow" }),
+    ])
+    expect(onFinishReason).toHaveBeenCalledWith("tool_calls")
+  })
+
   it("does not treat reasoning plus tool calls as a reasoning-only failure", async () => {
     const thinking = "先列出大纲和章节再决定怎么写。".repeat(20)
     expect(thinking.length).toBeGreaterThan(200)

+ 13 - 0
src/lib/novel/de-ai-batch/llm-runner.spec.ts

@@ -60,6 +60,19 @@ describe("de-ai batch llm runner", () => {
     expect(stream).toHaveBeenCalledWith(config, expect.any(Array), expect.any(Object), signal)
   })
 
+  it("在完整结果形成后过滤 Gemini 普通文本形式的思考摘要", async () => {
+    const stream = vi.fn(async (_config, _messages, callbacks) => {
+      callbacks.onToken("**Initiating the Analysis**\n\n")
+      callbacks.onToken("I'm currently dissecting the task and applying the requested rewrite rules.\n\n")
+      callbacks.onToken("巷口的雨停了,叶刃收起伞。")
+      callbacks.onDone()
+    })
+    const runner = createDeAiBatchLlmRunner({ resolveConfig: () => ({}) as never, stream: stream as never })
+
+    await expect(runner({ task, chapter, signal: new AbortController().signal }))
+      .resolves.toBe("巷口的雨停了,叶刃收起伞。")
+  })
+
   it("按任务模型、项目默认模型、聊天模型的顺序生成稳定 provider/model key", () => {
     expect(resolveDeAiBatchModelKey({
       taskModel: "custom-bound/test-model",

+ 2 - 1
src/lib/novel/de-ai-batch/llm-runner.ts

@@ -1,6 +1,7 @@
 import { streamChat, type StreamCallbacks } from "@/lib/llm-client"
 import type { ChatMessage } from "@/lib/llm-providers"
 import { buildDeAiRewriteMessages } from "@/lib/novel/de-ai-adapter"
+import { filterDeAiOutput } from "@/lib/novel/de-ai-output"
 import {
   isModelKeyRegistered,
   resolveModelConfig,
@@ -81,7 +82,7 @@ export function createDeAiBatchLlmRunner(options: DeAiBatchLlmRunnerOptions): De
       signal,
     )
     if (streamError) throw streamError
-    return content
+    return filterDeAiOutput(content)
   }
 }
 

+ 30 - 0
src/lib/novel/de-ai-output.spec.ts

@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest"
+import { filterDeAiOutput } from "./de-ai-output"
+
+const GEMINI_DE_AI_THOUGHTS = [
+  "**Initiating the Analysis**",
+  "",
+  "I'm currently dissecting the task. The core of this process involves identifying and applying de-AI techniques.",
+  "",
+  "**Refining the Rewrite**",
+  "",
+  "I'm now zeroing in on the output constraints and improving the dialogue.",
+].join("\n")
+
+describe("filterDeAiOutput", () => {
+  it("过滤 Gemini 去 AI 味结果前置的思考摘要并保留正文", () => {
+    const output = `${GEMINI_DE_AI_THOUGHTS}\n\n雨声压住了巷口的脚步。叶刃没有回头。`
+
+    expect(filterDeAiOutput(output)).toBe("雨声压住了巷口的脚步。叶刃没有回头。")
+  })
+
+  it("思考过程是全部输出时返回空文本", () => {
+    expect(filterDeAiOutput(GEMINI_DE_AI_THOUGHTS)).toBe("")
+  })
+
+  it("不改动正常的去 AI 味正文", () => {
+    const output = "雨声压住了巷口的脚步。\n\n叶刃把伞往下压了压。"
+
+    expect(filterDeAiOutput(output)).toBe(output)
+  })
+})

+ 10 - 0
src/lib/novel/de-ai-output.ts

@@ -0,0 +1,10 @@
+import { stripThoughtDumpFromText } from "@/lib/thought-dump"
+
+/**
+ * Gemini may stream its thought summary as ordinary text instead of a
+ * structured `thought` part. Filter the completed payload, where the full
+ * thought block can be recognized reliably, before exposing a de-AI result.
+ */
+export function filterDeAiOutput(content: string): string {
+  return stripThoughtDumpFromText(content).trim()
+}