diff --git a/packages/core/src/__tests__/agent-system-prompt.test.ts b/packages/core/src/__tests__/agent-system-prompt.test.ts index e4b56b22..4ddf6f76 100644 --- a/packages/core/src/__tests__/agent-system-prompt.test.ts +++ b/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"); + }); }); }); diff --git a/packages/core/src/__tests__/agent-tools-params.test.ts b/packages/core/src/__tests__/agent-tools-params.test.ts new file mode 100644 index 00000000..62277e59 --- /dev/null +++ b/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; + let tool: ReturnType; + + 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; + let tool: ReturnType; + + 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; + let tool: ReturnType; + + 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"); + }); +}); diff --git a/packages/core/src/agent/agent-system-prompt.ts b/packages/core/src/agent/agent-system-prompt.ts index 88202774..a8469a34 100644 --- a/packages/core/src/agent/agent-system-prompt.ts +++ b/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 diff --git a/packages/core/src/agent/agent-tools.ts b/packages/core/src/agent/agent-tools.ts index 068ce3d5..9a061a67 100644 --- a/packages/core/src/agent/agent-tools.ts +++ b/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> { - 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