ソースを参照

feat(agent): 写章强制走 workflow 并按文件夹分流大纲

新增 requiredToolsOnce gate,非 fast 模式未调用 run_chapter_workflow 前拦截终稿
outline 定位改为优先按文件夹分流,旧 type 仅作兼容回退

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 ヶ月 前
親
コミット
3ea5b8bf2a

+ 7 - 0
src/components/chat/chat-panel.spec.tsx

@@ -156,6 +156,13 @@ describe("chat-panel agent reference integration", () => {
     expect(source).toContain("aiWorkflowMode,")
   })
 
+  it("injects requiredToolsOnce for non-fast chapter writing via resolveRequiredToolsOnce", () => {
+    expect(source).toContain('import { resolveRequiredToolsOnce } from "@/lib/agent/required-tools-gate"')
+    expect(source).toContain("resolveRequiredToolsOnce({")
+    expect(source).toContain("...(requiredToolsOnce ? { requiredToolsOnce } : {})")
+    expect(source).toContain("必须调用 run_chapter_workflow 工具;未调用前禁止输出章节终稿正文。")
+  })
+
   it("does not bypass ReAct for chapter workflow execution", () => {
     expect(source).toContain("runAiChatSession")
     expect(source).toContain("run_chapter_workflow")

+ 10 - 1
src/components/chat/chat-panel.tsx

@@ -39,6 +39,7 @@ 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 { runDraftReviewSkill } from "@/lib/agent/skills/draft-review-skill"
 import { useDraftReviewStore } from "@/stores/draft-review-store"
 import { shouldKeepAwakeForWriting, withWritingWakeLock } from "@/lib/writing-wake-lock"
@@ -324,7 +325,7 @@ function buildChatAgentSystemPrompt(options: {
     if (options.aiWorkflowMode === "fast") {
       lines.push("快速模式下可以读取必要上下文;除非用户明确要求使用工作流或 Skill,否则不要主动调用 run_chapter_workflow。")
     } else {
-      lines.push("章节生成、续写、改写或润色应优先调用 run_chapter_workflow 工具。")
+      lines.push("章节生成、续写、改写或润色必须调用 run_chapter_workflow 工具;未调用前禁止输出章节终稿正文。")
     }
   }
   if (options.aiWorkflowMode) {
@@ -1789,6 +1790,13 @@ export function ChatPanel() {
           intent: effectiveTaskRoute?.intent,
           planExecuteActive,
         })
+        const requiredToolsOnce = resolveRequiredToolsOnce({
+          novelMode,
+          intent: effectiveTaskRoute?.intent,
+          mode: aiWorkflowMode,
+          planExecuteActive,
+          enabledToolNames: prePluginResult?.enabledToolNames,
+        })
         const record = await withWritingWakeLock(keepAwake, () => runAiChatSession({
           userMessage: plainText,
           projectPath,
@@ -1797,6 +1805,7 @@ export function ChatPanel() {
             systemPrompt: systemPromptForConfig,
             projectPath,
             taskGoal: plainText,
+            ...(requiredToolsOnce ? { requiredToolsOnce } : {}),
             requestOverrides: {
               ...agentConfig.requestOverrides,
               userMemorySurface: "ai-chat",

+ 3 - 0
src/lib/agent/plan-execute-policy.spec.ts

@@ -12,18 +12,21 @@ describe("Plan Execute policy", () => {
     expect(shouldRequirePlan(true, "fast", "write_chapter")).toBe(true)
     expect(buildPlanExecutePolicyPrompt("fast")).toContain("快速模式")
     expect(buildPlanExecutePolicyPrompt("fast")).toContain("先给出最短可执行计划")
+    expect(buildPlanExecutePolicyPrompt("fast")).toContain("必须调用 run_chapter_workflow")
   })
 
   it("creates a lightweight plan in standard mode when Plan Execute is enabled", () => {
     expect(shouldRequirePlan(true, "standard", "write_chapter")).toBe(true)
     expect(buildPlanExecutePolicyPrompt("standard")).toContain("标准模式")
     expect(buildPlanExecutePolicyPrompt("standard")).toContain("先创建轻量计划")
+    expect(buildPlanExecutePolicyPrompt("standard")).toContain("必须调用 run_chapter_workflow")
   })
 
   it("requires plan execute and review in strict mode when Plan Execute is enabled", () => {
     expect(shouldRequirePlan(true, "strict", "rewrite_chapter")).toBe(true)
     expect(buildPlanExecutePolicyPrompt("strict")).toContain("必须先计划")
     expect(buildPlanExecutePolicyPrompt("strict")).toContain("执行后审查")
+    expect(buildPlanExecutePolicyPrompt("strict")).toContain("必须调用 run_chapter_workflow")
   })
 
   it("requires a user-facing executable plan instead of workflow narration", () => {

+ 4 - 4
src/lib/agent/plan-execute-policy.ts

@@ -27,7 +27,7 @@ export function buildPlanExecutePolicyPrompt(mode: LegacyAiWorkflowMode): string
     "计划必须整体包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
     "输出计划后必须暂停,等待用户确认后再进入正文或执行阶段。",
     "计划必须包含:任务目标、已读取依据、缺失资料、执行步骤、确认后动作。",
-    "读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件;不要凭空编造章节、大纲或记忆条目名称。list_outlines 后按 type 分流:优先关注 overview(索引)与 concept(硬约束),对 outline 类必须读正文确认对应该章后再写。",
+    "读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件;不要凭空编造章节、大纲或记忆条目名称。list_outlines 后按文件夹分流(优先):关注 大纲(索引)、设定(硬约束)、章纲(本章主候选)、卷纲;旧 type 仅无标准文件夹时作兼容参考;对章纲/卷纲必须读正文确认对应该章后再写。",
     "如果资料缺失,必须在“缺失资料”里说明,并基于已读取内容继续制定可执行方案。",
   ].join("\n")
 
@@ -37,7 +37,7 @@ export function buildPlanExecutePolicyPrompt(mode: LegacyAiWorkflowMode): string
       "快速模式:用户已主动开启计划执行,先给出最短可执行计划再直接执行。",
       "计划最多 3 条,只写将要读取和执行的关键步骤。",
       executablePlanFormat,
-      "如果需要生成、续写、改写或润色章节,优先调用 run_chapter_workflow。",
+      "如果需要生成、续写、改写或润色章节,必须调用 run_chapter_workflow;未调用前禁止输出章节终稿。",
     ].join("\n")
   }
 
@@ -48,7 +48,7 @@ export function buildPlanExecutePolicyPrompt(mode: LegacyAiWorkflowMode): string
       "计划必须简短,最多 5 条,只写将要读取和执行的关键步骤。",
       "执行后审查结果是否满足用户请求、项目设定和输出边界。",
       executablePlanFormat,
-      "如果是章节生成、续写、改写或润色,必须优先调用 run_chapter_workflow。",
+      "如果是章节生成、续写、改写或润色,必须调用 run_chapter_workflow;未调用前禁止输出章节终稿。",
     ].join("\n")
   }
 
@@ -57,6 +57,6 @@ export function buildPlanExecutePolicyPrompt(mode: LegacyAiWorkflowMode): string
     "标准模式:先创建轻量计划,再快速执行。",
     "计划最多 3 条,不能替代正文,不能把计划混入最终章节正文。",
     executablePlanFormat,
-    "如果是章节生成、续写、改写或润色,优先调用 run_chapter_workflow。",
+    "如果是章节生成、续写、改写或润色,必须调用 run_chapter_workflow;未调用前禁止输出章节终稿。",
   ].join("\n")
 }

+ 1 - 1
src/lib/agent/plugins/build-system-prompt-plugin.spec.ts

@@ -106,7 +106,7 @@ describe("BuildSystemPromptPlugin selected skills", () => {
     expect(result.finalSystemPrompt).toContain("planBlueprint")
     expect(result.finalSystemPrompt).toContain("大纲定位协议")
     expect(result.finalSystemPrompt).toContain("list_outlines")
-    expect(result.finalSystemPrompt).toContain("按 type 分流")
+    expect(result.finalSystemPrompt).toContain("按文件夹分流")
     expect(result.finalSystemRulesPrompt).toContain("章节主编策划协议")
     expect(result.finalSystemRulesPrompt).not.toContain("context prompt")
     const finalPrompt = result.finalSystemPrompt ?? ""

+ 1 - 1
src/lib/agent/plugins/build-system-prompt-plugin.ts

@@ -97,7 +97,7 @@ function buildChapterPlanProtocol(mode: LegacyAiWorkflowMode): string {
     "输出规范:",
     "1. 计划必须整体包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
     "2. 计划只供用户确认,正文生成必须等待用户确认后再开始。当前阶段禁用正文生成类工具,只能使用读取类工具收集资料。",
-    "3. 计划必须基于会话上下文包;读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件名,绝不编造资料名称。list_outlines 后按 type 分流:优先关注 overview(索引)与 concept(硬约束),对 outline 类必须 read_outline 读正文确认对应该章后再写。",
+    "3. 计划必须基于会话上下文包;读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件名,绝不编造资料名称。list_outlines 后按文件夹分流(优先):关注 大纲(索引)、设定(硬约束)、章纲(本章主候选)、卷纲;旧 type 仅无标准文件夹时作兼容参考;对章纲/卷纲必须 read_outline 读正文确认对应该章后再写。",
     "4. 计划总长控制在 1200-1800字,避免堆砌分析维度;用结论和执行项表达。",
     "5. 场景必须用 S1/S2/S3 编号,后续正文会按编号执行。",
     "6. 不要使用旧版分析报告式编号标题。",

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

@@ -0,0 +1,159 @@
+import { describe, expect, it } from "vitest"
+import {
+  RequiredToolsNotCalledError,
+  buildRequiredToolNudgeMessage,
+  missingRequiredToolsOnce,
+  resolveRequiredToolsOnce,
+  shouldBlockFinalWithoutRequiredTools,
+} from "./required-tools-gate"
+
+describe("required-tools-gate", () => {
+  it("does not block when no required tools configured", () => {
+    expect(
+      shouldBlockFinalWithoutRequiredTools({
+        availableToolNames: ["run_chapter_workflow"],
+        calledToolNames: [],
+        toolsEnabled: true,
+      }),
+    ).toBe(false)
+  })
+
+  it("blocks when required tool is available but not called", () => {
+    const missing = missingRequiredToolsOnce({
+      requiredToolsOnce: ["run_chapter_workflow"],
+      availableToolNames: ["read_chapter", "run_chapter_workflow"],
+      calledToolNames: ["read_chapter"],
+      toolsEnabled: true,
+    })
+    expect(missing).toEqual(["run_chapter_workflow"])
+    expect(
+      shouldBlockFinalWithoutRequiredTools({
+        requiredToolsOnce: ["run_chapter_workflow"],
+        availableToolNames: ["run_chapter_workflow"],
+        calledToolNames: [],
+        toolsEnabled: true,
+      }),
+    ).toBe(true)
+  })
+
+  it("does not block when required tool was already called", () => {
+    expect(
+      shouldBlockFinalWithoutRequiredTools({
+        requiredToolsOnce: ["run_chapter_workflow"],
+        availableToolNames: ["run_chapter_workflow"],
+        calledToolNames: ["run_chapter_workflow"],
+        toolsEnabled: true,
+      }),
+    ).toBe(false)
+  })
+
+  it("does not block when tools are disabled", () => {
+    expect(
+      shouldBlockFinalWithoutRequiredTools({
+        requiredToolsOnce: ["run_chapter_workflow"],
+        availableToolNames: ["run_chapter_workflow"],
+        calledToolNames: [],
+        toolsEnabled: false,
+      }),
+    ).toBe(false)
+  })
+
+  it("ignores required tools that are not available", () => {
+    expect(
+      missingRequiredToolsOnce({
+        requiredToolsOnce: ["run_chapter_workflow"],
+        availableToolNames: ["read_chapter"],
+        calledToolNames: [],
+        toolsEnabled: true,
+      }),
+    ).toEqual([])
+  })
+
+  it("builds a Chinese nudge that forbids direct final prose", () => {
+    const message = buildRequiredToolNudgeMessage(["run_chapter_workflow"])
+    expect(message).toContain("禁止直接输出章节终稿")
+    expect(message).toContain("run_chapter_workflow")
+  })
+
+  it("RequiredToolsNotCalledError names missing tools", () => {
+    const err = new RequiredToolsNotCalledError(["run_chapter_workflow"])
+    expect(err.name).toBe("RequiredToolsNotCalledError")
+    expect(err.message).toContain("run_chapter_workflow")
+    expect(err.missingTools).toEqual(["run_chapter_workflow"])
+  })
+})
+
+describe("resolveRequiredToolsOnce", () => {
+  it("requires workflow for non-fast chapter writing when tool is enabled", () => {
+    expect(
+      resolveRequiredToolsOnce({
+        novelMode: true,
+        intent: "write_chapter",
+        mode: "strict",
+        planExecuteActive: false,
+        enabledToolNames: ["read_chapter", "run_chapter_workflow"],
+      }),
+    ).toEqual(["run_chapter_workflow"])
+
+    expect(
+      resolveRequiredToolsOnce({
+        novelMode: true,
+        intent: "polish_chapter",
+        mode: "standard",
+        planExecuteActive: false,
+      }),
+    ).toEqual(["run_chapter_workflow"])
+  })
+
+  it("skips fast mode, plan phase, non-novel, and non-writing intents", () => {
+    expect(
+      resolveRequiredToolsOnce({
+        novelMode: true,
+        intent: "write_chapter",
+        mode: "fast",
+        planExecuteActive: false,
+        enabledToolNames: ["run_chapter_workflow"],
+      }),
+    ).toBeUndefined()
+
+    expect(
+      resolveRequiredToolsOnce({
+        novelMode: true,
+        intent: "write_chapter",
+        mode: "strict",
+        planExecuteActive: true,
+        enabledToolNames: ["run_chapter_workflow"],
+      }),
+    ).toBeUndefined()
+
+    expect(
+      resolveRequiredToolsOnce({
+        novelMode: false,
+        intent: "write_chapter",
+        mode: "strict",
+        planExecuteActive: false,
+      }),
+    ).toBeUndefined()
+
+    expect(
+      resolveRequiredToolsOnce({
+        novelMode: true,
+        intent: "character_query",
+        mode: "strict",
+        planExecuteActive: false,
+      }),
+    ).toBeUndefined()
+  })
+
+  it("skips when enabledToolNames explicitly omits the workflow tool", () => {
+    expect(
+      resolveRequiredToolsOnce({
+        novelMode: true,
+        intent: "write_chapter",
+        mode: "strict",
+        planExecuteActive: false,
+        enabledToolNames: ["read_chapter", "read_outline"],
+      }),
+    ).toBeUndefined()
+  })
+})

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

@@ -0,0 +1,78 @@
+import type { NovelTaskIntent } from "@/lib/novel/task-router"
+import { OUTLINE_FIND_CHAPTER_INTENTS } from "@/lib/novel/outline-find-protocol"
+import { resolveAiWorkflowMode, type LegacyAiWorkflowMode } from "./workflow-mode"
+
+export interface RequiredToolsGateInput {
+  requiredToolsOnce?: string[] | null
+  availableToolNames: Iterable<string>
+  calledToolNames: Iterable<string>
+  toolsEnabled: boolean
+}
+
+/** 返回仍缺失的必调工具名;空数组表示不拦截。 */
+export function missingRequiredToolsOnce(input: RequiredToolsGateInput): string[] {
+  const required = (input.requiredToolsOnce ?? []).filter((name) => name.trim())
+  if (!input.toolsEnabled || required.length === 0) return []
+
+  const available = new Set(input.availableToolNames)
+  const applicable = required.filter((name) => available.has(name))
+  if (applicable.length === 0) return []
+
+  const called = new Set(input.calledToolNames)
+  return applicable.filter((name) => !called.has(name))
+}
+
+export function shouldBlockFinalWithoutRequiredTools(input: RequiredToolsGateInput): boolean {
+  return missingRequiredToolsOnce(input).length > 0
+}
+
+export function buildRequiredToolNudgeMessage(missing: string[]): string {
+  const names = missing.join("、")
+  return [
+    "系统约束:禁止直接输出章节终稿正文。",
+    `本轮必须先调用以下工具:${names}。`,
+    "请立即调用 run_chapter_workflow(可先补读必要上下文),不要把章节正文作为本轮最终回复。",
+  ].join("\n")
+}
+
+export class RequiredToolsNotCalledError extends Error {
+  readonly missingTools: string[]
+
+  constructor(missingTools: string[]) {
+    super(
+      `章节写作未调用必选工具(${missingTools.join("、")}),已拒绝未走工作流的终稿。请重试并调用 run_chapter_workflow。`,
+    )
+    this.name = "RequiredToolsNotCalledError"
+    this.missingTools = missingTools
+  }
+}
+
+export interface ResolveRequiredToolsOnceInput {
+  novelMode: boolean
+  intent?: string | null
+  mode?: LegacyAiWorkflowMode | null
+  planExecuteActive: boolean
+  enabledToolNames?: string[] | null
+}
+
+/**
+ * 非 fast 章节写作、且工具可用、非计划收集阶段时,要求至少调用一次 run_chapter_workflow。
+ */
+export function resolveRequiredToolsOnce(
+  input: ResolveRequiredToolsOnceInput,
+): string[] | undefined {
+  if (!input.novelMode) return undefined
+  if (input.planExecuteActive) return undefined
+  if (!input.intent || !OUTLINE_FIND_CHAPTER_INTENTS.has(input.intent as NovelTaskIntent)) {
+    return undefined
+  }
+  const mode = input.mode == null ? "strict" : resolveAiWorkflowMode(input.mode)
+  if (mode === "fast") return undefined
+
+  const enabled = input.enabledToolNames
+  if (Array.isArray(enabled) && !enabled.includes("run_chapter_workflow")) {
+    return undefined
+  }
+
+  return ["run_chapter_workflow"]
+}

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

@@ -88,6 +88,205 @@ describe("AgentRunner", () => {
     expect(callbacks.onError).not.toHaveBeenCalled()
   })
 
+  it("nudges and continues when requiredToolsOnce is missing on a no-tool final", async () => {
+    const tool: Tool = {
+      name: "run_chapter_workflow",
+      description: "workflow",
+      category: "action",
+      parameters: {
+        userRequest: { type: "string", description: "request", required: true },
+      },
+      execute: vi.fn().mockResolvedValue("最终正文:\n章节内容"),
+    }
+    registry.register(tool)
+
+    mockStreamChat
+      .mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+        cb.onToken("我直接写完了")
+        cb.onDone()
+      })
+      .mockImplementationOnce(async (_config: unknown, msgs: unknown[], cb: StreamCallbacks) => {
+        const messages = msgs as AgentMessage[]
+        expect(messages.some((message) =>
+          message.role === "system" &&
+          typeof message.content === "string" &&
+          message.content.includes("禁止直接输出章节终稿"),
+        )).toBe(true)
+        cb.onToolCallDelta?.({ index: 0, id: "call_workflow_1", name: "run_chapter_workflow" })
+        cb.onToolCallDelta?.({ index: 0, arguments: '{"userRequest":"写第1章"}' })
+        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(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+    const result = await runner.run(
+      {
+        maxRounds: 4,
+        tools: [tool],
+        systemPrompt: "You are helpful",
+        llmConfig: mockLlmConfig,
+        requiredToolsOnce: ["run_chapter_workflow"],
+      },
+      registry,
+      [systemMsg, userMsg],
+      callbacks,
+      undefined,
+    )
+
+    expect(tool.execute).toHaveBeenCalledOnce()
+    expect(callbacks.onDone).toHaveBeenCalledOnce()
+    expect(callbacks.onError).not.toHaveBeenCalled()
+    expect(result.finalText).toBe("最终交付正文")
+    expect(result.roundsUsed).toBe(3)
+    // blocked draft must not stream as final
+    expect(callbacks.onText.mock.calls.flat()).not.toContain("我直接写完了")
+  })
+
+  it("errors on last round when requiredToolsOnce is never called", async () => {
+    const tool: Tool = {
+      name: "run_chapter_workflow",
+      description: "workflow",
+      category: "action",
+      parameters: {
+        userRequest: { type: "string", description: "request", required: true },
+      },
+      execute: vi.fn().mockResolvedValue("ok"),
+    }
+    registry.register(tool)
+
+    mockStreamChat.mockImplementation(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(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+    const result = await runner.run(
+      {
+        maxRounds: 2,
+        tools: [tool],
+        systemPrompt: "You are helpful",
+        llmConfig: mockLlmConfig,
+        requiredToolsOnce: ["run_chapter_workflow"],
+      },
+      registry,
+      [systemMsg, userMsg],
+      callbacks,
+      undefined,
+    )
+
+    expect(callbacks.onDone).not.toHaveBeenCalled()
+    expect(callbacks.onError).toHaveBeenCalledOnce()
+    expect(callbacks.onError.mock.calls[0][0].name).toBe("RequiredToolsNotCalledError")
+    expect(result.finalText).toBe("")
+    expect(tool.execute).not.toHaveBeenCalled()
+  })
+
+  it("does not block no-tool finals when requiredToolsOnce is unset", async () => {
+    const tool: Tool = {
+      name: "run_chapter_workflow",
+      description: "workflow",
+      category: "action",
+      parameters: {},
+      execute: vi.fn().mockResolvedValue("ok"),
+    }
+    registry.register(tool)
+
+    mockStreamChat.mockImplementation(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(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+    const result = await runner.run(
+      {
+        maxRounds: 2,
+        tools: [tool],
+        systemPrompt: "You are helpful",
+        llmConfig: mockLlmConfig,
+      },
+      registry,
+      [systemMsg, userMsg],
+      callbacks,
+      undefined,
+    )
+
+    expect(result.finalText).toBe("普通回复")
+    expect(callbacks.onDone).toHaveBeenCalledOnce()
+    expect(callbacks.onError).not.toHaveBeenCalled()
+  })
+
+  it("skips required-tools gate after tools fallback disables tools", async () => {
+    const tool: Tool = {
+      name: "run_chapter_workflow",
+      description: "workflow",
+      category: "action",
+      parameters: {},
+      execute: vi.fn().mockResolvedValue("ok"),
+    }
+    registry.register(tool)
+
+    let callCount = 0
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks, _signal?: unknown, overrides?: { tools?: unknown }) => {
+      callCount += 1
+      if (callCount === 1 && overrides?.tools) {
+        cb.onError?.(new Error("tools are not supported"))
+        return
+      }
+      cb.onToken("无工具回退正文")
+      cb.onDone()
+    })
+
+    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: "You are helpful",
+        llmConfig: mockLlmConfig,
+        requiredToolsOnce: ["run_chapter_workflow"],
+      },
+      registry,
+      [systemMsg, userMsg],
+      callbacks,
+      undefined,
+    )
+
+    expect(result.finalText).toBe("无工具回退正文")
+    expect(callbacks.onDone).toHaveBeenCalledOnce()
+    expect(callbacks.onError).not.toHaveBeenCalled()
+  })
+
   it("replays reasoning_content on tool-call assistant messages in the next round", async () => {
     const tool: Tool = {
       name: "read_chapter",

+ 32 - 0
src/lib/agent/runner.ts

@@ -19,6 +19,11 @@ import { addLlmUsage } from "../llm-usage"
 import { trimChatMessagesToBudget } from "../chat-request-budget"
 import { logReasoningReplay } from "../reasoning-replay-debug"
 import { ToolEvidenceLedger } from "./tool-evidence-ledger"
+import {
+  RequiredToolsNotCalledError,
+  buildRequiredToolNudgeMessage,
+  missingRequiredToolsOnce,
+} from "./required-tools-gate"
 
 export class ModelDoesNotSupportToolsError extends Error {
   constructor() {
@@ -256,6 +261,33 @@ export class AgentRunner {
       }
 
       if (toolCalls.length === 0) {
+        const missingRequired = missingRequiredToolsOnce({
+          requiredToolsOnce: config.requiredToolsOnce,
+          availableToolNames: config.tools.map((tool) => tool.name),
+          calledToolNames: record.toolCalls.map((call) => call.name),
+          toolsEnabled: Boolean(openaiTools),
+        })
+        if (missingRequired.length > 0) {
+          if (roundText.trim() || roundReasoningContent) {
+            workingMessages.push({
+              role: "assistant",
+              content: roundText || "",
+              reasoning_content: roundReasoningContent,
+            })
+          }
+          workingMessages.push({
+            role: "system",
+            content: buildRequiredToolNudgeMessage(missingRequired),
+          })
+          const isLastRound = round >= maxRounds - 1
+          if (isLastRound) {
+            await clearPersistedBreakpoint()
+            callbacks.onError(new RequiredToolsNotCalledError(missingRequired))
+            return record
+          }
+          continue
+        }
+
         finalText = roundText
         record.finalText = finalText
         if (roundText) callbacks.onText(roundText)

+ 1 - 1
src/lib/agent/tools/list-outlines.ts

@@ -33,7 +33,7 @@ export function createListOutlinesTool(
   return {
     name: "list_outlines",
     description:
-      "列出大纲目录下全部 Markdown 文件(含子目录),并标注 frontmatter 的 type / outline_type。可选参数 chapterNumber 用于在结果中标注本次目标章号。写章节前应先调用本工具,再按 type 分流并用 read_outline 读正文判断哪份对应该章。",
+      "列出大纲目录下全部 Markdown 文件(含子目录),优先标注标准文件夹(大纲/卷纲/章纲/设定等),旧 frontmatter 的 type / outline_type 仅作兼容附加。可选参数 chapterNumber 用于在结果中标注本次目标章号。写章节前应先调用本工具,再按文件夹分流并用 read_outline 读正文判断哪份对应该章。",
     category: "read",
     parameters: {
       chapterNumber: {

+ 11 - 3
src/lib/agent/tools/list-tools.spec.ts

@@ -43,12 +43,13 @@ describe("list tools", () => {
     expect(result).toContain("本次目标章号:第 167 章")
   })
 
-  it("list_outlines recursively lists files with type annotations", async () => {
+  it("list_outlines recursively lists files with folder-first annotations", async () => {
     vi.mocked(listDirectory).mockImplementation(async (path: string) => {
       if (path.endsWith("/outlines")) {
         return [
           { name: "全局设定.md", path: `${path}/全局设定.md`, is_dir: false },
           { name: "卷纲", path: `${path}/卷纲`, is_dir: true },
+          { name: "章纲", path: `${path}/章纲`, is_dir: true },
         ]
       }
       if (path.endsWith("/卷纲")) {
@@ -56,6 +57,11 @@ describe("list tools", () => {
           { name: "第三卷大纲.md", path: `${path}/第三卷大纲.md`, is_dir: false },
         ]
       }
+      if (path.endsWith("/章纲")) {
+        return [
+          { name: "第52章-条件.md", path: `${path}/第52章-条件.md`, is_dir: false },
+        ]
+      }
       return []
     })
     vi.mocked(readFile).mockImplementation(async (path: string) => {
@@ -65,14 +71,16 @@ describe("list tools", () => {
       if (path.includes("第三卷大纲")) {
         return `---\ntype: outline\ntitle: "第三卷"\n---\n`
       }
-      return ""
+      return "# 纯 Markdown 章纲\n"
     })
 
     const tool = createListOutlinesTool("/project/wiki/outlines")
     const result = await tool.execute({ chapterNumber: 167 })
     expect(result).toContain("全局设定.md  type=overview")
-    expect(result).toContain("卷纲/第三卷大纲.md  type=outline")
+    expect(result).toContain("卷纲/第三卷大纲.md  folder=卷纲  type=outline")
+    expect(result).toContain("章纲/第52章-条件.md  folder=章纲")
     expect(result).toContain("本次目标章号:第 167 章")
+    expect(result).toContain("优先按文件夹(folder)分流")
   })
 
   it("list_memories returns file list from memory dir", async () => {

+ 46 - 18
src/lib/agent/tools/outline-list-helpers.spec.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from "vitest"
 import {
   buildOutlineListToolResult,
+  extractOutlineFolder,
   extractOutlineTypeFields,
   formatOutlineListLine,
   type OutlineListEntry,
@@ -29,46 +30,73 @@ title: "规则"
     expect(extractOutlineTypeFields("# 无 frontmatter")).toEqual({})
   })
 
-  it("formats list lines with type fields", () => {
-    const entry: OutlineListEntry = {
+  it("extracts standard outline folder from relative path", () => {
+    expect(extractOutlineFolder("章纲/第52章-条件.md")).toBe("章纲")
+    expect(extractOutlineFolder("设定/写作通则.md")).toBe("设定")
+    expect(extractOutlineFolder("第三卷大纲.md")).toBeUndefined()
+    expect(extractOutlineFolder("其他/说明.md")).toBeUndefined()
+  })
+
+  it("formats list lines with folder first, then optional type fields", () => {
+    const withFolderAndType: OutlineListEntry = {
       relativePath: "卷纲/第三卷.md",
       absolutePath: "/p/wiki/outlines/卷纲/第三卷.md",
+      folder: "卷纲",
       type: "outline",
       outlineType: "volume-outline",
     }
-    expect(formatOutlineListLine(entry, 0)).toBe(
-      "1. 卷纲/第三卷.md  type=outline  outline_type=volume-outline",
+    expect(formatOutlineListLine(withFolderAndType, 0)).toBe(
+      "1. 卷纲/第三卷.md  folder=卷纲  type=outline  outline_type=volume-outline",
+    )
+
+    const folderOnly: OutlineListEntry = {
+      relativePath: "章纲/第52章-条件.md",
+      absolutePath: "/p/wiki/outlines/章纲/第52章-条件.md",
+      folder: "章纲",
+    }
+    expect(formatOutlineListLine(folderOnly, 1)).toBe(
+      "2. 章纲/第52章-条件.md  folder=章纲",
     )
   })
 
-  it("builds tool result with mixed types and optional target chapter", () => {
+  it("builds tool result with folder-first guidance and optional target chapter", () => {
     const result = buildOutlineListToolResult(
       [
         {
-          relativePath: "第三卷大纲.md",
-          absolutePath: "/o/第三卷大纲.md",
+          relativePath: "章纲/第52章-条件.md",
+          absolutePath: "/o/章纲/第52章-条件.md",
+          folder: "章纲",
+        },
+        {
+          relativePath: "设定/写作通则.md",
+          absolutePath: "/o/设定/写作通则.md",
+          folder: "设定",
+        },
+        {
+          relativePath: "卷纲/第三卷.md",
+          absolutePath: "/o/卷纲/第三卷.md",
+          folder: "卷纲",
           type: "outline",
+          outlineType: "volume-outline",
         },
         {
           relativePath: "全局设定.md",
           absolutePath: "/o/全局设定.md",
           type: "overview",
         },
-        {
-          relativePath: "钢铁洪流系统规则.md",
-          absolutePath: "/o/钢铁洪流系统规则.md",
-          type: "concept",
-        },
       ],
       167,
     )
 
-    expect(result).toContain("1. 第三卷大纲.md  type=outline")
-    expect(result).toContain("2. 全局设定.md  type=overview")
-    expect(result).toContain("3. 钢铁洪流系统规则.md  type=concept")
+    expect(result).toContain("1. 章纲/第52章-条件.md  folder=章纲")
+    expect(result).toContain("2. 设定/写作通则.md  folder=设定")
+    expect(result).toContain("3. 卷纲/第三卷.md  folder=卷纲  type=outline  outline_type=volume-outline")
+    expect(result).toContain("4. 全局设定.md  type=overview")
     expect(result).toContain("本次目标章号:第 167 章")
-    expect(result).toContain("overview:优先当索引读")
-    expect(result).toContain("concept:全书硬约束")
-    expect(result).toContain("不要跳过 overview/concept")
+    expect(result).toContain("优先按文件夹(folder)分流")
+    expect(result).toContain("章纲:本章主候选")
+    expect(result).toContain("设定:全书硬约束")
+    expect(result).toContain("旧兼容:overview≈大纲")
+    expect(result).toContain("优先在章纲中定位")
   })
 })

+ 28 - 6
src/lib/agent/tools/outline-list-helpers.ts

@@ -1,12 +1,21 @@
+/**
+ * list_outlines 列表与标注:文件夹优先,旧 frontmatter type 仅作兼容附加。
+ */
+
 import { listDirectory, readFile } from "@/commands/fs"
 import { parseFrontmatter } from "@/lib/frontmatter"
+import { DEFAULT_OUTLINE_FOLDERS } from "@/lib/novel/outline-workbench"
 import { normalizePath } from "@/lib/path-utils"
 import type { FileNode } from "@/types/wiki"
 
+const STANDARD_OUTLINE_FOLDERS = new Set(DEFAULT_OUTLINE_FOLDERS.map((folder) => folder.name))
+
 export interface OutlineListEntry {
   /** 相对 outlines 根目录的路径,含 .md */
   relativePath: string
   absolutePath: string
+  /** 路径首段命中标准大纲文件夹时 */
+  folder?: string
   type?: string
   outlineType?: string
 }
@@ -33,6 +42,13 @@ export function extractOutlineTypeFields(content: string): {
   }
 }
 
+/** 从相对路径首段解析标准大纲文件夹名 */
+export function extractOutlineFolder(relativePath: string): string | undefined {
+  const first = relativePath.split("/").filter(Boolean)[0]
+  if (!first || first.toLowerCase().endsWith(".md")) return undefined
+  return STANDARD_OUTLINE_FOLDERS.has(first) ? first : undefined
+}
+
 async function collectOutlineMarkdownFiles(
   rootDir: string,
   depth = 0,
@@ -69,6 +85,7 @@ function toRelativePath(outlinesDir: string, absolutePath: string): string {
 
 export function formatOutlineListLine(entry: OutlineListEntry, index: number): string {
   const parts = [`${index + 1}. ${entry.relativePath}`]
+  if (entry.folder) parts.push(`folder=${entry.folder}`)
   if (entry.type) parts.push(`type=${entry.type}`)
   if (entry.outlineType) parts.push(`outline_type=${entry.outlineType}`)
   return parts.join("  ")
@@ -85,6 +102,7 @@ export async function listOutlineEntries(
 
   for (const file of files) {
     const relativePath = toRelativePath(outlinesDir, file.path) || file.name
+    const folder = extractOutlineFolder(relativePath)
     let type: string | undefined
     let outlineType: string | undefined
     try {
@@ -99,6 +117,7 @@ export async function listOutlineEntries(
     entries.push({
       relativePath,
       absolutePath: file.path,
+      folder,
       type,
       outlineType,
     })
@@ -122,16 +141,19 @@ export function buildOutlineListToolResult(
     "可用大纲列表:",
     ...entries.map((entry, index) => formatOutlineListLine(entry, index)),
     "",
-    "说明:请根据 frontmatter 的 type / outline_type 分流。",
-    "- overview:优先当索引读,用来发现规则与卷纲入口。",
-    "- concept:全书硬约束/机制,写正文前关注并读取相关项;不要用章号匹配。",
-    "- outline:读正文判断是否对目标章;同为 outline 也可能不是卷纲。",
-    "不要只凭文件名选择;不要跳过 overview/concept 只读一份卷纲。",
+    "说明:优先按文件夹(folder)分流;无标准文件夹时再参考旧 frontmatter 的 type / outline_type。",
+    "- 大纲:索引/总纲入口,用来发现规则与卷纲入口;通常不是本章剧情细纲。",
+    "- 设定:全书硬约束/机制,写正文前关注并读取相关项;不要用章号匹配。",
+    "- 章纲:本章主候选,按目标章号定位后读正文。",
+    "- 卷纲:卷级候选,确认章号落在该卷后再读。",
+    "- 人物小传 / 伏笔 / 组织:辅助资料,不当本章剧情大纲。",
+    "- 旧兼容:overview≈大纲,concept≈设定,outline≈卷纲/章纲(视 outline_type)。",
+    "不要只凭文件名选择;不要跳过 大纲/设定(或旧 overview/concept)只读一份卷纲。",
   ]
 
   if (typeof targetChapterNumber === "number" && targetChapterNumber > 0) {
     lines.push(
-      `本次目标章号:第 ${targetChapterNumber} 章。请结合 overview/concept 约束,为该章找到正文内容对应的大纲。`,
+      `本次目标章号:第 ${targetChapterNumber} 章。请优先在章纲中定位,并结合设定/大纲约束,为该章找到对应大纲。`,
     )
   }
 

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

@@ -58,6 +58,11 @@ export interface AgentConfig {
   projectPath?: string
   /** Stage F: 本次任务目标,用于断点恢复 */
   taskGoal?: string
+  /**
+   * 本 run 结束前必须至少发起过一次的工具名。
+   * 缺则拒绝无 tool 终稿并续轮(见 AgentRunner required-tools gate)。
+   */
+  requiredToolsOnce?: string[]
 }
 
 export interface AgentToolEvent {

+ 10 - 3
src/lib/novel/outline-find-protocol.spec.ts

@@ -11,11 +11,18 @@ describe("outline-find-protocol", () => {
     const text = buildOutlineFindProtocol(167)
     expect(text).toContain("本次写作目标:第 167 章")
     expect(text).toContain("list_outlines")
-    expect(text).toContain("overview(高优先级入口)")
-    expect(text).toContain("concept / 设定类(写作硬约束)")
-    expect(text).toContain("禁止在未查看 overview/concept")
+    expect(text).toContain("优先扫 folder")
+    expect(text).toContain("按文件夹分流(主路径")
+    expect(text).toContain("章纲:本章主候选")
+    expect(text).toContain("设定:写作硬约束")
+    expect(text).toContain("兼容(非强制)")
+    expect(text).toContain("overview≈大纲")
     expect(text).toContain("read_outline")
     expect(text).toContain("禁止只凭文件名")
+    expect(text).toContain("必须调用 run_chapter_workflow")
+    expect(text).toContain("禁止直接输出终稿正文")
+    expect(text).not.toContain("再写作或调用")
+    expect(text).not.toContain("先扫一遍有哪些 overview / concept / outline")
   })
 
   it("asks model to resolve chapter number when missing", () => {

+ 12 - 10
src/lib/novel/outline-find-protocol.ts

@@ -1,5 +1,5 @@
 /**
- * 写作前找大纲协议:先钉目标章号,再按 type 分流,最后读正文判断归属。
+ * 写作前找大纲协议:先钉目标章号,再按文件夹分流(主路径),旧 type 仅作兼容,最后读正文判断归属。
  * 不依赖文件名规范,也不要求大纲正文写法统一。
  */
 
@@ -27,15 +27,17 @@ export function buildOutlineFindProtocol(targetChapterNumber?: number): string {
     "## 大纲定位协议(写章节前必须遵守)",
     "",
     targetLine,
-    "1. 调用 list_outlines 查看全部大纲候选及其 type / outline_type;先扫一遍有哪些 overview / concept / outline,不要只盯卷纲文件名。",
-    "2. 按 type 分流处理(有 type 用 type;无 type 则读正文判断用途):",
-    "   - overview(高优先级入口):应优先 read_outline 读索引,按 related / 文档表发现必须遵守的规则文档与卷纲入口;不要跳过 overview 直接瞎点卷纲。overview 本身不是本章剧情大纲。",
-    "   - concept / 设定类(写作硬约束):列表中出现的 concept 默认视为全书机制/叙述禁则;写正文前应至少读与本次任务相关的 concept(有 overview 指引时按指引读;无指引时对列出的 concept 做必要性判断并读取关键项)。不要用章号去「匹配」concept,也不要把它们当成卷纲。",
-    "   - outline(及 outline_type 为 story/volume/chapter-outline 等):主候选;必须 read_outline 读正文,判断是否对应该章 / 当前阶段。",
-    "   - 未知 type:必须读正文判断是卷纲、章纲、设定还是清单。",
-    "3. 同为 outline 也可能不是卷纲(如资产明细、人物表);禁止只看 type 或文件名就选定。",
-    "4. 确认对应该章的大纲,并已知相关 overview/concept 约束后,再写作或调用 run_chapter_workflow。",
-    "禁止只凭文件名猜测分卷;禁止把 concept/overview 当成章节剧情大纲;禁止在未查看 overview/concept 的情况下只读一份卷纲就开写。",
+    "1. 调用 list_outlines 查看全部大纲候选;优先扫 folder(大纲 / 设定 / 章纲 / 卷纲 / 人物小传等),不要只盯卷纲文件名。",
+    "2. 按文件夹分流(主路径;新项目默认无 type):",
+    "   - 大纲:索引/总纲入口;应优先 read_outline 读索引,发现必须遵守的规则文档与卷纲入口;本身通常不是本章剧情细纲。",
+    "   - 设定:写作硬约束/机制;写正文前应至少读与本次任务相关的设定。不要用章号去「匹配」设定,也不要把它们当成卷纲。",
+    "   - 章纲:本章主候选;必须 read_outline 读正文,按目标章号定位对应章纲。",
+    "   - 卷纲:卷级候选;确认章号落在该卷后再读。",
+    "   - 人物小传 / 伏笔 / 组织:辅助资料,不当本章剧情大纲。",
+    "3. 兼容(非强制):若几乎无标准文件夹、却有旧 frontmatter type,可参考 overview≈大纲、concept≈设定、outline≈卷纲/章纲;不要求新项目补 type。",
+    "4. 文件夹与 type 都缺失时,必须读正文判断是卷纲、章纲、设定还是清单;禁止只看文件名就选定。",
+    "5. 确认对应该章的大纲,并已知相关 大纲/设定(或旧 overview/concept)约束后,必须调用 run_chapter_workflow;禁止直接输出终稿正文。",
+    "禁止只凭文件名猜测分卷;禁止把设定/人物小传(或旧 concept/overview)当成章节剧情大纲;禁止在未查看 大纲/设定 的情况下只读一份卷纲就开写。",
   ].join("\n")
 }
 

+ 1 - 1
src/lib/novel/task-router.ts

@@ -466,7 +466,7 @@ export function buildTaskDirective(route: TaskRouteResult): string {
   ) {
     lines.push(`本次写作目标:第 ${route.chapterNumber} 章。`)
     lines.push(
-      "写正文前先 list_outlines(可传 chapterNumber),再按 type 分流并用 read_outline 读正文,确认对应该章的大纲后再写。",
+      "写正文前先 list_outlines(可传 chapterNumber),再按文件夹分流(优先;旧 type 仅兼容)并用 read_outline 读正文,确认对应该章的大纲后再写。",
     )
   }