Просмотр исходного кода

feat: expose input governance controls to agent mode

Ma 7 месяцев назад
Родитель
Сommit
b9060dbd00
3 измененных файлов с 279 добавлено и 2 удалено
  1. 119 0
      packages/core/src/__tests__/pipeline-agent.test.ts
  2. 103 2
      packages/core/src/pipeline/agent.ts
  3. 57 0
      skills/SKILL.md

+ 119 - 0
packages/core/src/__tests__/pipeline-agent.test.ts

@@ -0,0 +1,119 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { AGENT_TOOLS, executeAgentTool } from "../pipeline/agent.js";
+import { PipelineRunner, StateManager, type PipelineConfig } from "../index.js";
+
+describe("agent pipeline tools", () => {
+  let root: string;
+  let state: StateManager;
+  let pipeline: PipelineRunner;
+  let config: PipelineConfig;
+  let bookId: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-agent-tools-"));
+    state = new StateManager(root);
+    bookId = "agent-book";
+
+    config = {
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+      inputGovernanceMode: "v2",
+    };
+
+    pipeline = new PipelineRunner(config);
+
+    await state.saveBookConfig(bookId, {
+      id: bookId,
+      title: "Agent Book",
+      platform: "tomato",
+      genre: "other",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 3000,
+      createdAt: "2026-03-22T00:00:00.000Z",
+      updatedAt: "2026-03-22T00:00:00.000Z",
+    });
+
+    const storyDir = join(state.bookDir(bookId), "story");
+    await mkdir(join(storyDir, "runtime"), { recursive: true });
+    await mkdir(join(state.bookDir(bookId), "chapters"), { recursive: true });
+    await writeFile(join(state.bookDir(bookId), "chapters", "index.json"), "[]", "utf-8");
+
+    await Promise.all([
+      writeFile(join(storyDir, "author_intent.md"), "# Author Intent\n\nKeep the story centered on the mentor conflict.\n", "utf-8"),
+      writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
+      writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
+      writeFile(join(storyDir, "book_rules.md"), "---\nprohibitions:\n  - Do not reveal the mastermind\n---\n\n# Book Rules\n", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
+    ]);
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("registers the input governance tools", () => {
+    const toolNames = AGENT_TOOLS.map((tool) => tool.name);
+
+    expect(toolNames).toContain("plan_chapter");
+    expect(toolNames).toContain("compose_chapter");
+    expect(toolNames).toContain("update_author_intent");
+    expect(toolNames).toContain("update_current_focus");
+  });
+
+  it("plans and composes chapters through the agent tool surface", async () => {
+    const planResult = JSON.parse(await executeAgentTool(
+      pipeline,
+      state,
+      config,
+      "plan_chapter",
+      { bookId, guidance: "Ignore the guild chase and focus on the mentor conflict." },
+    ));
+
+    expect(planResult.intentPath).toBe("story/runtime/chapter-0001.intent.md");
+
+    const composeResult = JSON.parse(await executeAgentTool(
+      pipeline,
+      state,
+      config,
+      "compose_chapter",
+      { bookId, guidance: "Ignore the guild chase and focus on the mentor conflict." },
+    ));
+
+    expect(composeResult.contextPath).toBe("story/runtime/chapter-0001.context.json");
+    expect(composeResult.ruleStackPath).toBe("story/runtime/chapter-0001.rule-stack.yaml");
+    expect(composeResult.tracePath).toBe("story/runtime/chapter-0001.trace.json");
+  });
+
+  it("updates author_intent.md and current_focus.md through dedicated tools", async () => {
+    await executeAgentTool(pipeline, state, config, "update_author_intent", {
+      bookId,
+      content: "# Author Intent\n\nMake this a colder revenge story.\n",
+    });
+    await executeAgentTool(pipeline, state, config, "update_current_focus", {
+      bookId,
+      content: "# Current Focus\n\nSpend the next two chapters on mentor fallout.\n",
+    });
+
+    await expect(readFile(join(state.bookDir(bookId), "story", "author_intent.md"), "utf-8"))
+      .resolves.toContain("colder revenge story");
+    await expect(readFile(join(state.bookDir(bookId), "story", "current_focus.md"), "utf-8"))
+      .resolves.toContain("mentor fallout");
+  });
+});

+ 103 - 2
packages/core/src/pipeline/agent.ts

@@ -17,6 +17,30 @@ const TOOLS: ReadonlyArray<ToolDefinition> = [
       required: ["bookId"],
     },
   },
+  {
+    name: "plan_chapter",
+    description: "为下一章生成 chapter intent(章节目标、必须保留、冲突说明)。适合在正式写作前检查当前控制输入是否正确。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "书籍ID" },
+        guidance: { type: "string", description: "本章额外指导(可选,自然语言)" },
+      },
+      required: ["bookId"],
+    },
+  },
+  {
+    name: "compose_chapter",
+    description: "为下一章生成 context/rule-stack/trace 运行时产物。适合在写作前确认系统实际会带哪些上下文和优先级。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "书籍ID" },
+        guidance: { type: "string", description: "本章额外指导(可选,自然语言)" },
+      },
+      required: ["bookId"],
+    },
+  },
   {
     name: "audit_chapter",
     description: "审计指定章节。检查连续性、OOC、数值、伏笔等问题。",
@@ -64,6 +88,30 @@ const TOOLS: ReadonlyArray<ToolDefinition> = [
       required: ["title", "genre", "platform"],
     },
   },
+  {
+    name: "update_author_intent",
+    description: "更新书级长期意图文档 author_intent.md。用于修改这本书长期想成为什么。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "书籍ID" },
+        content: { type: "string", description: "author_intent.md 的完整新内容" },
+      },
+      required: ["bookId", "content"],
+    },
+  },
+  {
+    name: "update_current_focus",
+    description: "更新当前关注点文档 current_focus.md。用于把最近几章的注意力拉回某条主线或冲突。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "书籍ID" },
+        content: { type: "string", description: "current_focus.md 的完整新内容" },
+      },
+      required: ["bookId", "content"],
+    },
+  },
   {
     name: "get_book_status",
     description: "获取书籍状态概览:章数、字数、最近章节审计情况。",
@@ -199,9 +247,13 @@ export async function runAgentLoop(
 | get_book_status | 查看书的章数、字数、审计状态 |
 | read_truth_files | 读取长期记忆(状态卡、资源账本、伏笔池)和设定(世界观、卷纲、本书规则) |
 | create_book | 建书,生成世界观、卷纲、本书规则(自动加载题材 genre profile) |
+| plan_chapter | 先生成 chapter intent,确认本章目标/冲突/优先级 |
+| compose_chapter | 再生成 runtime context/rule stack,确认实际输入 |
 | write_draft | 写一章草稿(自动加载 genre profile + book_rules) |
 | audit_chapter | 审计章节(32维度,按题材条件启用,含AI痕迹+敏感词检测) |
 | revise_chapter | 修订章节(支持 polish/rewrite/rework/spot-fix/anti-detect 五种模式) |
+| update_author_intent | 更新书级长期意图 author_intent.md |
+| update_current_focus | 更新当前关注点 current_focus.md |
 | write_full_pipeline | 完整管线:写 → 审 → 改(如需要) |
 | scan_market | 扫描平台排行榜,分析市场趋势 |
 | web_fetch | 抓取指定URL的文本内容 |
@@ -212,7 +264,11 @@ export async function runAgentLoop(
 
 ## 长期记忆
 
-每本书有七个长期记忆文件,是 Agent 写作和审计的事实依据:
+每本书有两层控制面:
+- **author_intent.md** — 这本书长期想成为什么
+- **current_focus.md** — 最近 1-3 章要把注意力拉回哪里
+
+以及七个长期记忆文件,是 Agent 写作和审计的事实依据:
 - **current_state.md** — 角色位置、关系、已知信息、当前冲突
 - **particle_ledger.md** — 物品/资源账本,每笔增减有据可查
 - **pending_hooks.md** — 已埋伏笔、推进状态、预期回收时机
@@ -232,6 +288,7 @@ export async function runAgentLoop(
 - 用户提供了题材/创意但没说要扫描市场 → 跳过 scan_market,直接 create_book
 - 用户说了书名/bookId → 直接操作,不需要先 list_books
 - 每完成一步,简要汇报进展
+- 当用户要求“先把注意力拉回某条线”时,优先 update_current_focus,然后 plan_chapter / compose_chapter,再决定是否 write_draft 或 write_full_pipeline
 - 仿写流程:用户提供参考文本 → import_style → 生成 style_guide.md,后续写作自动参照
 - 番外流程:先 create_book 建番外书 → import_canon 导入正传正典 → 然后正常 write_draft
 - 续写流程:用户提供已有章节 → import_chapters → 然后 write_draft 续写`,
@@ -279,7 +336,7 @@ export async function runAgentLoop(
   return lastAssistantMessage;
 }
 
-async function executeTool(
+export async function executeAgentTool(
   pipeline: PipelineRunner,
   state: import("../state/manager.js").StateManager,
   config: PipelineConfig,
@@ -287,6 +344,22 @@ async function executeTool(
   args: Record<string, unknown>,
 ): Promise<string> {
   switch (name) {
+    case "plan_chapter": {
+      const result = await pipeline.planChapter(
+        args.bookId as string,
+        args.guidance as string | undefined,
+      );
+      return JSON.stringify(result);
+    }
+
+    case "compose_chapter": {
+      const result = await pipeline.composeChapter(
+        args.bookId as string,
+        args.guidance as string | undefined,
+      );
+      return JSON.stringify(result);
+    }
+
     case "write_draft": {
       const result = await pipeline.writeDraft(
         args.bookId as string,
@@ -354,6 +427,24 @@ async function executeTool(
       return JSON.stringify(result);
     }
 
+    case "update_author_intent": {
+      await state.ensureControlDocuments(args.bookId as string);
+      const { writeFile } = await import("node:fs/promises");
+      const { join } = await import("node:path");
+      const storyDir = join(state.bookDir(args.bookId as string), "story");
+      await writeFile(join(storyDir, "author_intent.md"), args.content as string, "utf-8");
+      return JSON.stringify({ bookId: args.bookId, file: "story/author_intent.md", written: true });
+    }
+
+    case "update_current_focus": {
+      await state.ensureControlDocuments(args.bookId as string);
+      const { writeFile } = await import("node:fs/promises");
+      const { join } = await import("node:path");
+      const storyDir = join(state.bookDir(args.bookId as string), "story");
+      await writeFile(join(storyDir, "current_focus.md"), args.content as string, "utf-8");
+      return JSON.stringify({ bookId: args.bookId, file: "story/current_focus.md", written: true });
+    }
+
     case "read_truth_files": {
       const result = await pipeline.readTruthFiles(args.bookId as string);
       return JSON.stringify(result);
@@ -468,5 +559,15 @@ async function executeTool(
   }
 }
 
+async function executeTool(
+  pipeline: PipelineRunner,
+  state: import("../state/manager.js").StateManager,
+  config: PipelineConfig,
+  name: string,
+  args: Record<string, unknown>,
+): Promise<string> {
+  return executeAgentTool(pipeline, state, config, name, args);
+}
+
 /** Export tool definitions so external systems can reference them. */
 export { TOOLS as AGENT_TOOLS };

+ 57 - 0
skills/SKILL.md

@@ -107,6 +107,41 @@ inkos status
    inkos review approve-all
    ```
 
+### Workflow 2.5: Steering Chapter Focus Before Writing
+
+Use this when the user says things like "pull focus back to the mentor conflict", "pause the merchant guild subplot", or "change what the next chapter should prioritize".
+
+1. **Update the book-level control docs when needed**:
+   - Use `update_author_intent` to change the long-horizon identity of the book
+   - Use `update_current_focus` to change the next 1-3 chapters' focus
+
+2. **Compile the next chapter intent**:
+   ```text
+   plan_chapter(bookId, guidance?)
+   ```
+   - Generates `story/runtime/chapter-XXXX.intent.md`
+   - Use this to verify what the system thinks the next chapter should do
+
+3. **Compose the actual runtime input package**:
+   ```text
+   compose_chapter(bookId, guidance?)
+   ```
+   - Generates `story/runtime/chapter-XXXX.context.json`
+   - Generates `story/runtime/chapter-XXXX.rule-stack.yaml`
+   - Generates `story/runtime/chapter-XXXX.trace.json`
+
+4. **Only then write**:
+   - `write_draft` if the user wants intermediate review
+   - `write_full_pipeline` if they want the usual write → audit → revise flow
+
+Recommended orchestration:
+- user asks to redirect focus
+- `update_current_focus`
+- `plan_chapter`
+- `compose_chapter`
+- inspect the resulting intent/paths
+- `write_draft` or `write_full_pipeline`
+
 ### Workflow 3: Import Existing Chapters & Continue
 
 Use this when you have an existing novel (or partial novel) and want InkOS to pick up where it left off.
@@ -258,6 +293,28 @@ inkos agent "写一部都市题材的小说,主角是一个年轻律师,第
 - Agent interprets natural language and invokes appropriate commands
 - Useful for complex multi-step requests
 
+## Input Governance Tools
+
+These tools are the preferred control surface for chapter steering:
+
+- `plan_chapter(bookId, guidance?)`
+  - Generates chapter intent for the next chapter
+  - Use before writing when the user wants to change focus
+
+- `compose_chapter(bookId, guidance?)`
+  - Generates runtime context/rule-stack/trace artifacts
+  - Use after planning and before writing
+
+- `update_author_intent(bookId, content)`
+  - Rewrites `story/author_intent.md`
+  - Use for long-horizon changes to the book's identity
+
+- `update_current_focus(bookId, content)`
+  - Rewrites `story/current_focus.md`
+  - Use for local steering over the next 1-3 chapters
+
+`write_truth_file` remains available for broad file edits, but prefer the dedicated control tools above for input-governance changes.
+
 ## Key Concepts
 
 ### Book ID Auto-Detection