Browse Source

fix(agent): 修复大纲写入缺少 .md 仍进入确认态

自动补全 outlineName 扩展名,预览失败标为 error,重复检测按规范化文件名分组。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 2 tháng trước cách đây
mục cha
commit
d7cff3781f

+ 4 - 2
src/components/chat/agent-workflow-panel.tsx

@@ -2,6 +2,7 @@ import { useMemo, useRef, useEffect } from "react"
 import { getWorkflowToolDescription } from "@/lib/agent/workflow-trace"
 import type { AgentRunRecord } from "@/lib/agent/types"
 import type { ContextTrace } from "@/lib/agent/context-trace"
+import { normalizeOutlineWriteTarget } from "@/lib/agent/tools/write-outline-node"
 import { createStreamingEventBuilder, compareToolCallsByStartedAt, filterToolCallsForDisplay } from "@/components/common/timeline-types"
 import type { ToolCallEventItem, TimelineToolCategory } from "@/components/common/timeline-types"
 import { EventStream } from "@/components/common/event-stream"
@@ -115,10 +116,11 @@ export function AgentWorkflowPanel({
     const groupedByFile = new Map<string, typeof pendingApprovalCalls>()
     for (const call of pendingApprovalCalls) {
       if (call.name !== "write_outline_node") continue
-      const outlineName = (call.params as Record<string, unknown>)?.outlineName as
+      const rawName = (call.params as Record<string, unknown>)?.outlineName as
         | string
         | undefined
-      if (!outlineName) continue
+      if (!rawName) continue
+      const outlineName = normalizeOutlineWriteTarget(rawName)
       const existing = groupedByFile.get(outlineName) ?? []
       existing.push(call)
       groupedByFile.set(outlineName, existing)

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

@@ -673,6 +673,59 @@ describe("AgentRunner", () => {
     expect(onToolEvent).toHaveBeenCalledWith(expect.objectContaining({ type: "approval_required", callId: "write_1", preview: "written preview content" }))
   })
 
+  it("marks confirm-required tools as error when preview generation throws", async () => {
+    const tool: Tool = {
+      name: "write_outline_node",
+      description: "",
+      category: "write",
+      permission: "confirm",
+      parameters: {},
+      generatePreview: vi.fn().mockRejectedValue(new Error("无法写入大纲:大纲文件名称必须是 Markdown 文件。")),
+      execute: vi.fn(),
+    }
+    registry.register(tool)
+
+    let callCount = 0
+    mockStreamChat.mockImplementation(async (_config: unknown, messages: AgentMessage[], cb: StreamCallbacks) => {
+      callCount++
+      if (callCount === 1) {
+        cb.onToolCallDelta?.({ index: 0, id: "write_outline_1", name: "write_outline_node" })
+        cb.onToolCallDelta?.({ index: 0, arguments: '{"outlineName":"坏目标.txt","nodeTitle":"节点","nodeContent":"正文"}' })
+        cb.onDone()
+      } else {
+        expect(messages[messages.length - 1].content).toContain("预览生成失败")
+        cb.onToken("写入目标无效,已跳过确认。")
+        cb.onDone()
+      }
+    })
+
+    const onToolError = vi.fn()
+    const onToolEvent = vi.fn()
+    const config: AgentConfig = { maxRounds: 3, tools: [tool], systemPrompt: "", llmConfig: mockLlmConfig }
+    const result = await runner.run(
+      config,
+      registry,
+      [systemMsg, userMsg],
+      {
+        onText: vi.fn(),
+        onToolCall: vi.fn(),
+        onToolResult: vi.fn(),
+        onToolError,
+        onToolEvent,
+        onDone: vi.fn(),
+        onError: vi.fn(),
+      },
+      undefined,
+    )
+
+    expect(tool.generatePreview).toHaveBeenCalledTimes(1)
+    expect(tool.execute).not.toHaveBeenCalled()
+    expect(result.toolCalls[0].status).toBe("error")
+    expect(result.toolCalls[0].result).toContain("预览生成失败")
+    expect(onToolError).toHaveBeenCalledWith("write_outline_1", expect.stringContaining("预览生成失败"))
+    expect(onToolEvent).toHaveBeenCalledWith(expect.objectContaining({ type: "error", callId: "write_outline_1" }))
+  })
+
   it("keeps full tool result in record but sends compressed result back to the model", async () => {
     const longResult = `${"开头".repeat(2000)}\n中间内容\n${"结尾".repeat(2000)}`
     const tool: Tool = {

+ 22 - 1
src/lib/agent/runner.ts

@@ -362,7 +362,28 @@ export class AgentRunner {
             const previewFn = tool.generatePreview ?? tool.execute
             preview = await withToolTimeout(previewFn(params, signal, executionContext), tool.executeTimeoutMs)
           } catch (e) {
-            preview = `预览生成失败:${e instanceof Error ? e.message : String(e)}`
+            const errorMsg = `预览生成失败:${e instanceof Error ? e.message : String(e)}`
+            toolCallRecord.status = "error"
+            toolCallRecord.result = errorMsg
+            toolCallRecord.finishedAt = Date.now()
+            record.toolCalls.push(toolCallRecord)
+            callbacks.onToolError(tc.id, errorMsg)
+            callbacks.onToolEvent?.({
+              type: "error",
+              callId: tc.id,
+              name: toolName,
+              params,
+              result: errorMsg,
+              timestamp: toolCallRecord.finishedAt,
+            })
+            workingMessages.push({
+              role: "tool",
+              content: evidenceLedger.format(toolName, params, errorMsg),
+              tool_call_id: tc.id,
+              name: toolName,
+            })
+            await saveToolProgress()
+            continue
           }
           toolCallRecord.status = "approval_required"
           ;(toolCallRecord as any).preview = preview

+ 12 - 0
src/lib/agent/tools/write-outline-node.spec.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from "vitest"
 import {
   buildOutlineNodeWriteContent,
+  normalizeOutlineWriteTarget,
   validateOutlineWriteTarget,
 } from "./write-outline-node"
 
@@ -39,6 +40,17 @@ describe("write-outline-node helpers", () => {
     expect(content.trim()).not.toMatch(/^\{/)
   })
 
+  it("为缺少扩展名的大纲目标自动补全 .md", () => {
+    expect(normalizeOutlineWriteTarget("第一卷-有钱了我也还是程序员")).toBe(
+      "第一卷-有钱了我也还是程序员.md",
+    )
+    expect(normalizeOutlineWriteTarget("卷纲/第一卷完整卷纲")).toBe("卷纲/第一卷完整卷纲.md")
+    expect(normalizeOutlineWriteTarget("第一卷完整卷纲.MD")).toBe("第一卷完整卷纲.md")
+    expect(normalizeOutlineWriteTarget("第一卷完整卷纲.md")).toBe("第一卷完整卷纲.md")
+    expect(normalizeOutlineWriteTarget("章纲.txt")).toBe("章纲.txt")
+    expect(validateOutlineWriteTarget(normalizeOutlineWriteTarget("第一卷完整卷纲"))).toBeNull()
+  })
+
   it("拒绝不安全的大纲写入目标", () => {
     expect(validateOutlineWriteTarget("../章纲.md")).toContain("上级目录")
     expect(validateOutlineWriteTarget("C:/Book/章纲.md")).toContain("绝对路径")

+ 37 - 11
src/lib/agent/tools/write-outline-node.ts

@@ -76,6 +76,22 @@ export function buildOutlineNodeWriteContent(nodeTitle: string, nodeContent: str
   return `## ${nodeTitle}\n\n${trimmed}\n`
 }
 
+/** 补全缺失的 .md;已有其它扩展名时原样返回,交由校验拒绝。 */
+export function normalizeOutlineWriteTarget(outlineName: string): string {
+  const normalized = outlineName.replace(/\\/g, "/").trim()
+  if (!normalized) return normalized
+  const segments = normalized.split("/")
+  const fileName = segments[segments.length - 1] ?? ""
+  if (!fileName || fileName === "." || fileName === "..") return normalized
+  if (/\.md$/i.test(fileName)) {
+    segments[segments.length - 1] = fileName.replace(/\.md$/i, ".md")
+    return segments.join("/")
+  }
+  if (/\.[A-Za-z0-9]+$/.test(fileName)) return normalized
+  segments[segments.length - 1] = `${fileName}.md`
+  return segments.join("/")
+}
+
 export function validateOutlineWriteTarget(outlineName: string): string | null {
   const normalized = outlineName.replace(/\\/g, "/").trim()
   if (!normalized) return "大纲文件名称不能为空。"
@@ -91,6 +107,22 @@ export function validateOutlineWriteTarget(outlineName: string): string | null {
   return null
 }
 
+function resolveOutlineWriteParams(params: Record<string, unknown>): {
+  outlineName: string
+  nodeTitle: string
+  nodeContent: string
+  targetError: string | null
+} {
+  const outlineName = normalizeOutlineWriteTarget(String(params.outlineName ?? ""))
+  params.outlineName = outlineName
+  return {
+    outlineName,
+    nodeTitle: String(params.nodeTitle ?? ""),
+    nodeContent: String(params.nodeContent ?? ""),
+    targetError: validateOutlineWriteTarget(outlineName),
+  }
+}
+
 function buildChapterOutlineQualityText(outlineName: string, content: string): string {
   if (!isLikelyChapterOutline(content, outlineName)) return ""
   const quality = summarizeChapterOutlineQuality(content)
@@ -112,20 +144,17 @@ function buildChapterOutlineQualityText(outlineName: string, content: string): s
 export function createWriteOutlineNodeTool(outlinesDir: string): Tool {
   return {
     name: "write_outline_node",
-    description: "写入或更新大纲节点内容。参数 outlineName 为大纲文件名,nodeTitle 为节点标题,nodeContent 为节点内容。将追加或更新对应节点。",
+    description: "写入或更新大纲节点内容。参数 outlineName 为大纲 Markdown 文件名(可省略 .md,系统会自动补全),nodeTitle 为节点标题,nodeContent 为节点内容。将追加或更新对应节点。",
     category: "write",
     permission: "confirm",
     parameters: {
-      outlineName: { type: "string", description: "大纲文件名称", required: true },
+      outlineName: { type: "string", description: "大纲文件名称(.md;省略扩展名时自动补全)", required: true },
       nodeTitle: { type: "string", description: "节点标题", required: true },
       nodeContent: { type: "string", description: "节点内容", required: true },
     },
     generatePreview: async (params) => {
-      const outlineName = params.outlineName as string
-      const nodeTitle = params.nodeTitle as string
-      const nodeContent = params.nodeContent as string
-      const targetError = validateOutlineWriteTarget(outlineName)
-      if (targetError) return `无法写入大纲:${targetError}`
+      const { outlineName, nodeTitle, nodeContent, targetError } = resolveOutlineWriteParams(params)
+      if (targetError) throw new Error(`无法写入大纲:${targetError}`)
       const path = `${outlinesDir}/${outlineName}`
       const newSection = buildOutlineNodeWriteContent(nodeTitle, nodeContent)
       try {
@@ -140,10 +169,7 @@ export function createWriteOutlineNodeTool(outlinesDir: string): Tool {
       return `将写入大纲「${outlineName}」\n\n预览:\n${newSection}${buildChapterOutlineQualityText(outlineName, newSection)}`
     },
     execute: async (params) => {
-      const outlineName = params.outlineName as string
-      const nodeTitle = params.nodeTitle as string
-      const nodeContent = params.nodeContent as string
-      const targetError = validateOutlineWriteTarget(outlineName)
+      const { outlineName, nodeTitle, nodeContent, targetError } = resolveOutlineWriteParams(params)
       if (targetError) return `错误:${targetError}`
       const path = `${outlinesDir}/${outlineName}`
       const content = buildOutlineNodeWriteContent(nodeTitle, nodeContent)