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

feat(core): add deterministic writing tools to agent session

Ma 6 месяцев назад
Родитель
Сommit
18c8da1035

+ 13 - 0
packages/core/src/__tests__/agent-system-prompt.test.ts

@@ -50,11 +50,24 @@ describe("buildAgentSystemPrompt", () => {
       expect(prompt).toContain("auditor");
       expect(prompt).toContain("auditor");
       expect(prompt).toContain("reviser");
       expect(prompt).toContain("reviser");
       expect(prompt).toContain("read");
       expect(prompt).toContain("read");
+      expect(prompt).toContain("revise_chapter");
+      expect(prompt).toContain("write_truth_file");
+      expect(prompt).toContain("rename_entity");
+      expect(prompt).toContain("patch_chapter_text");
       expect(prompt).toContain("edit");
       expect(prompt).toContain("edit");
       expect(prompt).toContain("grep");
       expect(prompt).toContain("grep");
       expect(prompt).toContain("ls");
       expect(prompt).toContain("ls");
     });
     });
 
 
+    it("with-book prompt steers high-risk edits to dedicated deterministic tools", () => {
+      const prompt = buildAgentSystemPrompt("my-book", "zh");
+      expect(prompt).toContain("改设定/改真相文件");
+      expect(prompt).toContain("write_truth_file");
+      expect(prompt).toContain("用户要求重写/精修已有章节");
+      expect(prompt).toContain("revise_chapter");
+      expect(prompt).toContain("不要再用 edit 处理真相文件或章节重写");
+    });
+
     it("Chinese prompt warns NOT to call architect", () => {
     it("Chinese prompt warns NOT to call architect", () => {
       const prompt = buildAgentSystemPrompt("my-book", "zh");
       const prompt = buildAgentSystemPrompt("my-book", "zh");
       expect(prompt).toContain("不要调用 architect");
       expect(prompt).toContain("不要调用 architect");

+ 122 - 0
packages/core/src/__tests__/agent-tools.test.ts

@@ -0,0 +1,122 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { StateManager } from "../state/manager.js";
+import {
+  createPatchChapterTextTool,
+  createRenameEntityTool,
+  createReviseChapterTool,
+  createWriteTruthFileTool,
+} from "../agent/agent-tools.js";
+
+describe("agent deterministic writing tools", () => {
+  let root: string;
+  let state: StateManager;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-agent-tools-"));
+    state = new StateManager(root);
+
+    await state.saveBookConfig("harbor", {
+      id: "harbor",
+      title: "Harbor",
+      platform: "tomato",
+      genre: "other",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 3000,
+      createdAt: "2026-04-16T00:00:00.000Z",
+      updatedAt: "2026-04-16T00:00:00.000Z",
+    });
+
+    await mkdir(join(state.bookDir("harbor"), "story", "runtime"), { recursive: true });
+    await mkdir(join(state.bookDir("harbor"), "chapters"), { recursive: true });
+    await writeFile(join(state.bookDir("harbor"), "story", "story_bible.md"), "# Story Bible\n\nLin Yue guards the jade seal.\n", "utf-8");
+    await writeFile(join(state.bookDir("harbor"), "chapters", "0003_Storm.md"), "# 第3章 风暴\n\nLin Yue kept the jade seal hidden.\n", "utf-8");
+    await state.saveChapterIndex("harbor", [{
+      number: 3,
+      title: "风暴",
+      status: "ready-for-review",
+      wordCount: 120,
+      createdAt: "2026-04-16T00:00:00.000Z",
+      updatedAt: "2026-04-16T00:00:00.000Z",
+      auditIssues: [],
+      lengthWarnings: [],
+    }]);
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("writes truth files through the deterministic tool path", async () => {
+    const tool = createWriteTruthFileTool({} as never, root, "harbor");
+
+    const result = await tool.execute("tool-1", {
+      fileName: "story_bible.md",
+      content: "# Story Bible\n\nLin Yue now distrusts the guild.\n",
+    });
+
+    expect(result.content[0]?.type).toBe("text");
+    await expect(readFile(join(state.bookDir("harbor"), "story", "story_bible.md"), "utf-8"))
+      .resolves.toContain("distrusts the guild");
+  });
+
+  it("routes chapter rewrite requests through reviseDraft with the requested mode", async () => {
+    const pipeline = {
+      reviseDraft: vi.fn(async () => ({
+        chapterNumber: 3,
+        wordCount: 1300,
+        fixedIssues: [],
+        applied: true,
+        status: "ready-for-review" as const,
+      })),
+    };
+    const tool = createReviseChapterTool(pipeline as never, "harbor");
+
+    const result = await tool.execute("tool-2", {
+      chapterNumber: 3,
+      mode: "rewrite",
+    });
+
+    expect(pipeline.reviseDraft).toHaveBeenCalledWith("harbor", 3, "rewrite");
+    expect(result.content[0]?.type).toBe("text");
+  });
+
+  it("renames entities through the deterministic edit controller", async () => {
+    const tool = createRenameEntityTool({} as never, root, "harbor");
+
+    await tool.execute("tool-3", {
+      oldValue: "Lin Yue",
+      newValue: "Lin Yan",
+    });
+
+    await expect(readFile(join(state.bookDir("harbor"), "story", "story_bible.md"), "utf-8"))
+      .resolves.toContain("Lin Yan");
+    await expect(readFile(join(state.bookDir("harbor"), "chapters", "0003_Storm.md"), "utf-8"))
+      .resolves.toContain("Lin Yan");
+  });
+
+  it("patches chapter text through the deterministic edit controller", async () => {
+    const tool = createPatchChapterTextTool({} as never, root, "harbor");
+
+    await tool.execute("tool-4", {
+      chapterNumber: 3,
+      targetText: "jade seal hidden",
+      replacementText: "jade seal locked beneath the altar",
+    });
+
+    await expect(readFile(join(state.bookDir("harbor"), "chapters", "0003_Storm.md"), "utf-8"))
+      .resolves.toContain("locked beneath the altar");
+    await expect(state.loadChapterIndex("harbor")).resolves.toEqual([
+      expect.objectContaining({
+        number: 3,
+        status: "audit-failed",
+        auditIssues: expect.arrayContaining([
+          expect.stringContaining("Manual text edit requires review"),
+        ]),
+      }),
+    ]);
+  });
+});

+ 8 - 0
packages/core/src/agent/agent-session.ts

@@ -5,11 +5,15 @@ import type { Model, Api, AssistantMessage, UserMessage } from "@mariozechner/pi
 import type { PipelineRunner } from "../pipeline/runner.js";
 import type { PipelineRunner } from "../pipeline/runner.js";
 import { buildAgentSystemPrompt } from "./agent-system-prompt.js";
 import { buildAgentSystemPrompt } from "./agent-system-prompt.js";
 import {
 import {
+  createPatchChapterTextTool,
+  createRenameEntityTool,
   createSubAgentTool,
   createSubAgentTool,
+  createReviseChapterTool,
   createReadTool,
   createReadTool,
   createEditTool,
   createEditTool,
   createGrepTool,
   createGrepTool,
   createLsTool,
   createLsTool,
+  createWriteTruthFileTool,
 } from "./agent-tools.js";
 } from "./agent-tools.js";
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
@@ -232,6 +236,10 @@ export async function runAgentSession(
         tools: [
         tools: [
           createSubAgentTool(pipeline, bookId),
           createSubAgentTool(pipeline, bookId),
           createReadTool(projectRoot),
           createReadTool(projectRoot),
+          createReviseChapterTool(pipeline, bookId),
+          createWriteTruthFileTool(pipeline, projectRoot, bookId),
+          createRenameEntityTool(pipeline, projectRoot, bookId),
+          createPatchChapterTextTool(pipeline, projectRoot, bookId),
           createEditTool(projectRoot),
           createEditTool(projectRoot),
           createGrepTool(projectRoot),
           createGrepTool(projectRoot),
           createLsTool(projectRoot),
           createLsTool(projectRoot),

+ 21 - 4
packages/core/src/agent/agent-system-prompt.ts

@@ -74,7 +74,11 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
   - agent="reviser" 修订章节
   - agent="reviser" 修订章节
   - agent="exporter" 导出书籍
   - agent="exporter" 导出书籍
 - **read** — 读取书籍的设定文件或章节内容
 - **read** — 读取书籍的设定文件或章节内容
-- **edit** — 编辑设定文件(如修改角色名、调整世界观)
+- **revise_chapter** — 对已有章节做精修/重写/返工
+- **write_truth_file** — 整文件覆盖真相文件(story_bible、volume_outline、book_rules、current_focus 等)
+- **rename_entity** — 统一改角色/实体名
+- **patch_chapter_text** — 对已有章节做局部定点修补
+- **edit** — 通用精确字符串替换,仅作为兜底;不要用它处理真相文件或章节重写
 - **grep** — 搜索内容(如"哪一章提到了某个角色")
 - **grep** — 搜索内容(如"哪一章提到了某个角色")
 - **ls** — 列出文件或章节
 - **ls** — 列出文件或章节
 
 
@@ -82,7 +86,11 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 
 
 - 写章节、修订、审计等重操作 → 使用 sub_agent 委托对应子智能体
 - 写章节、修订、审计等重操作 → 使用 sub_agent 委托对应子智能体
 - 用户问设定相关问题 → 先用 read 读取对应文件再回答
 - 用户问设定相关问题 → 先用 read 读取对应文件再回答
-- 用户想做小修改(改名字、调设定)→ 用 edit 直接修改
+- 用户想改设定/改真相文件 → 优先用 write_truth_file,不要再用 edit 处理真相文件或章节重写
+- 用户要求重写/精修已有章节 → 用 revise_chapter
+- 用户要求角色或实体改名 → 用 rename_entity
+- 用户要求对某一章做局部小修 → 用 patch_chapter_text
+- edit 只留给非 canonical 文件的小范围精确替换
 - 其他情况 → 直接对话回答
 - 其他情况 → 直接对话回答
 - **注意:不要调用 architect,当前已有书籍,不需要建书**
 - **注意:不要调用 architect,当前已有书籍,不需要建书**
 - **不要在回复中添加表情符号**
 - **不要在回复中添加表情符号**
@@ -114,14 +122,23 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
   - agent="reviser" for chapter revision
   - agent="reviser" for chapter revision
   - agent="exporter" for book export
   - agent="exporter" for book export
 - **read** — Read truth files or chapter content
 - **read** — Read truth files or chapter content
-- **edit** — Edit truth files (rename characters, adjust world settings)
+- **revise_chapter** — Rewrite or polish an existing chapter
+- **write_truth_file** — Replace a canonical truth file in story/
+- **rename_entity** — Rename a character or entity across the book
+- **patch_chapter_text** — Apply a local deterministic patch to a chapter
+- **edit** — Exact string replacement fallback only; do not use it for truth files or chapter rewrites
 - **grep** — Search content across chapters
 - **grep** — Search content across chapters
 - **ls** — List files or chapters
 - **ls** — List files or chapters
 
 
 ## Guidelines
 ## Guidelines
 
 
 - Use sub_agent for heavy operations (writing, revision, auditing)
 - Use sub_agent for heavy operations (writing, revision, auditing)
-- Use read/edit for settings inquiries and small changes
+- Use read first for settings inquiries
+- Use write_truth_file for truth files and setting changes
+- Use revise_chapter for rewrite/polish/rework of existing chapters
+- Use rename_entity for character/entity renames
+- Use patch_chapter_text for local chapter fixes
+- Keep edit as a last-resort fallback for non-canonical files
 - Chat directly for other questions
 - Chat directly for other questions
 - **Do NOT call architect — a book already exists**
 - **Do NOT call architect — a book already exists**
 - **Do NOT use emoji in your responses**
 - **Do NOT use emoji in your responses**

+ 179 - 1
packages/core/src/agent/agent-tools.ts

@@ -1,9 +1,11 @@
 import { Type, type Static } from "@mariozechner/pi-ai";
 import { Type, type Static } from "@mariozechner/pi-ai";
 import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
 import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
 import type { PipelineRunner } from "../pipeline/runner.js";
 import type { PipelineRunner } from "../pipeline/runner.js";
-import type { ReviseMode } from "../agents/reviser.js";
+import { DEFAULT_REVISE_MODE, type ReviseMode } from "../agents/reviser.js";
 import { readFile, writeFile, readdir, stat } from "node:fs/promises";
 import { readFile, writeFile, readdir, stat } from "node:fs/promises";
 import { join, normalize, resolve } from "node:path";
 import { join, normalize, resolve } from "node:path";
+import { StateManager } from "../state/manager.js";
+import { createInteractionToolsFromDeps } from "../interaction/project-tools.js";
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // Helpers
 // Helpers
@@ -25,6 +27,23 @@ function safeBooksPath(booksRoot: string, relativePath: string): string {
   return resolved;
   return resolved;
 }
 }
 
 
+function resolveToolBookId(
+  toolName: string,
+  paramsBookId: string | undefined,
+  activeBookId: string | null,
+): string {
+  const resolvedBookId = paramsBookId ?? activeBookId ?? undefined;
+  if (!resolvedBookId) {
+    throw new Error(`${toolName} requires bookId when there is no active book.`);
+  }
+  return resolvedBookId;
+}
+
+function createDeterministicInteractionTools(pipeline: PipelineRunner, projectRoot: string) {
+  const state = new StateManager(projectRoot);
+  return createInteractionToolsFromDeps(pipeline, state);
+}
+
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // 1. SubAgentTool (sub_agent)
 // 1. SubAgentTool (sub_agent)
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
@@ -133,6 +152,165 @@ export function createSubAgentTool(pipeline: PipelineRunner, activeBookId: strin
   };
   };
 }
 }
 
 
+// ---------------------------------------------------------------------------
+// 1.5 Deterministic writing tools
+// ---------------------------------------------------------------------------
+
+const ReviseChapterParams = Type.Object({
+  bookId: Type.Optional(Type.String({ description: "Book ID. Defaults to the active book." })),
+  chapterNumber: Type.Optional(Type.Number({ description: "Chapter number. Defaults to the latest drafted chapter." })),
+  mode: Type.Optional(Type.Union([
+    Type.Literal("polish"),
+    Type.Literal("rewrite"),
+    Type.Literal("rework"),
+    Type.Literal("anti-detect"),
+    Type.Literal("spot-fix"),
+  ])),
+});
+
+export function createReviseChapterTool(
+  pipeline: PipelineRunner,
+  activeBookId: string | null,
+): AgentTool<typeof ReviseChapterParams> {
+  return {
+    name: "revise_chapter",
+    description:
+      "Rewrite or revise an existing chapter through the deterministic chapter revision pipeline. " +
+      "Use this for 精修、重写、返工, not the generic edit tool.",
+    label: "Revise Chapter",
+    parameters: ReviseChapterParams,
+    async execute(
+      _toolCallId: string,
+      params: Static<typeof ReviseChapterParams>,
+    ): Promise<AgentToolResult<undefined>> {
+      try {
+        const bookId = resolveToolBookId("revise_chapter", params.bookId, activeBookId);
+        const result = await pipeline.reviseDraft(
+          bookId,
+          params.chapterNumber as number,
+          (params.mode ?? DEFAULT_REVISE_MODE) as ReviseMode,
+        );
+        const responseText = `Chapter revision completed for "${bookId}"${params.chapterNumber ? ` chapter ${params.chapterNumber}` : ""}.`;
+        return textResult(responseText);
+      } catch (err: any) {
+        return textResult(`Failed to revise chapter: ${err?.message ?? String(err)}`);
+      }
+    },
+  };
+}
+
+const WriteTruthFileParams = Type.Object({
+  bookId: Type.Optional(Type.String({ description: "Book ID. Defaults to the active book." })),
+  fileName: Type.String({ description: "Truth file name, e.g. story_bible.md or current_focus.md" }),
+  content: Type.String({ description: "Complete replacement content for the truth file" }),
+});
+
+export function createWriteTruthFileTool(
+  pipeline: PipelineRunner,
+  projectRoot: string,
+  activeBookId: string | null,
+): AgentTool<typeof WriteTruthFileParams> {
+  const interactionTools = createDeterministicInteractionTools(pipeline, projectRoot);
+
+  return {
+    name: "write_truth_file",
+    description:
+      "Replace a canonical truth file in story/. Use this for story_bible, volume outline, rules, focus, and other book facts.",
+    label: "Write Truth File",
+    parameters: WriteTruthFileParams,
+    async execute(
+      _toolCallId: string,
+      params: Static<typeof WriteTruthFileParams>,
+    ): Promise<AgentToolResult<undefined>> {
+      try {
+        const bookId = resolveToolBookId("write_truth_file", params.bookId, activeBookId);
+        await interactionTools.writeTruthFile(bookId, params.fileName, params.content);
+        return textResult(`Updated ${params.fileName} for "${bookId}".`);
+      } catch (err: any) {
+        return textResult(`Failed to write truth file: ${err?.message ?? String(err)}`);
+      }
+    },
+  };
+}
+
+const RenameEntityParams = Type.Object({
+  bookId: Type.Optional(Type.String({ description: "Book ID. Defaults to the active book." })),
+  oldValue: Type.String({ description: "The old character/entity name" }),
+  newValue: Type.String({ description: "The new character/entity name" }),
+});
+
+export function createRenameEntityTool(
+  pipeline: PipelineRunner,
+  projectRoot: string,
+  activeBookId: string | null,
+): AgentTool<typeof RenameEntityParams> {
+  const interactionTools = createDeterministicInteractionTools(pipeline, projectRoot);
+
+  return {
+    name: "rename_entity",
+    description:
+      "Rename a character or entity across the current book through the deterministic edit controller.",
+    label: "Rename Entity",
+    parameters: RenameEntityParams,
+    async execute(
+      _toolCallId: string,
+      params: Static<typeof RenameEntityParams>,
+    ): Promise<AgentToolResult<undefined>> {
+      try {
+        const bookId = resolveToolBookId("rename_entity", params.bookId, activeBookId);
+        const result = await interactionTools.renameEntity(bookId, params.oldValue, params.newValue);
+        const responseText = (result as { __interaction?: { responseText?: string } }).__interaction?.responseText
+          ?? `Renamed ${params.oldValue} to ${params.newValue} in "${bookId}".`;
+        return textResult(responseText);
+      } catch (err: any) {
+        return textResult(`Failed to rename entity: ${err?.message ?? String(err)}`);
+      }
+    },
+  };
+}
+
+const PatchChapterTextParams = Type.Object({
+  bookId: Type.Optional(Type.String({ description: "Book ID. Defaults to the active book." })),
+  chapterNumber: Type.Number({ description: "Chapter number to patch" }),
+  targetText: Type.String({ description: "Exact text to replace" }),
+  replacementText: Type.String({ description: "Replacement text" }),
+});
+
+export function createPatchChapterTextTool(
+  pipeline: PipelineRunner,
+  projectRoot: string,
+  activeBookId: string | null,
+): AgentTool<typeof PatchChapterTextParams> {
+  const interactionTools = createDeterministicInteractionTools(pipeline, projectRoot);
+
+  return {
+    name: "patch_chapter_text",
+    description:
+      "Apply a local deterministic patch to an existing chapter and mark it for review.",
+    label: "Patch Chapter Text",
+    parameters: PatchChapterTextParams,
+    async execute(
+      _toolCallId: string,
+      params: Static<typeof PatchChapterTextParams>,
+    ): Promise<AgentToolResult<undefined>> {
+      try {
+        const bookId = resolveToolBookId("patch_chapter_text", params.bookId, activeBookId);
+        const result = await interactionTools.patchChapterText(
+          bookId,
+          params.chapterNumber,
+          params.targetText,
+          params.replacementText,
+        );
+        const responseText = (result as { __interaction?: { responseText?: string } }).__interaction?.responseText
+          ?? `Patched chapter ${params.chapterNumber} in "${bookId}".`;
+        return textResult(responseText);
+      } catch (err: any) {
+        return textResult(`Failed to patch chapter text: ${err?.message ?? String(err)}`);
+      }
+    },
+  };
+}
+
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // 2. Read Tool
 // 2. Read Tool
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------

+ 11 - 1
packages/core/src/agent/index.ts

@@ -1,3 +1,13 @@
 export { buildAgentSystemPrompt } from "./agent-system-prompt.js";
 export { buildAgentSystemPrompt } from "./agent-system-prompt.js";
-export { createSubAgentTool, createReadTool, createEditTool, createGrepTool, createLsTool } from "./agent-tools.js";
+export {
+  createSubAgentTool,
+  createReadTool,
+  createReviseChapterTool,
+  createWriteTruthFileTool,
+  createRenameEntityTool,
+  createPatchChapterTextTool,
+  createEditTool,
+  createGrepTool,
+  createLsTool,
+} from "./agent-tools.js";
 export { runAgentSession, evictAgentCache, type AgentSessionConfig, type AgentSessionResult } from "./agent-session.js";
 export { runAgentSession, evictAgentCache, type AgentSessionConfig, type AgentSessionResult } from "./agent-session.js";