agent-tools.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. import { Type, type Static } from "@mariozechner/pi-ai";
  2. import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
  3. import type { PipelineRunner } from "../pipeline/runner.js";
  4. import { DEFAULT_REVISE_MODE, type ReviseMode } from "../agents/reviser.js";
  5. import { readFile, writeFile, readdir, stat } from "node:fs/promises";
  6. import { join, normalize, resolve } from "node:path";
  7. import { StateManager } from "../state/manager.js";
  8. import { createInteractionToolsFromDeps } from "../interaction/project-tools.js";
  9. import { writeExportArtifact } from "../interaction/export-artifact.js";
  10. // ---------------------------------------------------------------------------
  11. // Helpers
  12. // ---------------------------------------------------------------------------
  13. function textResult(text: string): AgentToolResult<undefined> {
  14. return { content: [{ type: "text", text }], details: undefined };
  15. }
  16. /**
  17. * Resolve a user-supplied relative path against the books root and guard
  18. * against path-traversal (../ etc.).
  19. */
  20. function safeBooksPath(booksRoot: string, relativePath: string): string {
  21. const resolved = resolve(booksRoot, normalize(relativePath));
  22. if (!resolved.startsWith(booksRoot)) {
  23. throw new Error(`Path traversal blocked: ${relativePath}`);
  24. }
  25. return resolved;
  26. }
  27. function resolveToolBookId(
  28. toolName: string,
  29. paramsBookId: string | undefined,
  30. activeBookId: string | null,
  31. ): string {
  32. const resolvedBookId = paramsBookId ?? activeBookId ?? undefined;
  33. if (!resolvedBookId) {
  34. throw new Error(`${toolName} requires bookId when there is no active book.`);
  35. }
  36. return resolvedBookId;
  37. }
  38. function createDeterministicInteractionTools(pipeline: PipelineRunner, projectRoot: string) {
  39. const state = new StateManager(projectRoot);
  40. return createInteractionToolsFromDeps(pipeline, state);
  41. }
  42. // ---------------------------------------------------------------------------
  43. // 1. SubAgentTool (sub_agent)
  44. // ---------------------------------------------------------------------------
  45. const SubAgentParams = Type.Object({
  46. agent: Type.Union([
  47. Type.Literal("architect"),
  48. Type.Literal("writer"),
  49. Type.Literal("auditor"),
  50. Type.Literal("reviser"),
  51. Type.Literal("exporter"),
  52. ]),
  53. instruction: Type.String({ description: "Natural language instruction from the main Agent" }),
  54. bookId: Type.Optional(Type.String({ description: "Book ID — required for all agents except architect" })),
  55. title: Type.Optional(Type.String({ description: "Architect only: explicit book title. Required when creating a book." })),
  56. });
  57. function deriveBookIdFromTitle(title: string): string {
  58. return title
  59. .toLowerCase()
  60. .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
  61. .replace(/-+/g, "-")
  62. .replace(/^-+|-+$/g, "")
  63. .slice(0, 30);
  64. }
  65. export function createSubAgentTool(
  66. pipeline: PipelineRunner,
  67. activeBookId: string | null,
  68. projectRoot?: string,
  69. ): AgentTool<typeof SubAgentParams> {
  70. return {
  71. name: "sub_agent",
  72. description:
  73. "Delegate a heavy operation to a specialised sub-agent. " +
  74. "Use agent='architect' to initialise a new book, 'writer' to write the next chapter, " +
  75. "'auditor' to audit quality, 'reviser' to revise a chapter, 'exporter' to export.",
  76. label: "Sub-Agent",
  77. parameters: SubAgentParams,
  78. async execute(
  79. _toolCallId: string,
  80. params: Static<typeof SubAgentParams>,
  81. _signal?: AbortSignal,
  82. onUpdate?: AgentToolUpdateCallback,
  83. ): Promise<AgentToolResult<undefined>> {
  84. const { agent, instruction, bookId, title } = params;
  85. const progress = (msg: string) => {
  86. onUpdate?.(textResult(msg));
  87. };
  88. try {
  89. switch (agent) {
  90. case "architect": {
  91. // architect 只在没有书的时候可用(建书流程)
  92. if (activeBookId) {
  93. return textResult("当前已有书籍,不需要建书。如果你想创建新书,请先回到首页。");
  94. }
  95. const resolvedTitle = title?.trim();
  96. if (!resolvedTitle) {
  97. return textResult('Error: title is required for the architect agent.');
  98. }
  99. const id = bookId || deriveBookIdFromTitle(resolvedTitle) || `book-${Date.now().toString(36)}`;
  100. progress(`Starting architect for book "${id}"...`);
  101. await pipeline.initBook(
  102. { id, genre: "general", title: resolvedTitle, language: "zh" } as any,
  103. { externalContext: instruction },
  104. );
  105. progress(`Architect finished — book "${id}" foundation created.`);
  106. return textResult(`Book "${resolvedTitle}" (${id}) initialised successfully. Foundation files are ready.`);
  107. }
  108. case "writer": {
  109. if (!bookId) return textResult("Error: bookId is required for the writer agent.");
  110. progress(`Writing next chapter for "${bookId}"...`);
  111. const result = await pipeline.writeNextChapter(bookId);
  112. progress(`Writer finished chapter for "${bookId}".`);
  113. return textResult(
  114. `Chapter written for "${bookId}". ` +
  115. `Word count: ${(result as any).wordCount ?? "unknown"}.`,
  116. );
  117. }
  118. case "auditor": {
  119. if (!bookId) return textResult("Error: bookId is required for the auditor agent.");
  120. progress(`Auditing draft for "${bookId}"...`);
  121. const audit = await pipeline.auditDraft(bookId);
  122. progress(`Audit complete for "${bookId}".`);
  123. const issueCount = audit.issues?.length ?? 0;
  124. return textResult(
  125. `Audit complete for "${bookId}": ${issueCount} issue(s) found. ` +
  126. `Chapter ${audit.chapterNumber}.`,
  127. );
  128. }
  129. case "reviser": {
  130. if (!bookId) return textResult("Error: bookId is required for the reviser agent.");
  131. // Detect revision mode from instruction keywords
  132. const mode: ReviseMode = /rewrite|改写|重写/.test(instruction)
  133. ? "rewrite"
  134. : /polish|润色/.test(instruction)
  135. ? "polish"
  136. : /rework|返工/.test(instruction)
  137. ? "rework"
  138. : "spot-fix";
  139. progress(`Revising "${bookId}" in ${mode} mode...`);
  140. await pipeline.reviseDraft(bookId, undefined, mode);
  141. progress(`Revision complete for "${bookId}".`);
  142. return textResult(`Revision (${mode}) complete for "${bookId}".`);
  143. }
  144. case "exporter": {
  145. if (!bookId) return textResult("Error: bookId is required for the exporter agent.");
  146. if (!projectRoot) return textResult("Error: exporter requires projectRoot.");
  147. const inferredFormat = /epub/i.test(instruction)
  148. ? "epub"
  149. : /markdown|\bmd\b/i.test(instruction)
  150. ? "md"
  151. : "txt";
  152. const approvedOnly = /approved|已通过|通过章节/.test(instruction);
  153. const state = new StateManager(projectRoot);
  154. const result = await writeExportArtifact(state, bookId, {
  155. format: inferredFormat,
  156. approvedOnly,
  157. });
  158. return textResult(
  159. `Exported "${bookId}": ${result.chaptersExported} chapters, ${result.totalWords} words → ${result.outputPath}`,
  160. );
  161. }
  162. default:
  163. return textResult(`Unknown agent: ${agent}`);
  164. }
  165. } catch (err: any) {
  166. console.error(`[sub_agent] "${agent}" failed:`, err);
  167. return textResult(`Sub-agent "${agent}" failed: ${err?.message ?? String(err)}`);
  168. }
  169. },
  170. };
  171. }
  172. // ---------------------------------------------------------------------------
  173. // 1.5 Deterministic writing tools
  174. // ---------------------------------------------------------------------------
  175. const ReviseChapterParams = Type.Object({
  176. bookId: Type.Optional(Type.String({ description: "Book ID. Defaults to the active book." })),
  177. chapterNumber: Type.Optional(Type.Number({ description: "Chapter number. Defaults to the latest drafted chapter." })),
  178. mode: Type.Optional(Type.Union([
  179. Type.Literal("polish"),
  180. Type.Literal("rewrite"),
  181. Type.Literal("rework"),
  182. Type.Literal("anti-detect"),
  183. Type.Literal("spot-fix"),
  184. ])),
  185. });
  186. export function createReviseChapterTool(
  187. pipeline: PipelineRunner,
  188. activeBookId: string | null,
  189. ): AgentTool<typeof ReviseChapterParams> {
  190. return {
  191. name: "revise_chapter",
  192. description:
  193. "Rewrite or revise an existing chapter through the deterministic chapter revision pipeline. " +
  194. "Use this for 精修、重写、返工, not the generic edit tool.",
  195. label: "Revise Chapter",
  196. parameters: ReviseChapterParams,
  197. async execute(
  198. _toolCallId: string,
  199. params: Static<typeof ReviseChapterParams>,
  200. ): Promise<AgentToolResult<undefined>> {
  201. try {
  202. const bookId = resolveToolBookId("revise_chapter", params.bookId, activeBookId);
  203. const result = await pipeline.reviseDraft(
  204. bookId,
  205. params.chapterNumber as number,
  206. (params.mode ?? DEFAULT_REVISE_MODE) as ReviseMode,
  207. );
  208. const responseText = `Chapter revision completed for "${bookId}"${params.chapterNumber ? ` chapter ${params.chapterNumber}` : ""}.`;
  209. return textResult(responseText);
  210. } catch (err: any) {
  211. return textResult(`Failed to revise chapter: ${err?.message ?? String(err)}`);
  212. }
  213. },
  214. };
  215. }
  216. const WriteTruthFileParams = Type.Object({
  217. bookId: Type.Optional(Type.String({ description: "Book ID. Defaults to the active book." })),
  218. fileName: Type.String({ description: "Truth file name, e.g. story_bible.md or current_focus.md" }),
  219. content: Type.String({ description: "Complete replacement content for the truth file" }),
  220. });
  221. export function createWriteTruthFileTool(
  222. pipeline: PipelineRunner,
  223. projectRoot: string,
  224. activeBookId: string | null,
  225. ): AgentTool<typeof WriteTruthFileParams> {
  226. const interactionTools = createDeterministicInteractionTools(pipeline, projectRoot);
  227. return {
  228. name: "write_truth_file",
  229. description:
  230. "Replace a canonical truth file in story/. Use this for story_bible, volume outline, rules, focus, and other book facts.",
  231. label: "Write Truth File",
  232. parameters: WriteTruthFileParams,
  233. async execute(
  234. _toolCallId: string,
  235. params: Static<typeof WriteTruthFileParams>,
  236. ): Promise<AgentToolResult<undefined>> {
  237. try {
  238. const bookId = resolveToolBookId("write_truth_file", params.bookId, activeBookId);
  239. await interactionTools.writeTruthFile(bookId, params.fileName, params.content);
  240. return textResult(`Updated ${params.fileName} for "${bookId}".`);
  241. } catch (err: any) {
  242. return textResult(`Failed to write truth file: ${err?.message ?? String(err)}`);
  243. }
  244. },
  245. };
  246. }
  247. const RenameEntityParams = Type.Object({
  248. bookId: Type.Optional(Type.String({ description: "Book ID. Defaults to the active book." })),
  249. oldValue: Type.String({ description: "The old character/entity name" }),
  250. newValue: Type.String({ description: "The new character/entity name" }),
  251. });
  252. export function createRenameEntityTool(
  253. pipeline: PipelineRunner,
  254. projectRoot: string,
  255. activeBookId: string | null,
  256. ): AgentTool<typeof RenameEntityParams> {
  257. const interactionTools = createDeterministicInteractionTools(pipeline, projectRoot);
  258. return {
  259. name: "rename_entity",
  260. description:
  261. "Rename a character or entity across the current book through the deterministic edit controller.",
  262. label: "Rename Entity",
  263. parameters: RenameEntityParams,
  264. async execute(
  265. _toolCallId: string,
  266. params: Static<typeof RenameEntityParams>,
  267. ): Promise<AgentToolResult<undefined>> {
  268. try {
  269. const bookId = resolveToolBookId("rename_entity", params.bookId, activeBookId);
  270. const result = await interactionTools.renameEntity(bookId, params.oldValue, params.newValue);
  271. const responseText = (result as { __interaction?: { responseText?: string } }).__interaction?.responseText
  272. ?? `Renamed ${params.oldValue} to ${params.newValue} in "${bookId}".`;
  273. return textResult(responseText);
  274. } catch (err: any) {
  275. return textResult(`Failed to rename entity: ${err?.message ?? String(err)}`);
  276. }
  277. },
  278. };
  279. }
  280. const PatchChapterTextParams = Type.Object({
  281. bookId: Type.Optional(Type.String({ description: "Book ID. Defaults to the active book." })),
  282. chapterNumber: Type.Number({ description: "Chapter number to patch" }),
  283. targetText: Type.String({ description: "Exact text to replace" }),
  284. replacementText: Type.String({ description: "Replacement text" }),
  285. });
  286. export function createPatchChapterTextTool(
  287. pipeline: PipelineRunner,
  288. projectRoot: string,
  289. activeBookId: string | null,
  290. ): AgentTool<typeof PatchChapterTextParams> {
  291. const interactionTools = createDeterministicInteractionTools(pipeline, projectRoot);
  292. return {
  293. name: "patch_chapter_text",
  294. description:
  295. "Apply a local deterministic patch to an existing chapter and mark it for review.",
  296. label: "Patch Chapter Text",
  297. parameters: PatchChapterTextParams,
  298. async execute(
  299. _toolCallId: string,
  300. params: Static<typeof PatchChapterTextParams>,
  301. ): Promise<AgentToolResult<undefined>> {
  302. try {
  303. const bookId = resolveToolBookId("patch_chapter_text", params.bookId, activeBookId);
  304. const result = await interactionTools.patchChapterText(
  305. bookId,
  306. params.chapterNumber,
  307. params.targetText,
  308. params.replacementText,
  309. );
  310. const responseText = (result as { __interaction?: { responseText?: string } }).__interaction?.responseText
  311. ?? `Patched chapter ${params.chapterNumber} in "${bookId}".`;
  312. return textResult(responseText);
  313. } catch (err: any) {
  314. return textResult(`Failed to patch chapter text: ${err?.message ?? String(err)}`);
  315. }
  316. },
  317. };
  318. }
  319. // ---------------------------------------------------------------------------
  320. // 2. Read Tool
  321. // ---------------------------------------------------------------------------
  322. const ReadParams = Type.Object({
  323. path: Type.String({ description: "File path relative to books/, e.g. {bookId}/story/story_bible.md" }),
  324. });
  325. export function createReadTool(projectRoot: string): AgentTool<typeof ReadParams> {
  326. const booksRoot = join(projectRoot, "books");
  327. return {
  328. name: "read",
  329. description: "Read a file from the book directory. Path is relative to books/.",
  330. label: "Read File",
  331. parameters: ReadParams,
  332. async execute(
  333. _toolCallId: string,
  334. params: Static<typeof ReadParams>,
  335. ): Promise<AgentToolResult<undefined>> {
  336. try {
  337. const filePath = safeBooksPath(booksRoot, params.path);
  338. let content = await readFile(filePath, "utf-8");
  339. if (content.length > 10_000) {
  340. content = content.slice(0, 10_000) + "\n\n... [truncated at 10 000 chars]";
  341. }
  342. return textResult(content);
  343. } catch (err: any) {
  344. return textResult(`Failed to read "${params.path}": ${err?.message ?? String(err)}`);
  345. }
  346. },
  347. };
  348. }
  349. // ---------------------------------------------------------------------------
  350. // 3. Edit Tool
  351. // ---------------------------------------------------------------------------
  352. const EditParams = Type.Object({
  353. path: Type.String({ description: "File path relative to books/" }),
  354. old_string: Type.String({ description: "Exact string to find in the file" }),
  355. new_string: Type.String({ description: "Replacement string" }),
  356. });
  357. export function createEditTool(projectRoot: string): AgentTool<typeof EditParams> {
  358. const booksRoot = join(projectRoot, "books");
  359. return {
  360. name: "edit",
  361. description:
  362. "Edit a file using exact string replacement. " +
  363. "old_string must appear exactly once in the file. Path is relative to books/.",
  364. label: "Edit File",
  365. parameters: EditParams,
  366. async execute(
  367. _toolCallId: string,
  368. params: Static<typeof EditParams>,
  369. ): Promise<AgentToolResult<undefined>> {
  370. try {
  371. const filePath = safeBooksPath(booksRoot, params.path);
  372. const content = await readFile(filePath, "utf-8");
  373. const idx = content.indexOf(params.old_string);
  374. if (idx === -1) {
  375. return textResult(`old_string not found in "${params.path}".`);
  376. }
  377. if (content.indexOf(params.old_string, idx + 1) !== -1) {
  378. return textResult(`old_string appears more than once in "${params.path}". Provide a more specific match.`);
  379. }
  380. const updated = content.slice(0, idx) + params.new_string + content.slice(idx + params.old_string.length);
  381. await writeFile(filePath, updated, "utf-8");
  382. return textResult(`File "${params.path}" updated successfully.`);
  383. } catch (err: any) {
  384. return textResult(`Failed to edit "${params.path}": ${err?.message ?? String(err)}`);
  385. }
  386. },
  387. };
  388. }
  389. // ---------------------------------------------------------------------------
  390. // 4. Grep Tool
  391. // ---------------------------------------------------------------------------
  392. const GrepParams = Type.Object({
  393. bookId: Type.String({ description: "Book ID to search within" }),
  394. pattern: Type.String({ description: "Search pattern (plain text or regex)" }),
  395. });
  396. export function createGrepTool(projectRoot: string): AgentTool<typeof GrepParams> {
  397. const booksRoot = join(projectRoot, "books");
  398. return {
  399. name: "grep",
  400. description:
  401. "Search for a text pattern across a book's story/ and chapters/ directories. Returns matching lines.",
  402. label: "Search",
  403. parameters: GrepParams,
  404. async execute(
  405. _toolCallId: string,
  406. params: Static<typeof GrepParams>,
  407. ): Promise<AgentToolResult<undefined>> {
  408. try {
  409. const bookDir = safeBooksPath(booksRoot, params.bookId);
  410. const regex = new RegExp(params.pattern, "gi");
  411. const results: string[] = [];
  412. async function searchDir(dir: string, prefix: string) {
  413. let entries: string[];
  414. try {
  415. entries = await readdir(dir);
  416. } catch {
  417. return; // directory doesn't exist
  418. }
  419. for (const entry of entries) {
  420. const fullPath = join(dir, entry);
  421. const entryStat = await stat(fullPath);
  422. if (entryStat.isDirectory()) {
  423. await searchDir(fullPath, `${prefix}${entry}/`);
  424. } else if (entry.endsWith(".md") || entry.endsWith(".txt") || entry.endsWith(".json")) {
  425. const content = await readFile(fullPath, "utf-8");
  426. const lines = content.split("\n");
  427. for (let i = 0; i < lines.length; i++) {
  428. if (regex.test(lines[i])) {
  429. results.push(`${prefix}${entry}:${i + 1}: ${lines[i]}`);
  430. regex.lastIndex = 0; // reset for next test
  431. }
  432. }
  433. }
  434. }
  435. }
  436. await Promise.all([
  437. searchDir(join(bookDir, "story"), "story/"),
  438. searchDir(join(bookDir, "chapters"), "chapters/"),
  439. ]);
  440. if (results.length === 0) {
  441. return textResult(`No matches for "${params.pattern}" in book "${params.bookId}".`);
  442. }
  443. const truncated = results.length > 100
  444. ? results.slice(0, 100).join("\n") + `\n\n... [${results.length - 100} more matches]`
  445. : results.join("\n");
  446. return textResult(truncated);
  447. } catch (err: any) {
  448. return textResult(`Grep failed: ${err?.message ?? String(err)}`);
  449. }
  450. },
  451. };
  452. }
  453. // ---------------------------------------------------------------------------
  454. // 5. Ls Tool
  455. // ---------------------------------------------------------------------------
  456. const LsParams = Type.Object({
  457. bookId: Type.String({ description: "Book ID" }),
  458. subdir: Type.Optional(
  459. Type.String({ description: "Subdirectory within the book, e.g. 'story', 'chapters', 'story/runtime'" }),
  460. ),
  461. });
  462. export function createLsTool(projectRoot: string): AgentTool<typeof LsParams> {
  463. const booksRoot = join(projectRoot, "books");
  464. return {
  465. name: "ls",
  466. description: "List files in a book directory. Optionally specify a subdirectory like 'story' or 'chapters'.",
  467. label: "List Files",
  468. parameters: LsParams,
  469. async execute(
  470. _toolCallId: string,
  471. params: Static<typeof LsParams>,
  472. ): Promise<AgentToolResult<undefined>> {
  473. try {
  474. const base = safeBooksPath(booksRoot, params.bookId);
  475. const target = params.subdir ? safeBooksPath(base, params.subdir) : base;
  476. const entries = await readdir(target);
  477. const details: string[] = [];
  478. for (const entry of entries) {
  479. const fullPath = join(target, entry);
  480. try {
  481. const entryStat = await stat(fullPath);
  482. const suffix = entryStat.isDirectory() ? "/" : ` (${entryStat.size} bytes)`;
  483. details.push(`${entry}${suffix}`);
  484. } catch {
  485. details.push(entry);
  486. }
  487. }
  488. if (details.length === 0) {
  489. return textResult(`Directory is empty: ${params.bookId}/${params.subdir ?? ""}`);
  490. }
  491. return textResult(details.join("\n"));
  492. } catch (err: any) {
  493. return textResult(`Failed to list "${params.bookId}/${params.subdir ?? ""}": ${err?.message ?? String(err)}`);
  494. }
  495. },
  496. };
  497. }