Bläddra i källkod

Merge pull request #195 from SttFang/feat/sub-agent-params-alignment

feat: sub_agent tool params alignment with pipeline methods
hanjun fang 6 månader sedan
förälder
incheckning
52250539db

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

@@ -99,5 +99,15 @@ describe("buildAgentSystemPrompt", () => {
       // architect 不在可用工具列表里
       expect(prompt).not.toMatch(/agent="architect"/);
     });
+
+    it("book-mode prompt documents all sub_agent params", () => {
+      const prompt = buildAgentSystemPrompt("test-book", "zh");
+      expect(prompt).toContain("chapterWordCount");
+      expect(prompt).toContain("chapterNumber");
+      expect(prompt).toContain("mode");
+      expect(prompt).toContain("anti-detect");
+      expect(prompt).toContain("format");
+      expect(prompt).toContain("approvedOnly");
+    });
   });
 });

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

@@ -0,0 +1,150 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { createSubAgentTool } from "../agent/agent-tools.js";
+
+describe("SubAgentParams schema", () => {
+  const mockPipeline = {} as any;
+  const tool = createSubAgentTool(mockPipeline, null);
+  const schema = tool.parameters;
+  const props = (schema as any).properties;
+
+  it("has architect params: title, genre, platform, language, targetChapters", () => {
+    expect(props.title).toBeDefined();
+    expect(props.genre).toBeDefined();
+    expect(props.platform).toBeDefined();
+    expect(props.language).toBeDefined();
+    expect(props.targetChapters).toBeDefined();
+  });
+
+  it("has writer/architect param: chapterWordCount", () => {
+    expect(props.chapterWordCount).toBeDefined();
+  });
+
+  it("has reviser param: mode", () => {
+    expect(props.mode).toBeDefined();
+  });
+
+  it("has exporter params: format, approvedOnly", () => {
+    expect(props.format).toBeDefined();
+    expect(props.approvedOnly).toBeDefined();
+  });
+
+  it("has existing params: agent, instruction, bookId, chapterNumber", () => {
+    expect(props.agent).toBeDefined();
+    expect(props.instruction).toBeDefined();
+    expect(props.bookId).toBeDefined();
+    expect(props.chapterNumber).toBeDefined();
+  });
+
+  it("all new params have description with agent scope", () => {
+    expect(props.title.description).toMatch(/architect/i);
+    expect(props.genre.description).toMatch(/architect/i);
+    expect(props.mode.description).toMatch(/reviser/i);
+    expect(props.format.description).toMatch(/exporter/i);
+  });
+});
+
+describe("architect agent — BookConfig construction", () => {
+  let initBookMock: ReturnType<typeof vi.fn>;
+  let tool: ReturnType<typeof createSubAgentTool>;
+
+  beforeEach(() => {
+    initBookMock = vi.fn(async () => {});
+    const mockPipeline = { initBook: initBookMock } as any;
+    tool = createSubAgentTool(mockPipeline, null);
+  });
+
+  it("passes complete BookConfig with schema params", async () => {
+    await tool.execute("tc1", {
+      agent: "architect",
+      instruction: "Create a xuanhuan novel",
+      title: "天道独行",
+      genre: "xuanhuan",
+      platform: "tomato",
+      language: "zh",
+      targetChapters: 100,
+      chapterWordCount: 4000,
+    });
+    expect(initBookMock).toHaveBeenCalledOnce();
+    const [bookConfig, options] = initBookMock.mock.calls[0];
+    expect(bookConfig.title).toBe("天道独行");
+    expect(bookConfig.genre).toBe("xuanhuan");
+    expect(bookConfig.platform).toBe("tomato");
+    expect(bookConfig.language).toBe("zh");
+    expect(bookConfig.targetChapters).toBe(100);
+    expect(bookConfig.chapterWordCount).toBe(4000);
+    expect(bookConfig.status).toBe("outlining");
+    expect(bookConfig.createdAt).toBeDefined();
+    expect(options.externalContext).toBe("Create a xuanhuan novel");
+  });
+
+  it("uses defaults when optional params are omitted", async () => {
+    await tool.execute("tc2", { agent: "architect", instruction: "Create a book", title: "Test Book" });
+    const [bookConfig] = initBookMock.mock.calls[0];
+    expect(bookConfig.genre).toBe("general");
+    expect(bookConfig.platform).toBe("other");
+    expect(bookConfig.language).toBe("zh");
+    expect(bookConfig.targetChapters).toBe(200);
+    expect(bookConfig.chapterWordCount).toBe(3000);
+  });
+});
+
+describe("writer agent — wordCount passthrough", () => {
+  let writeNextChapterMock: ReturnType<typeof vi.fn>;
+  let tool: ReturnType<typeof createSubAgentTool>;
+
+  beforeEach(() => {
+    writeNextChapterMock = vi.fn(async () => ({ wordCount: 3000 }));
+    const mockPipeline = { writeNextChapter: writeNextChapterMock } as any;
+    tool = createSubAgentTool(mockPipeline, "existing-book");
+  });
+
+  it("passes chapterWordCount as wordCount", async () => {
+    await tool.execute("tc1", { agent: "writer", instruction: "Write", bookId: "my-book", chapterWordCount: 5000 });
+    expect(writeNextChapterMock).toHaveBeenCalledWith("my-book", 5000);
+  });
+
+  it("passes undefined when chapterWordCount omitted", async () => {
+    await tool.execute("tc2", { agent: "writer", instruction: "Write", bookId: "my-book" });
+    expect(writeNextChapterMock).toHaveBeenCalledWith("my-book", undefined);
+  });
+});
+
+describe("auditor agent — rich return value", () => {
+  it("returns issue details with severity", async () => {
+    const auditDraftMock = vi.fn(async () => ({
+      chapterNumber: 3, passed: false,
+      issues: [
+        { severity: "warning", description: "Pacing too fast" },
+        { severity: "critical", description: "Name inconsistency" },
+      ],
+    }));
+    const tool = createSubAgentTool({ auditDraft: auditDraftMock } as any, "book");
+    const result = await tool.execute("tc1", { agent: "auditor", instruction: "Audit", bookId: "my-book", chapterNumber: 3 });
+    const text = (result.content[0] as { type: "text"; text: string }).text;
+    expect(text).toContain("FAILED");
+    expect(text).toContain("2 issue(s)");
+    expect(text).toContain("[warning]");
+    expect(text).toContain("[critical]");
+    expect(text).toContain("Pacing too fast");
+  });
+});
+
+describe("reviser agent — mode field", () => {
+  let reviseDraftMock: ReturnType<typeof vi.fn>;
+  let tool: ReturnType<typeof createSubAgentTool>;
+
+  beforeEach(() => {
+    reviseDraftMock = vi.fn(async () => ({}));
+    tool = createSubAgentTool({ reviseDraft: reviseDraftMock } as any, "book");
+  });
+
+  it("uses mode param directly", async () => {
+    await tool.execute("tc1", { agent: "reviser", instruction: "Fix", bookId: "my-book", chapterNumber: 5, mode: "anti-detect" });
+    expect(reviseDraftMock).toHaveBeenCalledWith("my-book", 5, "anti-detect");
+  });
+
+  it("defaults to spot-fix", async () => {
+    await tool.execute("tc2", { agent: "reviser", instruction: "Fix", bookId: "my-book" });
+    expect(reviseDraftMock).toHaveBeenCalledWith("my-book", undefined, "spot-fix");
+  });
+});

+ 14 - 20
packages/core/src/agent/agent-system-prompt.ts

@@ -17,6 +17,7 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 
 2. **确认建书**(调用阶段)— 当信息足够时,调用 sub_agent 工具委托 architect 子智能体建书:
    - 必须显式传入 "title" 参数,不能留空
+   - 同时传入结构化参数:genre(题材)、platform(平台)、language(语言)、targetChapters(章数)、chapterWordCount(每章字数)
    - instruction 中包含收集到的所有信息(题材、世界观、主角、冲突等)
    - architect 会生成完整的 foundation(世界观设定、卷纲规划、叙事规则等)
 
@@ -47,6 +48,7 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 
 2. **Create book** — When you have enough info, call the sub_agent tool with agent="architect":
    - Pass the explicit "title" parameter; do not leave it empty
+   - Pass structured params: genre, platform, language, targetChapters, chapterWordCount
    - Include all collected info in the instruction
    - The architect will generate the complete foundation
 
@@ -71,21 +73,17 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 ## 可用工具
 
 - **sub_agent** — 委托子智能体执行重操作:
-  - agent="writer" 写下一章(支持 chapterWordCount)
-  - agent="auditor" 审计章节质量(可指定 chapterNumber)
-  - agent="reviser" 修订章节(可指定 chapterNumber 和 mode)
-  - agent="exporter" 导出书籍(可指定 format 和 approvedOnly)
-  - **chapterNumber 参数**:auditor 和 reviser 支持指定章节号,不指定则默认最新章节
-  - **chapterWordCount 参数**:writer 可指定本章目标字数
-  - **mode 参数**:reviser 可显式指定修订模式
-  - **format / approvedOnly 参数**:exporter 可显式指定导出格式和是否仅导出已通过章节
+  - agent="writer" 写下一章(参数:chapterWordCount 覆盖字数)
+  - agent="auditor" 审计章节质量(参数:chapterNumber 指定章节)
+  - agent="reviser" 修订章节(参数:chapterNumber, mode: spot-fix/polish/rewrite/rework/anti-detect)
+  - agent="exporter" 导出书籍(参数:format: txt/md/epub, approvedOnly: true/false)
 - **read** — 读取书籍的设定文件或章节内容
 - **revise_chapter** — 对已有章节做精修/重写/返工
 - **write_truth_file** — 整文件覆盖真相文件(story_bible、volume_outline、book_rules、current_focus 等)
 - **rename_entity** — 统一改角色/实体名
 - **patch_chapter_text** — 对已有章节做局部定点修补
-- **edit** — 通用精确字符串替换编辑
-- **write** — 新建或整文件覆盖写入
+- **edit** — 在设定文件里做精确字符串替换(章节正文请用 patch_chapter_text)
+- **write** — 新建文件,或者重写整个文件(已有内容会被覆盖;真相文件优先用 write_truth_file,章节修订用 revise_chapter)
 - **grep** — 搜索内容(如"哪一章提到了某个角色")
 - **ls** — 列出文件或章节
 
@@ -124,21 +122,17 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 ## Available Tools
 
 - **sub_agent** — Delegate to sub-agents:
-  - agent="writer" for writing next chapter (supports chapterWordCount)
-  - agent="auditor" for chapter quality audit (supports chapterNumber)
-  - agent="reviser" for chapter revision (supports chapterNumber and explicit mode)
-  - agent="exporter" for book export (supports format and approvedOnly)
-  - **chapterNumber param**: auditor and reviser accept an explicit chapter number; omit for latest
-  - **chapterWordCount param**: writer can override the chapter target word count
-  - **mode param**: reviser can explicitly choose the revision mode
-  - **format / approvedOnly params**: exporter can explicitly choose the export format and approval filter
+  - agent="writer" write next chapter (params: chapterWordCount for word count override)
+  - agent="auditor" audit chapter quality (params: chapterNumber to target specific chapter)
+  - agent="reviser" revise chapter (params: chapterNumber, mode: spot-fix/polish/rewrite/rework/anti-detect)
+  - agent="exporter" export book (params: format: txt/md/epub, approvedOnly: true/false)
 - **read** — Read truth files or chapter content
 - **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** — Generic exact string replacement editor
-- **write** — Create or fully overwrite a file
+- **edit** — Exact string replacement on setting files (use patch_chapter_text for chapter text)
+- **write** — Create a new file, or fully replace an existing file's content (prefer write_truth_file for canonical truth files, revise_chapter for chapter revisions)
 - **grep** — Search content across chapters
 - **ls** — List files or chapters
 

+ 48 - 24
packages/core/src/agent/agent-tools.ts

@@ -57,24 +57,39 @@ const SubAgentParams = Type.Object({
     Type.Literal("reviser"),
     Type.Literal("exporter"),
   ]),
-  instruction: Type.String({ description: "Natural language instruction from the main Agent" }),
+  instruction: Type.String({ description: "Natural language instruction for the sub-agent" }),
   bookId: Type.Optional(Type.String({ description: "Book ID — required for all agents except architect" })),
-  title: Type.Optional(Type.String({ description: "Architect only: explicit book title. Required when creating a book." })),
-  chapterNumber: Type.Optional(Type.Number({ description: "Target chapter number for auditor/reviser. Omit to use the latest chapter." })),
-  chapterWordCount: Type.Optional(Type.Number({ description: "Writer only: explicit chapterWordCount override." })),
+  chapterNumber: Type.Optional(Type.Number({ description: "auditor/reviser: target chapter number. Omit to use the latest chapter." })),
+  // -- architect params --
+  title: Type.Optional(Type.String({ description: "architect only: explicit book title. Required when creating a book." })),
+  genre: Type.Optional(Type.String({ description: "architect only: genre (xuanhuan, urban, mystery, romance, scifi, fantasy, wuxia, general, etc.)" })),
+  platform: Type.Optional(Type.Union([
+    Type.Literal("tomato"),
+    Type.Literal("qidian"),
+    Type.Literal("feilu"),
+    Type.Literal("other"),
+  ], { description: "architect only: target platform. Default: other" })),
+  language: Type.Optional(Type.Union([
+    Type.Literal("zh"),
+    Type.Literal("en"),
+  ], { description: "architect only: writing language. Default: zh" })),
+  targetChapters: Type.Optional(Type.Number({ description: "architect only: total chapter count. Default: 200" })),
+  chapterWordCount: Type.Optional(Type.Number({ description: "architect/writer: words per chapter. Default: 3000" })),
+  // -- reviser params --
   mode: Type.Optional(Type.Union([
     Type.Literal("spot-fix"),
     Type.Literal("polish"),
     Type.Literal("rewrite"),
     Type.Literal("rework"),
     Type.Literal("anti-detect"),
-  ])),
+  ], { description: "reviser only: revision mode. Default: spot-fix" })),
+  // -- exporter params --
   format: Type.Optional(Type.Union([
     Type.Literal("txt"),
     Type.Literal("md"),
     Type.Literal("epub"),
-  ])),
-  approvedOnly: Type.Optional(Type.Boolean({ description: "Exporter only: export approved chapters only." })),
+  ], { description: "exporter only: export format. Default: txt" })),
+  approvedOnly: Type.Optional(Type.Boolean({ description: "exporter only: export only approved chapters. Default: false" })),
 });
 
 function deriveBookIdFromTitle(title: string): string {
@@ -105,7 +120,7 @@ export function createSubAgentTool(
       _signal?: AbortSignal,
       onUpdate?: AgentToolUpdateCallback,
     ): Promise<AgentToolResult<undefined>> {
-      const { agent, instruction, bookId, title, chapterNumber, chapterWordCount, mode, format, approvedOnly } = params;
+      const { agent, instruction, bookId, title, chapterNumber, genre, platform, language, targetChapters, chapterWordCount, mode, format, approvedOnly } = params;
 
       const progress = (msg: string) => {
         onUpdate?.(textResult(msg));
@@ -114,7 +129,6 @@ export function createSubAgentTool(
       try {
         switch (agent) {
           case "architect": {
-            // architect 只在没有书的时候可用(建书流程)
             if (activeBookId) {
               return textResult("当前已有书籍,不需要建书。如果你想创建新书,请先回到首页。");
             }
@@ -123,9 +137,21 @@ export function createSubAgentTool(
               return textResult('Error: title is required for the architect agent.');
             }
             const id = bookId || deriveBookIdFromTitle(resolvedTitle) || `book-${Date.now().toString(36)}`;
+            const now = new Date().toISOString();
             progress(`Starting architect for book "${id}"...`);
             await pipeline.initBook(
-              { id, genre: "general", title: resolvedTitle, language: "zh" } as any,
+              {
+                id,
+                title: resolvedTitle,
+                genre: genre ?? "general",
+                platform: (platform ?? "other") as any,
+                language: (language ?? "zh") as any,
+                status: "outlining" as any,
+                targetChapters: targetChapters ?? 200,
+                chapterWordCount: chapterWordCount ?? 3000,
+                createdAt: now,
+                updatedAt: now,
+              },
               { externalContext: instruction },
             );
             progress(`Architect finished — book "${id}" foundation created.`);
@@ -148,22 +174,18 @@ export function createSubAgentTool(
             progress(`Auditing chapter ${chapterNumber ?? "latest"} for "${bookId}"...`);
             const audit = await pipeline.auditDraft(bookId, chapterNumber);
             progress(`Audit complete for "${bookId}".`);
-            const issueCount = audit.issues?.length ?? 0;
+            const issueLines = (audit.issues ?? [])
+              .map((i: any) => `[${i.severity}] ${i.description}`)
+              .join("\n");
             return textResult(
-              `Audit complete for "${bookId}": ${issueCount} issue(s) found. ` +
-              `Chapter ${audit.chapterNumber}.`,
+              `Audit chapter ${audit.chapterNumber}: ${audit.passed ? "PASSED" : "FAILED"}, ${(audit.issues ?? []).length} issue(s).` +
+              (issueLines ? `\n${issueLines}` : ""),
             );
           }
 
           case "reviser": {
             if (!bookId) return textResult("Error: bookId is required for the reviser agent.");
-            const resolvedMode: ReviseMode = mode ?? (/rewrite|改写|重写/.test(instruction)
-              ? "rewrite"
-              : /polish|润色/.test(instruction)
-                ? "polish"
-                : /rework|返工/.test(instruction)
-                  ? "rework"
-                  : "spot-fix");
+            const resolvedMode: ReviseMode = (mode as ReviseMode) ?? "spot-fix";
             progress(`Revising "${bookId}" chapter ${chapterNumber ?? "latest"} in ${resolvedMode} mode...`);
             await pipeline.reviseDraft(bookId, chapterNumber, resolvedMode);
             progress(`Revision complete for "${bookId}".`);
@@ -371,8 +393,9 @@ export function createEditTool(projectRoot: string): AgentTool<typeof EditParams
   return {
     name: "edit",
     description:
-      "Edit a file using exact string replacement. " +
-      "old_string must appear exactly once in the file. Path is relative to books/.",
+      "Edit a file under books/ via exact string replacement. " +
+      "old_string must appear exactly once in the file. " +
+      "For chapter text use patch_chapter_text; for canonical truth files (story_bible/volume_outline/book_rules/current_focus) prefer write_truth_file.",
     label: "Edit File",
     parameters: EditParams,
     async execute(
@@ -414,8 +437,9 @@ export function createWriteFileTool(projectRoot: string): AgentTool<typeof Write
   return {
     name: "write",
     description:
-      "Create a new file or overwrite an existing file. " +
-      "Path is relative to books/. Parent directories are created automatically.",
+      "Create a new file, or fully replace an existing file's content under books/. " +
+      "Parent directories are created automatically. Existing content is overwritten silently — " +
+      "for canonical truth files prefer write_truth_file; for chapter revisions use revise_chapter.",
     label: "Write File",
     parameters: WriteFileParams,
     async execute(