Procházet zdrojové kódy

feat(core): align sub_agent tool params with pipeline runner methods

Extend SubAgentParams schema with explicit architect params (genre,
platform, language, targetChapters) so the architect agent stops
hardcoding initBook config. Auditor now returns issue severity +
description list. Reviser reads mode directly from the schema instead
of regex-matching the instruction. System prompt and tests are updated
in lockstep with the schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fanghanjun před 6 měsíci
rodič
revize
31d170a9e0

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

@@ -99,5 +99,15 @@ describe("buildAgentSystemPrompt", () => {
       // architect 不在可用工具列表里
       // architect 不在可用工具列表里
       expect(prompt).not.toMatch(/agent="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");
+  });
+});

+ 10 - 16
packages/core/src/agent/agent-system-prompt.ts

@@ -17,6 +17,7 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 
 
 2. **确认建书**(调用阶段)— 当信息足够时,调用 sub_agent 工具委托 architect 子智能体建书:
 2. **确认建书**(调用阶段)— 当信息足够时,调用 sub_agent 工具委托 architect 子智能体建书:
    - 必须显式传入 "title" 参数,不能留空
    - 必须显式传入 "title" 参数,不能留空
+   - 同时传入结构化参数:genre(题材)、platform(平台)、language(语言)、targetChapters(章数)、chapterWordCount(每章字数)
    - instruction 中包含收集到的所有信息(题材、世界观、主角、冲突等)
    - instruction 中包含收集到的所有信息(题材、世界观、主角、冲突等)
    - architect 会生成完整的 foundation(世界观设定、卷纲规划、叙事规则等)
    - 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":
 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 the explicit "title" parameter; do not leave it empty
+   - Pass structured params: genre, platform, language, targetChapters, chapterWordCount
    - Include all collected info in the instruction
    - Include all collected info in the instruction
    - The architect will generate the complete foundation
    - The architect will generate the complete foundation
 
 
@@ -71,14 +73,10 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 ## 可用工具
 ## 可用工具
 
 
 - **sub_agent** — 委托子智能体执行重操作:
 - **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** — 读取书籍的设定文件或章节内容
 - **read** — 读取书籍的设定文件或章节内容
 - **revise_chapter** — 对已有章节做精修/重写/返工
 - **revise_chapter** — 对已有章节做精修/重写/返工
 - **write_truth_file** — 整文件覆盖真相文件(story_bible、volume_outline、book_rules、current_focus 等)
 - **write_truth_file** — 整文件覆盖真相文件(story_bible、volume_outline、book_rules、current_focus 等)
@@ -124,14 +122,10 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 ## Available Tools
 ## Available Tools
 
 
 - **sub_agent** — Delegate to sub-agents:
 - **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
 - **read** — Read truth files or chapter content
 - **revise_chapter** — Rewrite or polish an existing chapter
 - **revise_chapter** — Rewrite or polish an existing chapter
 - **write_truth_file** — Replace a canonical truth file in story/
 - **write_truth_file** — Replace a canonical truth file in story/

+ 42 - 20
packages/core/src/agent/agent-tools.ts

@@ -57,24 +57,39 @@ const SubAgentParams = Type.Object({
     Type.Literal("reviser"),
     Type.Literal("reviser"),
     Type.Literal("exporter"),
     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" })),
   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([
   mode: Type.Optional(Type.Union([
     Type.Literal("spot-fix"),
     Type.Literal("spot-fix"),
     Type.Literal("polish"),
     Type.Literal("polish"),
     Type.Literal("rewrite"),
     Type.Literal("rewrite"),
     Type.Literal("rework"),
     Type.Literal("rework"),
     Type.Literal("anti-detect"),
     Type.Literal("anti-detect"),
-  ])),
+  ], { description: "reviser only: revision mode. Default: spot-fix" })),
+  // -- exporter params --
   format: Type.Optional(Type.Union([
   format: Type.Optional(Type.Union([
     Type.Literal("txt"),
     Type.Literal("txt"),
     Type.Literal("md"),
     Type.Literal("md"),
     Type.Literal("epub"),
     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 {
 function deriveBookIdFromTitle(title: string): string {
@@ -105,7 +120,7 @@ export function createSubAgentTool(
       _signal?: AbortSignal,
       _signal?: AbortSignal,
       onUpdate?: AgentToolUpdateCallback,
       onUpdate?: AgentToolUpdateCallback,
     ): Promise<AgentToolResult<undefined>> {
     ): 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) => {
       const progress = (msg: string) => {
         onUpdate?.(textResult(msg));
         onUpdate?.(textResult(msg));
@@ -114,7 +129,6 @@ export function createSubAgentTool(
       try {
       try {
         switch (agent) {
         switch (agent) {
           case "architect": {
           case "architect": {
-            // architect 只在没有书的时候可用(建书流程)
             if (activeBookId) {
             if (activeBookId) {
               return textResult("当前已有书籍,不需要建书。如果你想创建新书,请先回到首页。");
               return textResult("当前已有书籍,不需要建书。如果你想创建新书,请先回到首页。");
             }
             }
@@ -123,9 +137,21 @@ export function createSubAgentTool(
               return textResult('Error: title is required for the architect agent.');
               return textResult('Error: title is required for the architect agent.');
             }
             }
             const id = bookId || deriveBookIdFromTitle(resolvedTitle) || `book-${Date.now().toString(36)}`;
             const id = bookId || deriveBookIdFromTitle(resolvedTitle) || `book-${Date.now().toString(36)}`;
+            const now = new Date().toISOString();
             progress(`Starting architect for book "${id}"...`);
             progress(`Starting architect for book "${id}"...`);
             await pipeline.initBook(
             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 },
               { externalContext: instruction },
             );
             );
             progress(`Architect finished — book "${id}" foundation created.`);
             progress(`Architect finished — book "${id}" foundation created.`);
@@ -148,22 +174,18 @@ export function createSubAgentTool(
             progress(`Auditing chapter ${chapterNumber ?? "latest"} for "${bookId}"...`);
             progress(`Auditing chapter ${chapterNumber ?? "latest"} for "${bookId}"...`);
             const audit = await pipeline.auditDraft(bookId, chapterNumber);
             const audit = await pipeline.auditDraft(bookId, chapterNumber);
             progress(`Audit complete for "${bookId}".`);
             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(
             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": {
           case "reviser": {
             if (!bookId) return textResult("Error: bookId is required for the reviser agent.");
             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...`);
             progress(`Revising "${bookId}" chapter ${chapterNumber ?? "latest"} in ${resolvedMode} mode...`);
             await pipeline.reviseDraft(bookId, chapterNumber, resolvedMode);
             await pipeline.reviseDraft(bookId, chapterNumber, resolvedMode);
             progress(`Revision complete for "${bookId}".`);
             progress(`Revision complete for "${bookId}".`);