Просмотр исходного кода

feat(agent): improve tool call workflow

Mochocyang 3 месяцев назад
Родитель
Сommit
c48fa10f9c

+ 38 - 0
gongjudiaoyongyouhua-分支说明.md

@@ -0,0 +1,38 @@
+# gongjudiaoyongyouhua 分支说明
+
+## 分支目标
+
+本分支用于优化 AI 工具调用流程,把当前工具调用从单纯执行器增强为更可控、可追踪、可验证的 Agent 流程。
+
+## 使用要求
+
+1. 不合并到 main,等待用户完成全面软件测试后再决定合并。
+2. 本分支只处理工具调用流程增强,不顺手改动无关 UI、主题或模型配置。
+3. 读取类工具默认自动执行,写入类工具必须进入确认/预览路径。
+4. 工具返回给模型的长内容可以压缩,但界面和记录中保留完整工具结果。
+5. 修改完成后必须完成源码启动、旧功能测试、构建和便携版打包验证。
+
+## 本次更新
+
+### 20260701-132053
+
+- 新增统一工具事件层 `applyAgentToolEvent`,聊天面板和 AI 大纲面板统一通过工具事件更新运行中、完成、错误和待确认状态。
+- 新增工具结果压缩模块,长工具结果回灌给模型前保留首尾证据并标记“已压缩给模型使用”,完整结果仍保存在工具调用记录中。
+- 写入类工具默认标记为需要确认,AgentRunner 在没有用户确认时不会执行写入,只返回可审核预览提示。
+- `write_chapter`、`write_memory`、`write_outline_node` 写入后会读回验证,成功显示“读回验证通过”,不一致时返回中文警告。
+- AI 大纲加入固定分析流程:先 list 确认可用资料,再 read 读取内容,分析冲突、缺口、伏笔、角色动机和章节承接,最后生成建议。
+
+## 验证记录
+
+- 20260701-132053:
+  - `npm.cmd exec -- vitest run src/lib/agent/runner.spec.ts src/lib/agent/tool-events.spec.ts src/lib/agent/tool-result.spec.ts src/lib/agent/tools/read-tools.spec.ts src/lib/agent/tools/write-tools.spec.ts src/components/chat/agent-tool-call-message.spec.tsx src/components/chat/chat-panel.spec.tsx src/components/sources/outline-chat-panel.spec.tsx src/components/reference/ReferenceInput.spec.tsx src/components/reference/ReferencePickerDialog.spec.tsx src/lib/reference/providers.spec.ts`:11 个测试文件、75 个用例通过。
+  - `npm.cmd run typecheck`:通过。
+  - `npm.cmd run test:mocks`:287 个测试文件、2127 个用例通过。
+  - `npm.cmd run build`:通过,存在既有 Vite chunk/dynamic import 警告。
+  - 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 通过 Job 验证,Vite ready 后已停止。
+  - `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `version-info.json`;Rust 构建存在既有 `file_sync.rs` dead-code 警告。
+
+## Git 状态
+
+- 20260701-132053 工具调用流程优化纳入本次提交。
+- 不合并 main。

+ 23 - 0
src/components/chat/agent-tool-call-message.spec.tsx

@@ -89,6 +89,29 @@ describe("AgentToolCallMessage", () => {
     expect(html).toContain("text-red-500")
   })
 
+  it("shows approval-required tool calls as pending confirmation", async () => {
+    await act(async () => {
+      root.render(
+        <AgentToolCallMessage
+          toolCalls={[
+            {
+              id: "call-approval",
+              name: "write_chapter",
+              params: { name: "第1章", content: "正文" },
+              result: "写入工具需要用户确认后才能执行。",
+              status: "approval_required",
+              startedAt: 100,
+              finishedAt: 100,
+            },
+          ]}
+        />,
+      )
+    })
+
+    expect(host.textContent).toContain("待确认")
+    expect(host.textContent).toContain("写入章节《第1章》")
+  })
+
   it("returns null when toolCalls is empty or undefined", async () => {
     await act(async () => {
       root.render(<AgentToolCallMessage toolCalls={[]} />)

+ 10 - 0
src/components/chat/agent-tool-call-message.tsx

@@ -115,6 +115,7 @@ function ToolCallRow({ call }: { call: ToolCallRecord }) {
   const [expanded, setExpanded] = useState(false)
   const category = useMemo(() => getToolCategory(call.name), [call.name])
   const hasError = call.status === "error"
+  const needsApproval = call.status === "approval_required"
   const description = useMemo(
     () => getToolCallDescription(call.name, call.params),
     [call.name, call.params],
@@ -123,6 +124,8 @@ function ToolCallRow({ call }: { call: ToolCallRecord }) {
 
   const rowClass = hasError
     ? "border-red-200 bg-red-50 text-red-800 hover:bg-red-100 dark:border-red-900/40 dark:bg-red-950/20 dark:text-red-300 dark:hover:bg-red-950/30"
+    : needsApproval
+      ? "border-amber-200 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300 dark:hover:bg-amber-950/30"
     : "border-border/60 bg-background hover:bg-accent/40"
 
   return (
@@ -139,6 +142,11 @@ function ToolCallRow({ call }: { call: ToolCallRecord }) {
           {description}
           {duration && <span className="ml-1 text-[10px] opacity-70">{duration}</span>}
         </span>
+        {needsApproval ? (
+          <span className="rounded border border-amber-300/70 bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:border-amber-800/60 dark:bg-amber-950/50 dark:text-amber-300">
+            待确认
+          </span>
+        ) : null}
         {expanded ? (
           <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
         ) : (
@@ -150,6 +158,8 @@ function ToolCallRow({ call }: { call: ToolCallRecord }) {
           className={`max-h-40 overflow-y-auto border-t px-2 py-1.5 text-[11px] whitespace-pre-wrap break-words leading-relaxed ${
             hasError
               ? "border-red-200/60 text-red-700 dark:border-red-900/30 dark:text-red-300/80"
+              : needsApproval
+                ? "border-amber-200/60 text-amber-800 dark:border-amber-900/30 dark:text-amber-300/80"
               : "border-border/50 text-muted-foreground"
           }`}
         >

+ 7 - 40
src/components/chat/chat-panel.tsx

@@ -25,7 +25,8 @@ import {
 } from "@/lib/reference/providers"
 import type { ReferenceToken } from "@/lib/reference/types"
 import { AgentRunner } from "@/lib/agent/runner"
-import type { AgentMessage, AgentRunRecord, ToolCall } from "@/lib/agent/types"
+import type { AgentMessage, AgentRunRecord } from "@/lib/agent/types"
+import { applyAgentToolEvent } from "@/lib/agent/tool-events"
 import { useAgentConfig } from "@/hooks/use-agent-config"
 import { resolveChapterLengthSpec } from "@/lib/novel/deep-chapter-prompts"
 import { streamChat } from "@/lib/llm-client"
@@ -858,48 +859,14 @@ export function ChatPanel() {
                 content: message.content + chunk,
               }))
             },
-            onToolCall: (call: ToolCall) => {
-              if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
-              updateAgentAssistantMessage(assistantMessage.id, (message) => {
-                const existing = message.agentToolCalls ?? []
-                if (existing.some((item) => item.id === call.id)) return message
-                return {
-                  ...message,
-                  agentToolCalls: [
-                    ...existing,
-                    {
-                      id: call.id,
-                      name: call.name,
-                      params: call.arguments,
-                      result: "",
-                      status: "done",
-                      startedAt: Date.now(),
-                      finishedAt: 0,
-                    },
-                  ],
-                }
-              })
-            },
-            onToolResult: (callId: string, result: string) => {
-              if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
-              updateAgentAssistantMessage(assistantMessage.id, (message) => ({
-                ...message,
-                agentToolCalls: (message.agentToolCalls ?? []).map((item) =>
-                  item.id === callId
-                    ? { ...item, result, status: "done", finishedAt: Date.now() }
-                    : item,
-                ),
-              }))
-            },
-            onToolError: (callId: string, error: string) => {
+            onToolCall: () => {},
+            onToolResult: () => {},
+            onToolError: () => {},
+            onToolEvent: (event) => {
               if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
               updateAgentAssistantMessage(assistantMessage.id, (message) => ({
                 ...message,
-                agentToolCalls: (message.agentToolCalls ?? []).map((item) =>
-                  item.id === callId
-                    ? { ...item, result: error, status: "error", finishedAt: Date.now() }
-                    : item,
-                ),
+                agentToolCalls: applyAgentToolEvent(message.agentToolCalls, event),
               }))
             },
             onDone: () => {

+ 8 - 0
src/components/sources/outline-chat-panel.spec.tsx

@@ -71,4 +71,12 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("请优先使用工具读取引用内容")
     expect(source).not.toContain("loadReferenceTokenContext(tokens)")
   })
+
+  it("forces outline chat through a dedicated list-read-analyze-generate workflow", () => {
+    expect(source).toContain("## AI大纲固定分析流程")
+    expect(source).toContain("先调用 list_outlines、list_chapters、list_memories、list_deductions")
+    expect(source).toContain("再调用 read_outline、read_chapter、read_memory、read_deduction")
+    expect(source).toContain("分析冲突、缺口、伏笔、角色动机和章节承接")
+    expect(source).toContain("最后再生成大纲建议")
+  })
 })

+ 17 - 75
src/components/sources/outline-chat-panel.tsx

@@ -30,7 +30,8 @@ import { useChatStore } from "@/stores/chat-store"
 import { AgentRunner } from "@/lib/agent/runner"
 import { ToolRegistry } from "@/lib/agent/registry"
 import { buildAgentConfig, modelSupportsTools } from "@/lib/agent/config"
-import type { AgentMessage, AgentRunRecord, ToolCall } from "@/lib/agent/types"
+import type { AgentMessage, AgentRunRecord } from "@/lib/agent/types"
+import { applyAgentToolEvent } from "@/lib/agent/tool-events"
 import { loadDeAiSkillConfig, type DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
 import {
   buildWebResearchContext,
@@ -88,6 +89,11 @@ function buildOutlineAgentSystemPrompt(options: { projectName?: string; webResea
     "如果用户提供 @ 引用,必须优先按路径、标题或会话ID调用对应读取工具获取正文内容。",
     "不要假设引用内容已经注入上下文;不要跳过工具直接空泛回答。",
     "回答必须基于已读取内容进行分析,说明关键判断依据;需要写入大纲节点时使用 write_outline_node。",
+    "## AI大纲固定分析流程",
+    "1. 先调用 list_outlines、list_chapters、list_memories、list_deductions 确认可用资料范围。",
+    "2. 再调用 read_outline、read_chapter、read_memory、read_deduction 读取用户 @ 引用和相关项目内容。",
+    "3. 分析冲突、缺口、伏笔、角色动机和章节承接,明确哪些判断来自已读取资料。",
+    "4. 最后再生成大纲建议;没有完成读取和分析前,不要直接给出结论。",
     "所有面向用户的回复必须使用中文。",
     options.projectName ? `当前项目:${options.projectName}` : "",
     options.webResearchContext?.trim()
@@ -577,45 +583,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             result += chunk
             setStreamingContent(result)
           },
-          onToolCall: (call: ToolCall) => {
-            updateOutlineAssistantMessage(convId, assistantId, (message) => {
-              const existing = message.agentToolCalls ?? []
-              if (existing.some((item) => item.id === call.id)) return message
-              return {
-                ...message,
-                agentToolCalls: [
-                  ...existing,
-                  {
-                    id: call.id,
-                    name: call.name,
-                    params: call.arguments,
-                    result: "",
-                    status: "done",
-                    startedAt: Date.now(),
-                    finishedAt: 0,
-                  },
-                ],
-              }
-            })
-          },
-          onToolResult: (callId, toolResult) => {
-            updateOutlineAssistantMessage(convId, assistantId, (message) => ({
-              ...message,
-              agentToolCalls: (message.agentToolCalls ?? []).map((item) =>
-                item.id === callId
-                  ? { ...item, result: toolResult, status: "done", finishedAt: Date.now() }
-                  : item,
-              ),
-            }))
-          },
-          onToolError: (callId, error) => {
+          onToolCall: () => {},
+          onToolResult: () => {},
+          onToolError: () => {},
+          onToolEvent: (event) => {
             updateOutlineAssistantMessage(convId, assistantId, (message) => ({
               ...message,
-              agentToolCalls: (message.agentToolCalls ?? []).map((item) =>
-                item.id === callId
-                  ? { ...item, result: error, status: "error", finishedAt: Date.now() }
-                  : item,
-              ),
+              agentToolCalls: applyAgentToolEvent(message.agentToolCalls, event),
             }))
           },
           onDone: () => {
@@ -794,45 +768,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             result += chunk
             setStreamingContent(result)
           },
-          onToolCall: (call: ToolCall) => {
-            updateOutlineAssistantMessage(activeConversationId, assistantId, (message) => {
-              const existing = message.agentToolCalls ?? []
-              if (existing.some((item) => item.id === call.id)) return message
-              return {
-                ...message,
-                agentToolCalls: [
-                  ...existing,
-                  {
-                    id: call.id,
-                    name: call.name,
-                    params: call.arguments,
-                    result: "",
-                    status: "done",
-                    startedAt: Date.now(),
-                    finishedAt: 0,
-                  },
-                ],
-              }
-            })
-          },
-          onToolResult: (callId, toolResult) => {
-            updateOutlineAssistantMessage(activeConversationId, assistantId, (message) => ({
-              ...message,
-              agentToolCalls: (message.agentToolCalls ?? []).map((item) =>
-                item.id === callId
-                  ? { ...item, result: toolResult, status: "done", finishedAt: Date.now() }
-                  : item,
-              ),
-            }))
-          },
-          onToolError: (callId, error) => {
+          onToolCall: () => {},
+          onToolResult: () => {},
+          onToolError: () => {},
+          onToolEvent: (event) => {
             updateOutlineAssistantMessage(activeConversationId, assistantId, (message) => ({
               ...message,
-              agentToolCalls: (message.agentToolCalls ?? []).map((item) =>
-                item.id === callId
-                  ? { ...item, result: error, status: "error", finishedAt: Date.now() }
-                  : item,
-              ),
+              agentToolCalls: applyAgentToolEvent(message.agentToolCalls, event),
             }))
           },
           onDone: () => {

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

@@ -197,4 +197,100 @@ describe("AgentRunner", () => {
 
     expect(onToolError).toHaveBeenCalledOnce()
   })
+
+  it("does not execute confirm-required write tools without approval", async () => {
+    const tool: Tool = {
+      name: "write_chapter",
+      description: "",
+      category: "write",
+      permission: "confirm",
+      parameters: {},
+      execute: vi.fn().mockResolvedValue("written"),
+    }
+    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_1", name: "write_chapter" })
+        cb.onToolCallDelta?.({ index: 0, arguments: '{"name":"第1章","content":"正文"}' })
+        cb.onDone()
+      } else {
+        expect(messages[messages.length - 1].content).toContain("需要用户确认")
+        cb.onToken("已生成写入预览,等待确认。")
+        cb.onDone()
+      }
+    })
+
+    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: vi.fn(),
+        onToolEvent,
+        onDone: vi.fn(),
+        onError: vi.fn(),
+      },
+      undefined,
+    )
+
+    expect(tool.execute).not.toHaveBeenCalled()
+    expect(result.toolCalls[0].status).toBe("approval_required")
+    expect(onToolEvent).toHaveBeenCalledWith(expect.objectContaining({ type: "approval_required", callId: "write_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 = {
+      name: "read_chapter",
+      description: "read",
+      category: "read",
+      parameters: {},
+      execute: vi.fn().mockResolvedValue(longResult),
+    }
+    registry.register(tool)
+
+    let compressedToolMessage = ""
+    let callCount = 0
+    mockStreamChat.mockImplementation(async (_config: unknown, messages: AgentMessage[], cb: StreamCallbacks) => {
+      callCount++
+      if (callCount === 1) {
+        cb.onToolCallDelta?.({ index: 0, id: "read_1", name: "read_chapter" })
+        cb.onToolCallDelta?.({ index: 0, arguments: "{}" })
+        cb.onDone()
+      } else {
+        compressedToolMessage = String(messages[messages.length - 1].content)
+        cb.onToken("已分析")
+        cb.onDone()
+      }
+    })
+
+    const config: AgentConfig = {
+      maxRounds: 3,
+      tools: [tool],
+      systemPrompt: "",
+      llmConfig: mockLlmConfig,
+      toolResultContextLimit: 1200,
+    }
+    const result = await runner.run(
+      config,
+      registry,
+      [systemMsg, userMsg],
+      { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
+      undefined,
+    )
+
+    expect(result.toolCalls[0].result).toBe(longResult)
+    expect(compressedToolMessage.length).toBeLessThan(longResult.length)
+    expect(compressedToolMessage).toContain("已压缩给模型使用")
+    expect(compressedToolMessage).toContain("开头")
+    expect(compressedToolMessage).toContain("结尾")
+  })
 })

+ 63 - 11
src/lib/agent/runner.ts

@@ -2,6 +2,7 @@ import { streamChat } from "../llm-client"
 import type { StreamCallbacks } from "../llm-client"
 import { accumulateToolCalls } from "./tool-call-parser"
 import { toOpenAITools } from "./tools-schema"
+import { formatToolResultForModel } from "./tool-result"
 import type { ToolRegistry } from "./registry"
 import type { AgentConfig, AgentMessage, AgentRunCallbacks, AgentRunRecord, ToolCall, ToolCallDelta } from "./types"
 import { DEFAULT_MAX_ROUNDS, TOOL_EXECUTE_TIMEOUT_MS } from "./types"
@@ -95,26 +96,25 @@ export class AgentRunner {
           catch { return {} }
         })()
 
-        const toolCallRecord: {
-          id: string
-          name: string
-          params: Record<string, unknown>
-          result: string
-          status: "done" | "error"
-          startedAt: number
-          finishedAt: number
-        } = {
+        const toolCallRecord: AgentRunRecord["toolCalls"][number] = {
           id: tc.id,
           name: toolName,
           params,
           result: "",
-          status: "done",
+          status: "running",
           startedAt: Date.now(),
           finishedAt: Date.now(),
         }
 
         const callbackToolCall: ToolCall = { id: tc.id, name: toolName, arguments: params }
         callbacks.onToolCall(callbackToolCall)
+        callbacks.onToolEvent?.({
+          type: "call_started",
+          callId: tc.id,
+          name: toolName,
+          params,
+          timestamp: toolCallRecord.startedAt,
+        })
 
         if (!tool) {
           const errorMsg = `错误: 未知工具 ${toolName}`
@@ -123,10 +123,41 @@ export class AgentRunner {
           toolCallRecord.result = errorMsg
           toolCallRecord.finishedAt = Date.now()
           record.toolCalls.push(toolCallRecord)
+          callbacks.onToolEvent?.({
+            type: "error",
+            callId: tc.id,
+            name: toolName,
+            params,
+            result: errorMsg,
+            timestamp: toolCallRecord.finishedAt,
+          })
           workingMessages.push({ role: "tool", content: toolCallRecord.result, tool_call_id: tc.id, name: toolName })
           continue
         }
 
+        const permission = tool.permission ?? (tool.category === "write" ? "confirm" : "auto")
+        if (permission === "confirm") {
+          const approvalMessage = [
+            `写入工具 ${toolName} 需要用户确认后才能执行。`,
+            "本次没有执行写入操作。",
+            "请根据用户指令生成可审核的写入预览,并等待用户明确确认后再写入。",
+          ].join("\n")
+          toolCallRecord.status = "approval_required"
+          toolCallRecord.result = approvalMessage
+          toolCallRecord.finishedAt = Date.now()
+          record.toolCalls.push(toolCallRecord)
+          callbacks.onToolEvent?.({
+            type: "approval_required",
+            callId: tc.id,
+            name: toolName,
+            params,
+            result: approvalMessage,
+            timestamp: toolCallRecord.finishedAt,
+          })
+          workingMessages.push({ role: "tool", content: approvalMessage, tool_call_id: tc.id, name: toolName })
+          continue
+        }
+
         try {
           const result = await Promise.race([
             tool.execute(params, signal),
@@ -135,15 +166,36 @@ export class AgentRunner {
           toolCallRecord.result = result
           toolCallRecord.finishedAt = Date.now()
           callbacks.onToolResult(tc.id, result)
+          callbacks.onToolEvent?.({
+            type: "result",
+            callId: tc.id,
+            name: toolName,
+            params,
+            result,
+            timestamp: toolCallRecord.finishedAt,
+          })
         } catch (err) {
           toolCallRecord.status = "error"
           toolCallRecord.result = `错误: ${err instanceof Error ? err.message : String(err)}`
           toolCallRecord.finishedAt = Date.now()
           callbacks.onToolError(tc.id, toolCallRecord.result)
+          callbacks.onToolEvent?.({
+            type: "error",
+            callId: tc.id,
+            name: toolName,
+            params,
+            result: toolCallRecord.result,
+            timestamp: toolCallRecord.finishedAt,
+          })
         }
 
         record.toolCalls.push(toolCallRecord)
-        workingMessages.push({ role: "tool", content: toolCallRecord.result, tool_call_id: tc.id, name: toolName })
+        workingMessages.push({
+          role: "tool",
+          content: formatToolResultForModel(toolName, toolCallRecord.result, config.toolResultContextLimit),
+          tool_call_id: tc.id,
+          name: toolName,
+        })
       }
 
       // Continue loop

+ 56 - 0
src/lib/agent/tool-events.spec.ts

@@ -0,0 +1,56 @@
+import { describe, expect, it } from "vitest"
+import { applyAgentToolEvent } from "./tool-events"
+
+describe("applyAgentToolEvent", () => {
+  it("creates and updates tool call records from normalized events", () => {
+    const started = applyAgentToolEvent(undefined, {
+      type: "call_started",
+      callId: "c1",
+      name: "read_chapter",
+      params: { name: "第1章" },
+      timestamp: 100,
+    })
+
+    expect(started).toEqual([
+      {
+        id: "c1",
+        name: "read_chapter",
+        params: { name: "第1章" },
+        result: "",
+        status: "running",
+        startedAt: 100,
+        finishedAt: 0,
+      },
+    ])
+
+    const finished = applyAgentToolEvent(started, {
+      type: "result",
+      callId: "c1",
+      name: "read_chapter",
+      params: { name: "第1章" },
+      result: "章节内容",
+      timestamp: 180,
+    })
+
+    expect(finished[0]).toMatchObject({
+      result: "章节内容",
+      status: "done",
+      startedAt: 100,
+      finishedAt: 180,
+    })
+  })
+
+  it("marks write tools as approval_required without treating them as errors", () => {
+    const records = applyAgentToolEvent(undefined, {
+      type: "approval_required",
+      callId: "w1",
+      name: "write_chapter",
+      params: { name: "第1章" },
+      result: "需要用户确认",
+      timestamp: 200,
+    })
+
+    expect(records[0].status).toBe("approval_required")
+    expect(records[0].result).toContain("需要用户确认")
+  })
+})

+ 45 - 0
src/lib/agent/tool-events.ts

@@ -0,0 +1,45 @@
+import type { AgentRunRecord, AgentToolEvent } from "./types"
+
+export type ToolCallRecord = AgentRunRecord["toolCalls"][number]
+
+export function applyAgentToolEvent(
+  records: ToolCallRecord[] | undefined,
+  event: AgentToolEvent,
+): ToolCallRecord[] {
+  const current = records ?? []
+  const existingIndex = current.findIndex((record) => record.id === event.callId)
+  const status =
+    event.type === "result"
+      ? "done"
+      : event.type === "error"
+        ? "error"
+        : event.type === "approval_required"
+          ? "approval_required"
+          : "running"
+
+  const nextRecord: ToolCallRecord = {
+    id: event.callId,
+    name: event.name,
+    params: event.params,
+    result: event.result ?? "",
+    status,
+    startedAt: event.timestamp,
+    finishedAt: event.type === "call_started" ? 0 : event.timestamp,
+  }
+
+  if (existingIndex < 0) {
+    return [...current, nextRecord]
+  }
+
+  return current.map((record, index) => {
+    if (index !== existingIndex) return record
+    return {
+      ...record,
+      name: event.name,
+      params: event.params,
+      result: event.result ?? record.result,
+      status,
+      finishedAt: event.type === "call_started" ? record.finishedAt : event.timestamp,
+    }
+  })
+}

+ 19 - 0
src/lib/agent/tool-result.spec.ts

@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest"
+import { formatToolResultForModel } from "./tool-result"
+
+describe("formatToolResultForModel", () => {
+  it("returns short tool results unchanged", () => {
+    expect(formatToolResultForModel("read_chapter", "短内容", 100)).toBe("短内容")
+  })
+
+  it("compresses long results while preserving beginning and ending evidence", () => {
+    const result = `${"开头内容".repeat(80)}\n${"中间内容".repeat(80)}\n${"结尾内容".repeat(80)}`
+    const compressed = formatToolResultForModel("read_chapter", result, 300)
+
+    expect(compressed.length).toBeLessThan(result.length)
+    expect(compressed).toContain("工具 read_chapter 返回内容较长,已压缩给模型使用")
+    expect(compressed).toContain("原始长度")
+    expect(compressed).toContain("开头内容")
+    expect(compressed).toContain("结尾内容")
+  })
+})

+ 27 - 0
src/lib/agent/tool-result.ts

@@ -0,0 +1,27 @@
+export const DEFAULT_TOOL_RESULT_CONTEXT_LIMIT = 6000
+
+export function formatToolResultForModel(
+  toolName: string,
+  result: string,
+  limit = DEFAULT_TOOL_RESULT_CONTEXT_LIMIT,
+): string {
+  if (result.length <= limit) return result
+
+  const safeLimit = Math.max(200, limit)
+  const header = `工具 ${toolName} 返回内容较长,已压缩给模型使用。原始长度:${result.length} 字。`
+  const bodyLimit = Math.max(120, safeLimit - header.length - 80)
+  const headLength = Math.floor(bodyLimit * 0.6)
+  const tailLength = Math.max(60, bodyLimit - headLength)
+  const head = result.slice(0, headLength).trim()
+  const tail = result.slice(-tailLength).trim()
+
+  return [
+    header,
+    "",
+    "## 开头片段",
+    head,
+    "",
+    "## 结尾片段",
+    tail,
+  ].join("\n")
+}

+ 9 - 3
src/lib/agent/tools/write-chapter.ts

@@ -1,11 +1,12 @@
 import type { Tool } from "../types"
-import { writeFile } from "@/commands/fs"
+import { readFile, writeFile } from "@/commands/fs"
 
 export function createWriteChapterTool(chaptersDir: string): Tool {
   return {
     name: "write_chapter",
     description: "写入或更新章节内容。参数 name 为章节名称,content 为完整 Markdown 内容。会覆盖已有文件。",
     category: "write",
+    permission: "confirm",
     parameters: {
       name: { type: "string", description: "章节名称(不含 .md 后缀)", required: true },
       content: { type: "string", description: "章节完整 Markdown 内容", required: true },
@@ -14,9 +15,14 @@ export function createWriteChapterTool(chaptersDir: string): Tool {
       const name = params.name as string
       const content = params.content as string
       if (!name.includes("/") && !name.includes("\\")) {
+        const path = `${chaptersDir}/${name}.md`
         try {
-          await writeFile(`${chaptersDir}/${name}.md`, content)
-          return `已写入章节「${name}」`
+          await writeFile(path, content)
+          const verified = await readFile(path)
+          if (verified !== content) {
+            return `已写入章节「${name}」,警告:写入后读回验证失败,请手动检查文件内容。`
+          }
+          return `已写入章节「${name}」,读回验证通过。`
         } catch (err) {
           return `错误:写入章节失败 — ${err instanceof Error ? err.message : String(err)}`
         }

+ 9 - 3
src/lib/agent/tools/write-memory.ts

@@ -1,11 +1,12 @@
 import type { Tool } from "../types"
-import { writeFile } from "@/commands/fs"
+import { readFile, writeFile } from "@/commands/fs"
 
 export function createWriteMemoryTool(memoryDir: string): Tool {
   return {
     name: "write_memory",
     description: "写入或更新记忆条目。参数 name 为记忆名称,content 为记忆内容。",
     category: "write",
+    permission: "confirm",
     parameters: {
       name: { type: "string", description: "记忆条目名称", required: true },
       content: { type: "string", description: "记忆内容", required: true },
@@ -13,9 +14,14 @@ export function createWriteMemoryTool(memoryDir: string): Tool {
     execute: async (params) => {
       const name = params.name as string
       const content = params.content as string
+      const path = `${memoryDir}/${name}.md`
       try {
-        await writeFile(`${memoryDir}/${name}.md`, content)
-        return `已写入记忆「${name}」`
+        await writeFile(path, content)
+        const verified = await readFile(path)
+        if (verified !== content) {
+          return `已写入记忆「${name}」,警告:写入后读回验证失败,请手动检查文件内容。`
+        }
+        return `已写入记忆「${name}」,读回验证通过。`
       } catch (err) {
         return `错误:写入记忆失败 — ${err instanceof Error ? err.message : String(err)}`
       }

+ 10 - 3
src/lib/agent/tools/write-outline-node.ts

@@ -1,11 +1,12 @@
 import type { Tool } from "../types"
-import { writeFile } from "@/commands/fs"
+import { readFile, writeFile } from "@/commands/fs"
 
 export function createWriteOutlineNodeTool(outlinesDir: string): Tool {
   return {
     name: "write_outline_node",
     description: "写入或更新大纲节点内容。参数 outlineName 为大纲文件名,nodeTitle 为节点标题,nodeContent 为节点内容。将追加或更新对应节点。",
     category: "write",
+    permission: "confirm",
     parameters: {
       outlineName: { type: "string", description: "大纲文件名称", required: true },
       nodeTitle: { type: "string", description: "节点标题", required: true },
@@ -15,9 +16,15 @@ export function createWriteOutlineNodeTool(outlinesDir: string): Tool {
       const outlineName = params.outlineName as string
       const nodeTitle = params.nodeTitle as string
       const nodeContent = params.nodeContent as string
+      const path = `${outlinesDir}/${outlineName}`
+      const content = `## ${nodeTitle}\n\n${nodeContent}\n`
       try {
-        await writeFile(`${outlinesDir}/${outlineName}`, `## ${nodeTitle}\n\n${nodeContent}\n`)
-        return `已写入大纲节点「${nodeTitle}」到「${outlineName}」`
+        await writeFile(path, content)
+        const verified = await readFile(path)
+        if (verified !== content) {
+          return `已写入大纲节点「${nodeTitle}」到「${outlineName}」,警告:写入后读回验证失败,请手动检查文件内容。`
+        }
+        return `已写入大纲节点「${nodeTitle}」到「${outlineName}」,读回验证通过。`
       } catch (err) {
         return `错误:写入大纲失败 — ${err instanceof Error ? err.message : String(err)}`
       }

+ 17 - 3
src/lib/agent/tools/write-tools.spec.ts

@@ -3,12 +3,12 @@ import { createWriteChapterTool } from "./write-chapter"
 import { createWriteMemoryTool } from "./write-memory"
 import { createApplySkillTool } from "./apply-skill"
 
-vi.mock("@/commands/fs", () => ({ writeFile: vi.fn() }))
+vi.mock("@/commands/fs", () => ({ readFile: vi.fn(), writeFile: vi.fn() }))
 vi.mock("@/lib/novel/de-ai-skill-library", () => ({
   getAllDeAiSkills: vi.fn(),
 }))
 
-import { writeFile } from "@/commands/fs"
+import { readFile, writeFile } from "@/commands/fs"
 import { getAllDeAiSkills } from "@/lib/novel/de-ai-skill-library"
 
 describe("write tools", () => {
@@ -18,10 +18,13 @@ describe("write tools", () => {
 
   it("write_chapter writes content to chapters dir", async () => {
     vi.mocked(writeFile).mockResolvedValue()
+    vi.mocked(readFile).mockResolvedValue("chapter body")
     const tool = createWriteChapterTool("/project/wiki/chapters")
     const result = await tool.execute({ name: "第1章", content: "chapter body" })
     expect(result).toContain("已写入")
+    expect(result).toContain("读回验证通过")
     expect(writeFile).toHaveBeenCalledWith("/project/wiki/chapters/第1章.md", "chapter body")
+    expect(readFile).toHaveBeenCalledWith("/project/wiki/chapters/第1章.md")
   })
 
   it("write_chapter reports error on failure", async () => {
@@ -33,9 +36,20 @@ describe("write tools", () => {
 
   it("write_memory writes to memory dir", async () => {
     vi.mocked(writeFile).mockResolvedValue()
+    vi.mocked(readFile).mockResolvedValue("desc")
     const tool = createWriteMemoryTool("/project/wiki/memory")
-    await tool.execute({ name: "曙光", content: "desc" })
+    const result = await tool.execute({ name: "曙光", content: "desc" })
     expect(writeFile).toHaveBeenCalledWith("/project/wiki/memory/曙光.md", "desc")
+    expect(readFile).toHaveBeenCalledWith("/project/wiki/memory/曙光.md")
+    expect(result).toContain("读回验证通过")
+  })
+
+  it("write tools report verification failures after writing", async () => {
+    vi.mocked(writeFile).mockResolvedValue()
+    vi.mocked(readFile).mockResolvedValue("different content")
+    const tool = createWriteChapterTool("/project/wiki/chapters")
+    const result = await tool.execute({ name: "第1章", content: "chapter body" })
+    expect(result).toContain("警告:写入后读回验证失败")
   })
 
   it("apply_skill returns skill content", async () => {

+ 15 - 1
src/lib/agent/types.ts

@@ -8,11 +8,14 @@ export interface ToolParameter {
 }
 
 export type ToolCategory = "read" | "write" | "action"
+export type ToolPermission = "auto" | "confirm"
+export type ToolCallStatus = "running" | "done" | "error" | "approval_required"
 
 export interface Tool {
   name: string
   description: string
   category: ToolCategory
+  permission?: ToolPermission
   parameters: Record<string, ToolParameter>
   execute(params: Record<string, unknown>, signal?: AbortSignal): Promise<string>
 }
@@ -35,15 +38,26 @@ export interface AgentConfig {
   tools: Tool[]
   systemPrompt: string
   llmConfig: LlmConfig
+  toolResultContextLimit?: number
   /** 模型标识,用于上层识别当前使用的模型 */
   modelId?: string
 }
 
+export interface AgentToolEvent {
+  type: "call_started" | "result" | "error" | "approval_required"
+  callId: string
+  name: string
+  params: Record<string, unknown>
+  result?: string
+  timestamp: number
+}
+
 export interface AgentRunCallbacks {
   onText: (chunk: string) => void
   onToolCall: (call: ToolCall) => void
   onToolResult: (callId: string, result: string) => void
   onToolError: (callId: string, error: string) => void
+  onToolEvent?: (event: AgentToolEvent) => void
   onDone: () => void
   onError: (error: Error) => void
 }
@@ -62,7 +76,7 @@ export interface AgentRunRecord {
     name: string
     params: Record<string, unknown>
     result: string
-    status: "done" | "error"
+    status: ToolCallStatus
     startedAt: number
     finishedAt: number
   }[]