Merge pull request #195 from SttFang/feat/sub-agent-params-alignment
Some checks are pending
CI / build-and-test (20, ubuntu-latest) (push) Waiting to run
CI / build-and-test (20, windows-latest) (push) Waiting to run
CI / build-and-test (22, ubuntu-latest) (push) Waiting to run
CI / build-and-test (22, windows-latest) (push) Waiting to run
CI / verify-pack (push) Blocked by required conditions

feat: sub_agent tool params alignment with pipeline methods
This commit is contained in:
hanjun fang 2026-04-17 01:11:38 +08:00 committed by GitHub
commit 52250539db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 222 additions and 44 deletions

View file

@ -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");
});
});
});

View file

@ -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");
});
});

View file

@ -17,6 +17,7 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
2. **** sub_agent architect
- "title"
- genreplatformlanguagetargetChapterschapterWordCount
- 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_biblevolume_outlinebook_rulescurrent_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

View file

@ -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(