refactor(agent): remove legacy nl execution paths

This commit is contained in:
Ma
2026-06-03 15:22:52 +08:00
parent 6c3e5ca4a7
commit 9a7249ca2e
20 changed files with 236 additions and 2680 deletions
@@ -1,142 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createProgram } from "../program.js";
import { createInteractCommand } from "../commands/interact.js";
const {
buildPipelineConfigMock,
createClientMock,
findProjectRootMock,
loadConfigMock,
runAgentSessionMock,
} = vi.hoisted(() => ({
buildPipelineConfigMock: vi.fn(() => ({})),
createClientMock: vi.fn(() => ({
_piModel: {
id: "gpt-5.4",
name: "gpt-5.4",
api: "openai-completions",
provider: "openai",
baseUrl: "https://example.invalid/v1",
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
},
_apiKey: "secret",
})),
findProjectRootMock: vi.fn(() => "/tmp/inkos-project"),
loadConfigMock: vi.fn(async () => ({
llm: {
provider: "openai",
model: "gpt-5.4",
apiFormat: "chat",
stream: false,
},
language: "zh",
})),
runAgentSessionMock: vi.fn(async () => ({
responseText: "Agent response.",
messages: [{ role: "assistant", content: "Agent response." }],
})),
}));
vi.mock("@actalk/inkos-core", async () => ({
PipelineRunner: class PipelineRunnerMock {
constructor(_config: unknown) {}
},
runAgentSession: runAgentSessionMock,
}));
vi.mock("../utils.js", () => ({
buildPipelineConfig: buildPipelineConfigMock,
createClient: createClientMock,
findProjectRoot: findProjectRootMock,
loadConfig: loadConfigMock,
}));
describe("interact command", () => {
const originalArgv = process.argv;
let projectRoot: string;
let stdoutSpy: ReturnType<typeof vi.spyOn> | {
mockClear: () => void;
mock: { calls: Array<ReadonlyArray<unknown>> };
};
let stdoutOutput: string[];
beforeEach(async () => {
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
projectRoot = await mkdtemp(join(tmpdir(), "inkos-interact-cli-"));
await mkdir(join(projectRoot, "books", "harbor"), { recursive: true });
await writeFile(join(projectRoot, "books", "harbor", "book.json"), "{}", "utf-8");
stdoutSpy.mockClear();
process.argv = originalArgv;
beforeEach(() => {
vi.clearAllMocks();
stdoutOutput = [];
vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => {
stdoutOutput.push(String(chunk));
return true;
});
});
afterEach(() => {
process.argv = originalArgv;
vi.restoreAllMocks();
});
it("routes natural language through the shared executor and prints plain text by default", async () => {
const runInteraction = vi.fn(async () => ({
request: { intent: "write_next" },
responseText: "Continuing harbor.",
session: {
activeBookId: "harbor",
automationMode: "semi",
messages: [{ role: "assistant", content: "Continuing harbor.", timestamp: 1 }],
events: [{ kind: "task.completed", status: "completed", timestamp: 1 }],
},
}));
it("routes natural language through runAgentSession", async () => {
const command = createInteractCommand({ readInput: async () => "" });
const program = createProgram({
runInteraction,
readInteractionInput: async () => "",
});
await command.parseAsync(["continue", "--book", "harbor"], { from: "user" });
await program.parseAsync(["interact", "continue", "--book", "harbor"], {
from: "user",
});
expect(runInteraction).toHaveBeenCalledWith(
expect(runAgentSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
projectRoot: process.cwd(),
input: "continue",
activeBookId: "harbor",
projectRoot: "/tmp/inkos-project",
bookId: "harbor",
sessionKind: "book",
actionSource: "free-text",
requestedIntent: undefined,
}),
"continue",
);
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining("Continuing harbor."));
expect(stdoutOutput.join("")).toContain("Agent response.");
});
it("emits structured JSON when --json is used", async () => {
const runInteraction = vi.fn(async () => ({
request: { intent: "switch_mode", mode: "auto" },
responseText: "Switched to auto.",
session: {
activeBookId: "harbor",
automationMode: "auto",
messages: [],
events: [{ kind: "task.completed", status: "completed", timestamp: 1 }],
},
}));
it("passes slash write as a requested intent", async () => {
const command = createInteractCommand({ readInput: async () => "" });
const program = createProgram({
runInteraction,
readInteractionInput: async () => "",
});
await command.parseAsync(["/write", "--book", "harbor", "--json"], { from: "user" });
await program.parseAsync(["interact", "切换到全自动", "--book", "harbor", "--json"], {
from: "user",
});
const output = stdoutSpy.mock.calls.map((call) => String(call[0])).join("");
const parsed = JSON.parse(output);
expect(parsed.request.intent).toBe("switch_mode");
expect(parsed.responseText).toBe("Switched to auto.");
expect(parsed.session.automationMode).toBe("auto");
});
it("accepts --message as an explicit OpenClaw-friendly input channel", async () => {
const runInteraction = vi.fn(async () => ({
request: { intent: "continue_book" },
responseText: "Continuing via --message.",
session: {
activeBookId: "harbor",
automationMode: "semi",
messages: [],
events: [],
},
}));
const program = createProgram({
runInteraction,
readInteractionInput: async () => "",
});
await program.parseAsync(["interact", "--book", "harbor", "--message", "continue current book"], {
from: "user",
});
expect(runInteraction).toHaveBeenCalledWith(
expect(runAgentSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
input: "continue current book",
activeBookId: "harbor",
bookId: "harbor",
sessionKind: "book",
actionSource: "slash",
requestedIntent: "write_next",
}),
"/write",
);
const output = stdoutOutput.join("");
expect(JSON.parse(output)).toEqual(expect.objectContaining({
responseText: "Agent response.",
session: expect.objectContaining({
sessionKind: "book",
activeBookId: "harbor",
}),
}));
});
it("reads the message from stdin when no args are provided", async () => {
const runInteraction = vi.fn(async () => ({
request: { intent: "explain_status" },
responseText: "Explaining status.",
session: {
activeBookId: "harbor",
automationMode: "semi",
messages: [],
events: [],
},
}));
it("reads input from the injected stdin helper", async () => {
const command = createInteractCommand({ readInput: async () => "why did it stop?" });
const program = createProgram({
runInteraction,
readInteractionInput: async () => "why did it stop?",
});
await command.parseAsync([], { from: "user" });
await program.parseAsync(["interact"], { from: "user" });
expect(runInteraction).toHaveBeenCalledWith(
expect(runAgentSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
input: "why did it stop?",
sessionKind: "chat",
actionSource: "free-text",
}),
"why did it stop?",
);
});
});
@@ -1,107 +0,0 @@
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { createInteractionToolsFromDeps } from "../interaction/tools.js";
const chapterResult = {
chapterNumber: 1,
title: "Draft",
wordCount: 1200,
revised: false,
status: "ready-for-review" as const,
auditResult: {
passed: true,
issues: [],
summary: "ok",
},
};
const reviseResult = {
chapterNumber: 3,
wordCount: 1200,
fixedIssues: [],
applied: true,
status: "ready-for-review" as const,
};
let projectRoot: string;
describe("interaction tools adapter", () => {
beforeAll(async () => {
projectRoot = await mkdtemp(join(tmpdir(), "inkos-interaction-tools-"));
await mkdir(join(projectRoot, "books", "harbor", "story"), { recursive: true });
});
afterAll(async () => {
// tmpdir cleanup omitted
});
it("delegates writeNextChapter and reviseDraft to the pipeline", async () => {
const pipeline = {
writeNextChapter: vi.fn(async () => chapterResult),
reviseDraft: vi.fn(async () => reviseResult),
};
const state = {
ensureControlDocuments: vi.fn(async () => {}),
bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
loadBookConfig: vi.fn(async () => ({
id: "harbor",
title: "Harbor",
platform: "other" as const,
genre: "other",
status: "outlining" as const,
targetChapters: 200,
chapterWordCount: 3000,
createdAt: "2026-04-10T00:00:00.000Z",
updatedAt: "2026-04-10T00:00:00.000Z",
})),
loadChapterIndex: vi.fn(async () => []),
saveChapterIndex: vi.fn(async () => undefined),
listBooks: vi.fn(async () => ["harbor"]),
};
const tools = createInteractionToolsFromDeps(projectRoot, pipeline, state);
await tools.writeNextChapter("harbor");
await tools.reviseDraft("harbor", 3, "rewrite");
expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor");
expect(pipeline.reviseDraft).toHaveBeenCalledWith("harbor", 3, "rewrite");
});
it("writes current_focus and author_intent through the canonical story paths", async () => {
const pipeline = {
writeNextChapter: vi.fn(async () => chapterResult),
reviseDraft: vi.fn(async () => reviseResult),
};
const state = {
ensureControlDocuments: vi.fn(async () => {}),
bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
loadBookConfig: vi.fn(async () => ({
id: "harbor",
title: "Harbor",
platform: "other" as const,
genre: "other",
status: "outlining" as const,
targetChapters: 200,
chapterWordCount: 3000,
createdAt: "2026-04-10T00:00:00.000Z",
updatedAt: "2026-04-10T00:00:00.000Z",
})),
loadChapterIndex: vi.fn(async () => []),
saveChapterIndex: vi.fn(async () => undefined),
listBooks: vi.fn(async () => ["harbor"]),
};
const tools = createInteractionToolsFromDeps(projectRoot, pipeline, state);
await tools.updateCurrentFocus("harbor", "# Current Focus\n\nBring focus back to the old case.\n");
await tools.updateAuthorIntent("harbor", "# Author Intent\n\nWrite a cold harbor mystery.\n");
await expect(readFile(join(projectRoot, "books", "harbor", "story", "current_focus.md"), "utf-8"))
.resolves.toContain("Bring focus back to the old case");
await expect(readFile(join(projectRoot, "books", "harbor", "story", "author_intent.md"), "utf-8"))
.resolves.toContain("Write a cold harbor mystery");
});
});
@@ -156,11 +156,13 @@ describe("tui agent session bridge", () => {
expect(persisted.activeBookId).toBe("night-harbor");
});
it("routes explicit create-book instructions directly to shared book creation", async () => {
const initBookSpy = vi.spyOn(
(await import("@actalk/inkos-core")).PipelineRunner.prototype as any,
"initBook",
);
it("routes create-book text through the unified agent session instead of parsing it locally", async () => {
runAgentSessionMock.mockResolvedValue({
responseText: "我理解你想创建《雾灯小巷》,请确认后我再建书。",
messages: [
{ role: "assistant", content: "我理解你想创建《雾灯小巷》,请确认后我再建书。" },
],
});
const { processTuiAgentInput } = await import("../tui/agent-input.js");
const session = createProjectSession(projectRoot);
@@ -170,27 +172,28 @@ describe("tui agent session bridge", () => {
session,
});
expect(runAgentSessionMock).not.toHaveBeenCalled();
expect(initBookSpy).toHaveBeenCalledWith(
expect(runAgentSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
id: "雾灯小巷",
title: "雾灯小巷",
genre: "urban",
platform: "tomato",
targetChapters: 10,
chapterWordCount: 1200,
}),
expect.objectContaining({
externalContext: expect.stringContaining("雾灯小巷"),
sessionKind: "book-create",
actionSource: "free-text",
requestedIntent: undefined,
}),
expect.stringContaining("雾灯小巷"),
[],
);
expect(result.session.activeBookId).toBe("雾灯小巷");
expect(result.responseText).toContain("已创建");
expect(result.session.activeBookId).toBeUndefined();
expect(result.responseText).toContain("请确认");
const persisted = await loadProjectSession(projectRoot);
expect(persisted.activeBookId).toBe("雾灯小巷");
expect(persisted.activeBookId).toBeUndefined();
});
it("routes write-next instructions directly to the writer when a book is active", async () => {
it("passes explicit slash write-next as a requested intent to the unified agent session", async () => {
runAgentSessionMock.mockResolvedValue({
responseText: "已为 night-harbor 完成下一章。",
messages: [
{ role: "assistant", content: "已为 night-harbor 完成下一章。" },
],
});
const { processTuiAgentInput } = await import("../tui/agent-input.js");
const session = {
...createProjectSession(projectRoot),
@@ -199,15 +202,22 @@ describe("tui agent session bridge", () => {
const result = await processTuiAgentInput({
projectRoot,
input: "写第1章",
input: "/write",
session,
});
expect(runAgentSessionMock).not.toHaveBeenCalled();
expect(result.responseText).toContain("第 1 章");
expect(result.responseText).toContain("雨夜");
expect(runAgentSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
bookId: "night-harbor",
sessionKind: "book",
actionSource: "slash",
requestedIntent: "write_next",
}),
"/write",
[],
);
expect(result.responseText).toContain("完成下一章");
const persisted = await loadProjectSession(projectRoot);
expect(persisted.activeBookId).toBe("night-harbor");
expect(persisted.activeChapterNumber).toBe(1);
});
});
+37 -27
View File
@@ -1,15 +1,16 @@
import { Command } from "commander";
import { runAgentLoop } from "@actalk/inkos-core";
import { loadConfig, createClient, findProjectRoot, resolveContext, log, logError } from "../utils.js";
import { PipelineRunner, runAgentSession } from "@actalk/inkos-core";
import { buildPipelineConfig, loadConfig, createClient, findProjectRoot, resolveBookId, resolveContext, log, logError } from "../utils.js";
export const agentCommand = new Command("agent")
.description("Natural language agent mode (LLM orchestrates via tool-use)")
.argument("<instruction>", "Natural language instruction")
.option("--book <bookId>", "Bind this request to an existing book")
.option("--session <sessionId>", "Reuse an agent session id")
.option("--context <text>", "Additional context (natural language)")
.option("--context-file <path>", "Read additional context from file")
.option("--max-turns <n>", "Maximum agent turns", "20")
.option("--json", "Output JSON (suppress progress messages)")
.option("--quiet", "Suppress tool call logs")
.option("--quiet", "Suppress non-JSON console output")
.action(async (instruction: string, opts) => {
try {
const config = await loadConfig();
@@ -21,38 +22,47 @@ export const agentCommand = new Command("agent")
? `${instruction}\n\n补充信息:${context}`
: instruction;
const maxTurns = parseInt(opts.maxTurns, 10);
const bookId = opts.book ? await resolveBookId(opts.book, root) : null;
const trimmed = fullInstruction.trim();
const actionSource = trimmed.startsWith("/") ? "slash" : "free-text";
const requestedIntent = bookId && trimmed === "/write"
? "write_next"
: !bookId && trimmed === "/create"
? "create_book"
: undefined;
const sessionKind = bookId
? "book"
: requestedIntent === "create_book"
? "book-create"
: "chat";
const sessionId = opts.session?.trim() || `cli-agent-${Date.now().toString(36)}`;
const pipeline = new PipelineRunner(buildPipelineConfig(config, root, {
externalContext: context,
quiet: opts.quiet || opts.json,
}));
const result = await runAgentLoop(
const result = await runAgentSession(
{
client,
model: config.llm.model,
sessionId,
bookId,
sessionKind,
actionSource,
requestedIntent,
language: config.language ?? "zh",
pipeline,
projectRoot: root,
model: client._piModel
? client._piModel
: { provider: config.llm.provider ?? "openai", modelId: config.llm.model },
apiKey: client._apiKey,
},
fullInstruction,
{
maxTurns,
onToolCall: opts.quiet || opts.json
? undefined
: (name, args) => {
log(` [tool] ${name}(${JSON.stringify(args)})`);
},
onToolResult: opts.quiet || opts.json
? undefined
: (name, result) => {
const preview = result.length > 200 ? `${result.slice(0, 200)}...` : result;
log(` [result] ${name}${preview}`);
},
onMessage: opts.json
? undefined
: (content) => {
log(`\n${content}`);
},
},
);
if (opts.json) {
log(JSON.stringify({ result }));
} else if (!opts.quiet && result.responseText.trim()) {
log(result.responseText);
}
} catch (e) {
if (opts.json) {
+52 -46
View File
@@ -1,29 +1,11 @@
import { Command } from "commander";
import {
processProjectInteractionInput,
type InteractionRuntimeTools,
PipelineRunner,
runAgentSession,
} from "@actalk/inkos-core";
import { createInteractionTools } from "../interaction/tools.js";
import { buildPipelineConfig, createClient, findProjectRoot, loadConfig } from "../utils.js";
export interface InteractCommandHooks {
readonly runInteraction?: (params: {
readonly projectRoot: string;
readonly input: string;
readonly activeBookId?: string;
readonly tools: InteractionRuntimeTools;
}) => Promise<{
readonly request: unknown;
readonly responseText?: string;
readonly session: {
readonly automationMode: string;
readonly activeBookId?: string;
readonly currentExecution?: unknown;
readonly pendingDecision?: unknown;
readonly messages: ReadonlyArray<unknown>;
readonly events: ReadonlyArray<unknown>;
};
}>;
readonly createTools?: (projectRoot: string) => Promise<InteractionRuntimeTools>;
readonly readInput?: () => Promise<string>;
}
@@ -65,45 +47,69 @@ async function readInteractionInput(
export function createInteractCommand(hooks: InteractCommandHooks = {}): Command {
return new Command("interact")
.description("Run a shared natural-language interaction against the current project")
.description("Run a natural-language agent interaction against the current project")
.argument("[message...]", "Natural-language message")
.option("--message <text>", "Explicit natural-language message")
.option("--book <bookId>", "Bind a specific active book for this interaction")
.option("--session <sessionId>", "Reuse an agent session id")
.option("--json", "Emit structured JSON for external agents")
.action(async (messageArgs: ReadonlyArray<string>, opts) => {
const input = await readInteractionInput(messageArgs, opts.message, hooks.readInput);
const projectRoot = process.cwd();
const tools = hooks.createTools
? await hooks.createTools(projectRoot)
: hooks.runInteraction
? ({} as InteractionRuntimeTools)
: await createInteractionTools(projectRoot, undefined, { requireApiKey: false });
const runInteraction = hooks.runInteraction ?? processProjectInteractionInput;
const result = await runInteraction({
const projectRoot = findProjectRoot();
const config = await loadConfig({ requireApiKey: false, projectRoot });
const client = createClient(config);
const bookId = typeof opts.book === "string" && opts.book.trim() ? opts.book.trim() : null;
const trimmed = input.trim();
const actionSource = trimmed.startsWith("/") ? "slash" : "free-text";
const requestedIntent = bookId && trimmed === "/write"
? "write_next"
: !bookId && trimmed === "/create"
? "create_book"
: undefined;
const sessionKind = bookId
? "book"
: requestedIntent === "create_book"
? "book-create"
: "chat";
const sessionId = typeof opts.session === "string" && opts.session.trim()
? opts.session.trim()
: `cli-interact-${Date.now().toString(36)}`;
const pipeline = new PipelineRunner(buildPipelineConfig(config, projectRoot, {
quiet: opts.json,
}));
const result = await runAgentSession({
sessionId,
bookId,
sessionKind,
actionSource,
requestedIntent,
language: config.language ?? "zh",
pipeline,
projectRoot,
input,
activeBookId: opts.book,
tools,
});
model: client._piModel
? client._piModel
: { provider: config.llm.provider ?? "openai", modelId: config.llm.model },
apiKey: client._apiKey,
}, input);
const responseText = result.responseText;
const session = {
sessionId,
sessionKind,
activeBookId: bookId ?? undefined,
};
if (opts.json) {
process.stdout.write(`${JSON.stringify({
request: result.request,
responseText: result.responseText,
session: result.session,
currentExecution: result.session.currentExecution ?? null,
pendingDecision: result.session.pendingDecision ?? null,
events: result.session.events,
responseText,
session,
}, null, 2)}\n`);
return;
}
const text = result.responseText
?? (result.session.messages.at(-1) && "content" in (result.session.messages.at(-1) as Record<string, unknown>)
? String((result.session.messages.at(-1) as Record<string, unknown>).content)
: "");
if (text) {
process.stdout.write(`${text}\n`);
if (responseText) {
process.stdout.write(`${responseText}\n`);
}
});
}
-49
View File
@@ -1,49 +0,0 @@
import {
PipelineRunner,
StateManager,
createInteractionToolsFromDeps,
type InteractionRuntimeTools,
} from "@actalk/inkos-core";
import { buildPipelineConfig, loadConfig } from "../utils.js";
type CliPipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
type CliStateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadBookConfig" | "loadChapterIndex" | "saveChapterIndex" | "listBooks">;
type CliInteractionToolHooks = {
readonly onChatTextDelta?: (text: string) => void;
readonly onDraftTextDelta?: (text: string) => void;
readonly getChatRequestOptions?: () => {
readonly temperature?: number;
readonly maxTokens?: number;
};
};
export function createCliInteractionToolsFromDeps(
pipeline: CliPipelineLike,
state: CliStateLike,
hooks?: CliInteractionToolHooks,
): InteractionRuntimeTools {
return createInteractionToolsFromDeps(pipeline, state, hooks);
}
// Backward-compatible export for existing CLI interaction tests.
export function createInteractionToolsFromDepsCompat(
_projectRoot: string,
pipeline: CliPipelineLike,
state: CliStateLike,
hooks?: CliInteractionToolHooks,
): InteractionRuntimeTools {
return createInteractionToolsFromDeps(pipeline, state, hooks);
}
export { createInteractionToolsFromDepsCompat as createInteractionToolsFromDeps };
export async function createInteractionTools(
projectRoot: string,
hooks?: CliInteractionToolHooks,
options?: { readonly requireApiKey?: boolean },
): Promise<InteractionRuntimeTools> {
const config = await loadConfig({ projectRoot, requireApiKey: options?.requireApiKey });
const pipeline = new PipelineRunner(buildPipelineConfig(config, projectRoot));
const state = new StateManager(projectRoot);
return createInteractionToolsFromDeps(pipeline, state, hooks);
}
-2
View File
@@ -37,7 +37,6 @@ const { version } = require("../package.json") as { version: string };
export interface ProgramHooks {
readonly launchTui?: (projectRoot: string) => Promise<void> | void;
readonly launchStudio?: (projectRoot: string, port: string) => Promise<void> | void;
readonly runInteraction?: InteractCommandHooks["runInteraction"];
readonly readInteractionInput?: InteractCommandHooks["readInput"];
}
@@ -89,7 +88,6 @@ export function createProgram(hooks: ProgramHooks = {}): Command {
program.addCommand(createStudioCommand({ launchStudio: hooks.launchStudio }));
program.addCommand(consolidateCommand);
program.addCommand(createInteractCommand({
runInteraction: hooks.runInteraction,
readInput: hooks.readInteractionInput,
}));
program.addCommand(createTuiCommand({ launchTui: hooks.launchTui }));
+10 -145
View File
@@ -2,7 +2,6 @@ import {
appendInteractionMessage,
clearPendingDecision,
createLLMClient,
isWriteNextInstruction,
runAgentSession,
type InteractionSession,
} from "@actalk/inkos-core";
@@ -42,75 +41,21 @@ export async function processTuiAgentInput(params: {
timestamp: userTimestamp,
});
if (!resolvedBookId && isCreateBookInstruction(params.input)) {
const book = parseBookCreationRequest(params.input);
if (book) {
await pipeline.initBook(book, {
externalContext: params.input,
authorIntent: params.input,
});
const responseText = `已创建《${book.title}》,接下来可以直接输入“写第1章”。`;
nextSession = appendInteractionMessage({
...nextSession,
activeBookId: book.id,
currentExecution: {
status: "completed",
bookId: book.id,
stageLabel: "architect",
},
}, {
role: "assistant",
content: responseText,
timestamp: userTimestamp + 1,
});
await persistProjectSession(params.projectRoot, nextSession);
return {
responseText,
session: nextSession,
};
}
}
if (resolvedBookId && isWriteNextInstruction(params.input, { allowSlashWrite: true })) {
const writeResult = await pipeline.writeNextChapter(resolvedBookId);
const chapterNumber = getResultNumber(writeResult, "chapterNumber");
const title = getResultString(writeResult, "title");
const wordCount = getResultNumber(writeResult, "wordCount");
const status = getResultString(writeResult, "status");
const responseText = [
`已为 ${resolvedBookId} 完成`,
chapterNumber ? `${chapterNumber}` : "下一章",
title ? `${title}` : "",
wordCount ? `,字数 ${wordCount}` : "",
status ? `,状态 ${status}` : "",
"。",
].join("");
nextSession = appendInteractionMessage({
...nextSession,
activeBookId: resolvedBookId,
currentExecution: {
status: "completed",
bookId: resolvedBookId,
...(chapterNumber ? { chapterNumber } : {}),
stageLabel: "writer",
},
...(chapterNumber ? { activeChapterNumber: chapterNumber } : {}),
}, {
role: "assistant",
content: responseText,
timestamp: userTimestamp + 1,
});
await persistProjectSession(params.projectRoot, nextSession);
return {
responseText,
session: nextSession,
};
}
const trimmedInput = params.input.trim();
const actionSource = trimmedInput.startsWith("/") ? "slash" : "free-text";
const requestedIntent = resolvedBookId && trimmedInput === "/write"
? "write_next"
: !resolvedBookId && trimmedInput === "/create"
? "create_book"
: undefined;
const result = await runAgentSession(
{
sessionId: params.session.sessionId,
bookId: resolvedBookId,
sessionKind: resolvedBookId ? "book" : "book-create",
actionSource,
requestedIntent,
language: config.language ?? "zh",
pipeline,
projectRoot: params.projectRoot,
@@ -167,76 +112,6 @@ export async function processTuiAgentInput(params: {
};
}
function isCreateBookInstruction(instruction: string): boolean {
return /^\/new\s+/i.test(instruction)
&& /(?:标题|书名|title)\s*[《"“]/i.test(instruction);
}
function parseBookCreationRequest(instruction: string): {
id: string;
title: string;
genre: string;
platform: "tomato" | "feilu" | "qidian" | "other";
language: "zh" | "en";
status: "outlining";
targetChapters: number;
chapterWordCount: number;
createdAt: string;
updatedAt: string;
} | undefined {
const title = extractTitle(instruction);
if (!title) return undefined;
const now = new Date().toISOString();
return {
id: deriveBookId(title),
title,
genre: inferGenre(instruction),
platform: inferPlatform(instruction),
language: /[\u4e00-\u9fff]/.test(instruction) ? "zh" : "en",
status: "outlining",
targetChapters: extractNumber(instruction, /(\d+)\s*章/) ?? 200,
chapterWordCount: extractNumber(instruction, /(?:每章|章节|章)\D{0,8}(\d{3,5})\s*字/) ?? 3000,
createdAt: now,
updatedAt: now,
};
}
function extractTitle(instruction: string): string | undefined {
const match = instruction.match(/(?:标题|书名|title)\s*[《"“]([^》"”]+)[》"”]/i);
const title = match?.[1]?.trim();
return title && title.length > 0 ? title : undefined;
}
function extractNumber(instruction: string, pattern: RegExp): number | undefined {
const value = Number.parseInt(instruction.match(pattern)?.[1] ?? "", 10);
return Number.isFinite(value) && value > 0 ? value : undefined;
}
function inferPlatform(instruction: string): "tomato" | "feilu" | "qidian" | "other" {
if (/番茄|tomato/i.test(instruction)) return "tomato";
if (/飞卢|feilu/i.test(instruction)) return "feilu";
if (/起点|qidian/i.test(instruction)) return "qidian";
return "other";
}
function inferGenre(instruction: string): string {
if (/都市/.test(instruction)) return "urban";
if (/悬疑|推理|mystery/i.test(instruction)) return "mystery";
if (/玄幻|xuanhuan/i.test(instruction)) return "xuanhuan";
if (/科幻|sci[-\s]?fi/i.test(instruction)) return "sci-fi";
if (/言情|甜宠|romance/i.test(instruction)) return "romance";
return "other";
}
function deriveBookId(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 30) || `book-${Date.now().toString(36)}`;
}
function extractCreatedBookId(messages: ReadonlyArray<unknown>): string | undefined {
for (const message of messages) {
const details = (message as { details?: { kind?: string; bookId?: string } }).details;
@@ -246,13 +121,3 @@ function extractCreatedBookId(messages: ReadonlyArray<unknown>): string | undefi
}
return undefined;
}
function getResultString(value: unknown, key: string): string | undefined {
const raw = (value as Record<string, unknown>)[key];
return typeof raw === "string" && raw.length > 0 ? raw : undefined;
}
function getResultNumber(value: unknown, key: string): number | undefined {
const raw = (value as Record<string, unknown>)[key];
return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
}
@@ -1,269 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createInteractionToolsFromDeps } from "../interaction/project-tools.js";
const mockChatCompletion = vi.hoisted(() => vi.fn());
const mockChatWithTools = vi.hoisted(() => vi.fn());
vi.mock("../index.js", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, chatCompletion: mockChatCompletion, chatWithTools: mockChatWithTools };
});
const fakePipeline = {
config: {
client: {} as object,
model: "gpt-4o",
},
writeNextChapter: vi.fn(),
reviseDraft: vi.fn(),
};
const fakeState = {
ensureControlDocuments: vi.fn(async () => {}),
bookDir: vi.fn(() => "/tmp/books/test"),
loadBookConfig: vi.fn(async () => undefined),
loadChapterIndex: vi.fn(async () => []),
saveChapterIndex: vi.fn(async () => undefined),
listBooks: vi.fn(async () => []),
};
const MOCK_CHAT_RESPONSE = {
content: [
"好的,你想写都市异能,请问主角是什么类型的能力?",
"",
':::field{key="title" label="书名"}',
"都市异能",
":::",
].join("\n"),
tokensUsed: { prompt: 5, completion: 80, total: 85 },
};
const MOCK_TOOL_RESPONSE = {
content: "好的,已根据你的描述生成建书参数。",
toolCalls: [
{
id: "call_1",
name: "create_book",
arguments: JSON.stringify({
title: "都市异能",
genre: "urban",
platform: "tomato",
targetChapters: 160,
chapterWordCount: 2800,
brief: "都市异能题材",
worldPremise: "旧城区被异能公司分区管理,普通人靠通行证活着。",
protagonist: "陈野,外卖员,能看见别人欠下的代价。",
conflictCore: "主角想保住妹妹,却被公司逼成黑市清账人。",
volumeOutline: "卷一先查妹妹病历,再掀出公司清账规则。",
nextQuestion: "主角的异能代价要不要更重?",
missingFields: ["supportingCast"],
readyToCreate: false,
}),
},
],
};
describe("chat tool maxTokens forwarding", () => {
beforeEach(() => {
mockChatCompletion.mockResolvedValue({
content: "Hello",
tokensUsed: { prompt: 5, completion: 10, total: 15 },
});
mockChatCompletion.mockClear();
});
it("does not pass maxTokens to chatCompletion when depth has no maxTokens set", async () => {
const tools = createInteractionToolsFromDeps(
fakePipeline as never,
fakeState as never,
{
getChatRequestOptions: () => ({ temperature: 0.7 }),
},
);
await tools.chat?.("你好", { bookId: "test-book", automationMode: "manual" });
expect(mockChatCompletion).toHaveBeenCalledOnce();
const options = mockChatCompletion.mock.calls[0]?.[3] as Record<string, unknown> | undefined;
expect(options).not.toHaveProperty("maxTokens");
});
it("passes maxTokens to chatCompletion when depth explicitly sets it", async () => {
const tools = createInteractionToolsFromDeps(
fakePipeline as never,
fakeState as never,
{
getChatRequestOptions: () => ({ temperature: 0.7, maxTokens: 512 }),
},
);
await tools.chat?.("你好", { bookId: "test-book", automationMode: "manual" });
expect(mockChatCompletion).toHaveBeenCalledOnce();
const options = mockChatCompletion.mock.calls[0]?.[3] as Record<string, unknown> | undefined;
expect(options).toHaveProperty("maxTokens", 512);
});
it("rethrows real chatCompletion errors instead of silently falling back", async () => {
mockChatCompletion.mockRejectedValueOnce(new Error("provider down"));
const tools = createInteractionToolsFromDeps(
fakePipeline as never,
fakeState as never,
{
getChatRequestOptions: () => ({ temperature: 0.7 }),
},
);
await expect(
tools.chat?.("你好", { bookId: "test-book", automationMode: "manual" }),
).rejects.toThrow("provider down");
});
});
describe("developBookDraft uses chatWithTools", () => {
beforeEach(() => {
mockChatWithTools.mockResolvedValue(MOCK_TOOL_RESPONSE);
mockChatWithTools.mockClear();
});
it("calls chatWithTools with create_book tool and does not pass maxTokens", async () => {
const tools = createInteractionToolsFromDeps(
fakePipeline as never,
fakeState as never,
);
await tools.developBookDraft?.("我想写都市异能", undefined);
expect(mockChatWithTools).toHaveBeenCalledOnce();
const options = mockChatWithTools.mock.calls[0]?.[4] as Record<string, unknown> | undefined;
expect(options).not.toHaveProperty("maxTokens");
});
it("extracts tool call arguments into the creation draft", async () => {
const tools = createInteractionToolsFromDeps(
fakePipeline as never,
fakeState as never,
);
const result = await tools.developBookDraft?.("我想写都市异能", undefined) as Record<string, unknown>;
const interaction = (result as { __interaction: Record<string, unknown> }).__interaction;
const details = interaction.details as Record<string, unknown>;
expect(details.creationDraft).toEqual(expect.objectContaining({
title: "都市异能",
genre: "urban",
platform: "tomato",
targetChapters: 160,
chapterWordCount: 2800,
blurb: "都市异能题材",
worldPremise: "旧城区被异能公司分区管理,普通人靠通行证活着。",
protagonist: "陈野,外卖员,能看见别人欠下的代价。",
conflictCore: "主角想保住妹妹,却被公司逼成黑市清账人。",
volumeOutline: "卷一先查妹妹病历,再掀出公司清账规则。",
nextQuestion: "主角的异能代价要不要更重?",
missingFields: ["supportingCast"],
readyToCreate: false,
}));
expect(details.toolCall).toEqual({
name: "create_book",
arguments: {
title: "都市异能",
genre: "urban",
platform: "tomato",
targetChapters: 160,
chapterWordCount: 2800,
brief: "都市异能题材",
worldPremise: "旧城区被异能公司分区管理,普通人靠通行证活着。",
protagonist: "陈野,外卖员,能看见别人欠下的代价。",
conflictCore: "主角想保住妹妹,却被公司逼成黑市清账人。",
volumeOutline: "卷一先查妹妹病历,再掀出公司清账规则。",
nextQuestion: "主角的异能代价要不要更重?",
missingFields: ["supportingCast"],
readyToCreate: false,
},
});
});
it("fills editable length defaults and never blocks creation on missing length", async () => {
// The LLM returns the six story-core fields but no length — length is a run
// parameter, so the draft should default to 200/3000 and still be ready.
mockChatWithTools.mockResolvedValueOnce({
content: "已生成草案。",
toolCalls: [
{
id: "call_2",
name: "create_book",
arguments: JSON.stringify({
title: "夜港账本",
genre: "urban",
platform: "tomato",
worldPremise: "近未来港口城,账本牵出多方势力。",
protagonist: "林砚,水货账房出身。",
conflictCore: "洗白与旧债回潮的对撞。",
readyToCreate: true,
}),
},
],
});
const tools = createInteractionToolsFromDeps(fakePipeline as never, fakeState as never);
const result = await tools.developBookDraft?.("我想写港风商战", undefined) as Record<string, unknown>;
const details = (result as { __interaction: { details: Record<string, unknown> } }).__interaction.details;
const draft = details.creationDraft as Record<string, unknown>;
expect(draft.targetChapters).toBe(200);
expect(draft.chapterWordCount).toBe(3000);
expect(draft.readyToCreate).toBe(true);
expect(draft.missingFields).not.toContain("targetChapters");
expect(draft.missingFields).not.toContain("chapterWordCount");
});
it("keeps a draft NOT ready while a story-core field (worldPremise) is missing, even with length defaulted", async () => {
mockChatWithTools.mockResolvedValueOnce({
content: "还差世界观。",
toolCalls: [
{
id: "call_3",
name: "create_book",
arguments: JSON.stringify({
title: "夜港账本",
genre: "urban",
platform: "tomato",
protagonist: "林砚。",
conflictCore: "洗白与旧债回潮的对撞。",
readyToCreate: true, // LLM over-claims; deterministic gate must override
}),
},
],
});
const tools = createInteractionToolsFromDeps(fakePipeline as never, fakeState as never);
const result = await tools.developBookDraft?.("我想写港风商战", undefined) as Record<string, unknown>;
const details = (result as { __interaction: { details: Record<string, unknown> } }).__interaction.details;
const draft = details.creationDraft as Record<string, unknown>;
expect(draft.targetChapters).toBe(200);
expect(draft.readyToCreate).toBe(false);
expect(draft.missingFields).toContain("worldPremise");
});
it("returns fallback when no LLM is configured", async () => {
const noLlmPipeline = {
config: {},
writeNextChapter: vi.fn(),
reviseDraft: vi.fn(),
};
const tools = createInteractionToolsFromDeps(
noLlmPipeline as never,
fakeState as never,
);
const result = await tools.developBookDraft?.("我想写都市异能", undefined) as Record<string, unknown>;
const interaction = (result as { __interaction: Record<string, unknown> }).__interaction;
expect(mockChatWithTools).not.toHaveBeenCalled();
expect(interaction.responseText).toContain("请先配置 LLM 模型");
});
});
@@ -5,7 +5,6 @@ import { runInteractionRequest } from "../interaction/runtime.js";
function makeTools(overrides: Partial<Parameters<typeof runInteractionRequest>[0]["tools"]> = {}) {
return {
listBooks: vi.fn(async () => ["harbor"]),
developBookDraft: vi.fn(),
createBook: vi.fn(),
exportBook: vi.fn(),
writeNextChapter: vi.fn(),
@@ -20,48 +19,6 @@ function makeTools(overrides: Partial<Parameters<typeof runInteractionRequest>[0
}
describe("interaction runtime", () => {
it("routes develop_book through the shared draft tool and updates the creation draft", async () => {
const developBookDraft = vi.fn(async () => ({
__interaction: {
responseText: "我先按港风商战悬疑收着。你更想写长篇连载,还是十来章能收住?",
details: {
creationDraft: {
concept: "港风商战悬疑,主角从灰产洗白。",
title: "夜港账本",
genre: "urban",
nextQuestion: "更想写长篇连载,还是十来章能收住?",
missingFields: ["targetChapters"],
readyToCreate: false,
},
},
},
}));
const result = await runInteractionRequest({
session: InteractionSessionSchema.parse({
sessionId: "session-draft",
projectRoot: "/tmp/project",
automationMode: "semi",
messages: [],
events: [],
}),
request: {
intent: "develop_book",
instruction: "我想写个港风商战悬疑,主角从灰产洗白。",
},
tools: makeTools({
developBookDraft,
}),
});
expect(developBookDraft).toHaveBeenCalledWith("我想写个港风商战悬疑,主角从灰产洗白。", undefined);
expect(result.session.creationDraft).toEqual(expect.objectContaining({
title: "夜港账本",
genre: "urban",
}));
expect(result.responseText).toContain("港风商战悬疑");
});
it("routes create_book through the shared create tool and binds the created book", async () => {
const createBook = vi.fn(async () => ({
bookId: "night-harbor",
@@ -1,354 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } 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";
import { PlannerAgent } from "../agents/planner.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");
vi.spyOn(PlannerAgent.prototype, "planChapter").mockImplementation(async (input) => {
const chapterNumber = input.chapterNumber;
// Try to read the local override from current_focus.md, mirroring the real planner logic
let goal = input.externalContext ?? "test goal";
try {
const { readFile: readFs } = await import("node:fs/promises");
const focusContent = await readFs(join(input.bookDir, "story", "current_focus.md"), "utf-8");
const overrideMatch = focusContent.match(/## Local Override\s*\n+([^\n#]+)/);
if (overrideMatch?.[1]?.trim()) {
goal = overrideMatch[1].trim();
}
} catch { /* ignore missing file */ }
const memo = {
chapter: chapterNumber,
goal,
isGoldenOpening: false,
body: "",
threadRefs: [] as string[],
};
const intentMarkdown = [
"# Chapter Intent",
"",
"## Goal",
goal,
].join("\n");
const { mkdir: mkdirFs, writeFile: writeFileFs } = await import("node:fs/promises");
const runtimeDir = join(input.bookDir, "story", "runtime");
await mkdirFs(runtimeDir, { recursive: true });
const runtimePath = join(runtimeDir, `chapter-${String(chapterNumber).padStart(4, "0")}.intent.md`);
await writeFileFs(runtimePath, intentMarkdown, "utf-8");
return {
intent: {
chapter: chapterNumber,
goal,
mustKeep: [],
mustAvoid: [],
styleEmphasis: [],
},
memo,
intentMarkdown,
plannerInputs: [runtimePath],
runtimePath,
};
});
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 () => {
vi.restoreAllMocks();
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");
});
it("normalizes human-facing platform aliases before create_book persists config", async () => {
const initBook = vi.spyOn(PipelineRunner.prototype, "initBook").mockResolvedValue(undefined);
const result = JSON.parse(await executeAgentTool(
pipeline,
state,
config,
"create_book",
{
title: "测试书",
genre: "urban",
platform: "番茄小说",
brief: "一本文娱爽文。",
},
));
expect(result).toMatchObject({ bookId: "测试书", title: "测试书", status: "created" });
expect(initBook).toHaveBeenCalledWith(expect.objectContaining({
id: "测试书",
platform: "tomato",
}));
});
it("keeps update_current_focus usable for explicit local overrides through the tool surface", async () => {
await executeAgentTool(pipeline, state, config, "update_current_focus", {
bookId,
content: [
"# Current Focus",
"",
"## Active Focus",
"",
"Keep the merchant guild trail visible in the background.",
"",
"## Local Override",
"",
"Stay inside the mentor debt confrontation first and delay the guild chase by one chapter.",
"",
].join("\n"),
});
const planResult = JSON.parse(await executeAgentTool(
pipeline,
state,
config,
"plan_chapter",
{ bookId },
));
const runtimePath = join(state.bookDir(bookId), planResult.intentPath);
const intentMarkdown = await readFile(runtimePath, "utf-8");
expect(intentMarkdown).toContain([
"## Goal",
"Stay inside the mentor debt confrontation first and delay the guild chase by one chapter.",
].join("\n"));
});
it("blocks write_full_pipeline when runtime progress is ahead of the chapter index", async () => {
const chaptersDir = join(state.bookDir(bookId), "chapters");
// Create durable chapter files for 1-3 but only index chapter 1.
// This produces durableChapter=3, nextNum=4 while lastIndexedChapter=1,
// triggering the sequential write guard.
await state.saveChapterIndex(bookId, [{
number: 1,
title: "Existing Chapter",
status: "approved",
wordCount: 120,
createdAt: "2026-03-22T00:00:00.000Z",
updatedAt: "2026-03-22T00:00:00.000Z",
auditIssues: [],
lengthWarnings: [],
}]);
await Promise.all([
writeFile(join(chaptersDir, "0001_Existing.md"), "# Chapter 1\n", "utf-8"),
writeFile(join(chaptersDir, "0002_Second.md"), "# Chapter 2\n", "utf-8"),
writeFile(join(chaptersDir, "0003_Third.md"), "# Chapter 3\n", "utf-8"),
]);
const writeNextChapter = vi.spyOn(pipeline, "writeNextChapter").mockResolvedValue({
bookId,
chapterNumber: 4,
title: "Should Not Run",
wordCount: 100,
filePath: "books/agent-book/chapters/0004_Should_Not_Run.md",
auditResult: { passed: true, issues: [], summary: "ok" },
revised: false,
status: "ready-for-review",
} as Awaited<ReturnType<typeof pipeline.writeNextChapter>>);
const result = JSON.parse(await executeAgentTool(
pipeline,
state,
config,
"write_full_pipeline",
{ bookId, count: 1 },
));
expect(result.error).toContain("write_full_pipeline");
expect(writeNextChapter).not.toHaveBeenCalled();
});
it("blocks write_truth_file from hacking chapter progress inside current_state.md", async () => {
const result = JSON.parse(await executeAgentTool(
pipeline,
state,
config,
"write_truth_file",
{
bookId,
fileName: "current_state.md",
content: "# Current State\n\n| Current Chapter | 999 |\n",
},
));
expect(result.error).toContain("章节进度");
});
// Phase hotfix 3: write_truth_file must accept both Chinese and English
// role-dir paths so English-layout books are writable, not just readable.
it("accepts roles/主要角色/<name>.md (zh locale)", async () => {
const result = JSON.parse(await executeAgentTool(
pipeline,
state,
config,
"write_truth_file",
{
bookId,
fileName: "roles/主要角色/林辞.md",
content: "# 林辞\n核心标签:沉默",
},
));
expect(result.error).toBeUndefined();
const written = await readFile(
join(state.bookDir(bookId), "story", "roles/主要角色/林辞.md"),
"utf-8",
);
expect(written).toContain("核心标签");
});
it("accepts roles/major/<name>.md (en locale)", async () => {
const result = JSON.parse(await executeAgentTool(
pipeline,
state,
config,
"write_truth_file",
{
bookId,
fileName: "roles/major/Mara.md",
content: "# Mara\nCore tag: stoic",
},
));
expect(result.error).toBeUndefined();
const written = await readFile(
join(state.bookDir(bookId), "story", "roles/major/Mara.md"),
"utf-8",
);
expect(written).toContain("Core tag");
});
it("accepts roles/minor/<name>.md (en locale)", async () => {
const result = JSON.parse(await executeAgentTool(
pipeline,
state,
config,
"write_truth_file",
{
bookId,
fileName: "roles/minor/Kit.md",
content: "# Kit\nMinor ally",
},
));
expect(result.error).toBeUndefined();
});
it("rejects unknown role tier dirs (path-traversal safety preserved)", async () => {
const result = JSON.parse(await executeAgentTool(
pipeline,
state,
config,
"write_truth_file",
{
bookId,
fileName: "roles/其他/X.md",
content: "# X",
},
));
expect(result.error).toBeDefined();
});
});
@@ -1,4 +1,4 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
@@ -7,10 +7,7 @@ import {
loadProjectSession,
persistProjectSession,
} from "../interaction/project-session-store.js";
import {
processProjectInteractionInput,
processProjectInteractionRequest,
} from "../interaction/project-control.js";
import { processProjectInteractionRequest } from "../interaction/project-control.js";
let projectRoot: string;
@@ -25,117 +22,6 @@ describe("project interaction control", () => {
// tmpdir cleanup omitted
});
it("routes explicit write command through the persisted active book", async () => {
await persistProjectSession(projectRoot, {
...createProjectSession(projectRoot),
activeBookId: "harbor",
});
const tools = {
listBooks: vi.fn(async () => ["harbor"]),
writeNextChapter: vi.fn(async () => ({ ok: true })),
reviseDraft: vi.fn(async () => ({ ok: true })),
patchChapterText: vi.fn(async () => ({ ok: true })),
renameEntity: vi.fn(async () => ({ ok: true })),
updateCurrentFocus: vi.fn(async () => ({ ok: true })),
updateAuthorIntent: vi.fn(async () => ({ ok: true })),
writeTruthFile: vi.fn(async () => ({ ok: true })),
};
const result = await processProjectInteractionInput({
projectRoot,
input: "/write",
tools,
});
expect(tools.writeNextChapter).toHaveBeenCalledWith("harbor");
expect(result.session.activeBookId).toBe("harbor");
expect(result.request.intent).toBe("write_next");
expect(result.session.events.map((event) => event.kind)).toEqual([
"task.started",
"task.completed",
]);
});
it("persists explicit mode switches in the project session", async () => {
await persistProjectSession(projectRoot, {
...createProjectSession(projectRoot),
activeBookId: "harbor",
});
const result = await processProjectInteractionInput({
projectRoot,
input: "/mode auto",
tools: {
listBooks: vi.fn(async () => ["harbor"]),
writeNextChapter: vi.fn(async () => ({ ok: true })),
reviseDraft: vi.fn(async () => ({ ok: true })),
patchChapterText: vi.fn(async () => ({ ok: true })),
renameEntity: vi.fn(async () => ({ ok: true })),
updateCurrentFocus: vi.fn(async () => ({ ok: true })),
updateAuthorIntent: vi.fn(async () => ({ ok: true })),
writeTruthFile: vi.fn(async () => ({ ok: true })),
},
});
expect(result.session.automationMode).toBe("auto");
expect(result.session.events.map((event) => event.kind)).toEqual([
"task.started",
"task.completed",
]);
});
it("persists failed execution state when a routed action throws", async () => {
await persistProjectSession(projectRoot, {
...createProjectSession(projectRoot),
activeBookId: "harbor",
});
await expect(processProjectInteractionInput({
projectRoot,
input: "/write",
tools: {
listBooks: vi.fn(async () => ["harbor"]),
writeNextChapter: vi.fn(async () => {
throw new Error("boom");
}),
reviseDraft: vi.fn(async () => ({ ok: true })),
patchChapterText: vi.fn(async () => ({ ok: true })),
renameEntity: vi.fn(async () => ({ ok: true })),
updateCurrentFocus: vi.fn(async () => ({ ok: true })),
updateAuthorIntent: vi.fn(async () => ({ ok: true })),
writeTruthFile: vi.fn(async () => ({ ok: true })),
},
})).rejects.toThrow("boom");
const failedSession = await loadProjectSession(projectRoot);
expect(failedSession.currentExecution?.status).toBe("failed");
expect(failedSession.events.at(-1)?.kind).toBe("task.failed");
expect(failedSession.events.at(-1)?.detail).toContain("boom");
});
it("persists book selection into the shared project session", async () => {
await persistProjectSession(projectRoot, createProjectSession(projectRoot));
const result = await processProjectInteractionInput({
projectRoot,
input: "/open harbor",
tools: {
writeNextChapter: vi.fn(async () => ({ ok: true })),
reviseDraft: vi.fn(async () => ({ ok: true })),
patchChapterText: vi.fn(async () => ({ ok: true })),
renameEntity: vi.fn(async () => ({ ok: true })),
updateCurrentFocus: vi.fn(async () => ({ ok: true })),
updateAuthorIntent: vi.fn(async () => ({ ok: true })),
writeTruthFile: vi.fn(async () => ({ ok: true })),
listBooks: vi.fn(async () => ["harbor"]),
},
});
expect(result.session.activeBookId).toBe("harbor");
expect(result.request.intent).toBe("select_book");
});
it("persists structured create_book requests into the shared project session", async () => {
await persistProjectSession(projectRoot, createProjectSession(projectRoot));
@@ -184,58 +70,4 @@ describe("project interaction control", () => {
expect(persisted.activeBookId).toBe("night-harbor");
});
it("persists a creation draft across explicit slash-command ideation turns", async () => {
const ideationRoot = await mkdtemp(join(tmpdir(), "inkos-project-ideation-"));
await writeFile(join(ideationRoot, "inkos.json"), JSON.stringify({ language: "zh" }), "utf-8");
await persistProjectSession(ideationRoot, createProjectSession(ideationRoot));
const tools = {
listBooks: vi.fn(async () => ["harbor"]),
developBookDraft: vi.fn(async () => ({
__interaction: {
responseText: "我先按港风商战悬疑收着。你更想写长篇连载,还是十来章能收住?",
details: {
creationDraft: {
concept: "港风商战悬疑,主角从灰产洗白。",
title: "夜港账本",
genre: "urban",
nextQuestion: "更想写长篇连载,还是十来章能收住?",
missingFields: ["targetChapters"],
readyToCreate: false,
},
},
},
})),
createBook: vi.fn(async () => ({ ok: true })),
exportBook: vi.fn(async () => ({ ok: true })),
writeNextChapter: vi.fn(async () => ({ ok: true })),
reviseDraft: vi.fn(async () => ({ ok: true })),
patchChapterText: vi.fn(async () => ({ ok: true })),
renameEntity: vi.fn(async () => ({ ok: true })),
updateCurrentFocus: vi.fn(async () => ({ ok: true })),
updateAuthorIntent: vi.fn(async () => ({ ok: true })),
writeTruthFile: vi.fn(async () => ({ ok: true })),
};
try {
const result = await processProjectInteractionInput({
projectRoot: ideationRoot,
input: "/new 我想写个港风商战悬疑,主角从灰产洗白。",
tools,
});
expect(result.request.intent).toBe("develop_book");
expect(result.session.creationDraft).toEqual(expect.objectContaining({
title: "夜港账本",
genre: "urban",
}));
const persisted = await loadProjectSession(ideationRoot);
expect(persisted.creationDraft).toEqual(expect.objectContaining({
title: "夜港账本",
}));
} finally {
await rm(ideationRoot, { recursive: true, force: true });
}
});
});
@@ -3,7 +3,6 @@ import type { AssistantMessage, Model, Api } from "@mariozechner/pi-ai";
import {
__resetFixedTemperatureWarnings,
chatCompletion,
chatWithTools,
type LLMClient,
} from "../llm/provider.js";
@@ -285,33 +284,6 @@ describe("chatCompletion via pi-ai", () => {
expect(mockCompleteSimple).not.toHaveBeenCalled();
});
it("rejects oversized tool context before sending to pi-ai", async () => {
const client = makeClient(0.7, {
stream: false,
_piModel: {
...MOCK_PI_MODEL,
contextWindow: 80,
},
});
const error = await captureError(
chatWithTools(
client,
"test-model",
[
{ role: "system", content: "系统设定".repeat(40) },
{ role: "user", content: "用户消息".repeat(40) },
],
[],
{ maxTokens: 20 },
),
);
expect(error.message).toContain("context window");
expect(error.message).toContain("compress");
expect(mockComplete).not.toHaveBeenCalled();
});
it("calls onTextDelta for each text chunk", async () => {
const msg = makeAssistantMessage("abc");
mockStreamSimple.mockReturnValue(makeEventStream([
+1 -3
View File
@@ -276,7 +276,6 @@ export {
type NaturalLanguageRoutingContext,
} from "./interaction/nl-router.js";
export {
processProjectInteractionInput,
processProjectInteractionRequest,
} from "./interaction/project-control.js";
export { createInteractionToolsFromDeps } from "./interaction/project-tools.js";
@@ -344,7 +343,7 @@ export {
export * from "./agent/index.js";
// LLM
export { createLLMClient, chatCompletion, chatWithTools, createStreamMonitor, PartialResponseError, type LLMClient, type LLMResponse, type LLMMessage, type ToolDefinition, type ToolCall, type AgentMessage, type ChatWithToolsResult, type StreamProgress, type OnStreamProgress } from "./llm/provider.js";
export { createLLMClient, chatCompletion, createStreamMonitor, PartialResponseError, type LLMClient, type LLMResponse, type LLMMessage, type StreamProgress, type OnStreamProgress } from "./llm/provider.js";
export {
SERVICE_PRESETS,
SERVICE_TO_PI_PROVIDER,
@@ -430,7 +429,6 @@ export { analyzeHookHealth } from "./utils/hook-health.js";
// Pipeline
export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type PlanChapterResult, type ComposeChapterResult, type ReviseResult, type TruthFiles, type BookStatusInfo, type ImportChaptersInput, type ImportChaptersResult, type TokenUsageSummary } from "./pipeline/runner.js";
export { Scheduler, type SchedulerConfig } from "./pipeline/scheduler.js";
export { runAgentLoop, AGENT_TOOLS as AGENT_TOOLS, type AgentLoopOptions } from "./pipeline/agent.js";
export { detectChapter, detectAndRewrite, loadDetectionHistory, type DetectChapterResult, type DetectAndRewriteResult } from "./pipeline/detection-runner.js";
// State
@@ -1,7 +1,6 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { appendInteractionEvent, appendInteractionMessage } from "./session.js";
import { routeNaturalLanguageIntent } from "./nl-router.js";
import { appendInteractionEvent } from "./session.js";
import type { InteractionRequest } from "./intents.js";
import type { InteractionRuntimeTools } from "./runtime.js";
import { runInteractionRequest } from "./runtime.js";
@@ -60,62 +59,6 @@ async function processProjectInteractionRequestInternal(params: {
}
}
export async function processProjectInteractionInput(params: {
readonly projectRoot: string;
readonly input: string;
readonly tools: InteractionRuntimeTools;
readonly activeBookId?: string;
}) {
const requestLanguage = await detectProjectInteractionLanguage(params.projectRoot);
const session = await loadProjectSession(params.projectRoot);
const restoredBookId = await resolveSessionActiveBook(params.projectRoot, session);
const resolvedBookId = params.activeBookId ?? restoredBookId;
const sessionWithBook = resolvedBookId && session.activeBookId !== resolvedBookId
? { ...session, activeBookId: resolvedBookId }
: session;
const userSession = appendInteractionMessage(sessionWithBook, {
role: "user",
content: params.input,
timestamp: Date.now(),
});
const request = attachRequestLanguage(routeNaturalLanguageIntent(params.input, {
activeBookId: userSession.activeBookId,
hasCreationDraft: Boolean(userSession.creationDraft),
}), requestLanguage);
try {
const result = await runInteractionRequest({
session: userSession,
request,
tools: params.tools,
});
await persistProjectSession(params.projectRoot, result.session);
return {
...result,
request,
};
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
const failedSession = appendInteractionEvent({
...userSession,
currentExecution: {
status: "failed",
bookId: userSession.activeBookId,
chapterNumber: userSession.activeChapterNumber,
stageLabel: request.language === "en" ? `failed ${request.intent}` : `执行失败:${request.intent}`,
},
}, {
kind: "task.failed",
timestamp: Date.now(),
status: "failed",
bookId: userSession.activeBookId,
chapterNumber: userSession.activeChapterNumber,
detail,
});
await persistProjectSession(params.projectRoot, failedSession);
throw error;
}
}
export async function processProjectInteractionRequest(params: {
readonly projectRoot: string;
readonly request: InteractionRequest;
+1 -271
View File
@@ -8,13 +8,11 @@ import type {
ReviseMode,
LLMClient,
BookConfig,
ToolDefinition,
} from "../index.js";
import { chatCompletion, chatWithTools } from "../index.js";
import { chatCompletion } from "../index.js";
import { executeEditTransaction } from "./edit-controller.js";
import { defaultChapterLength } from "../utils/length-metrics.js";
import type { InteractionRuntimeTools } from "./runtime.js";
import type { BookCreationDraft } from "./session.js";
import { writeExportArtifact } from "./export-artifact.js";
import { safeChildPath } from "../utils/path-safety.js";
import { deriveBookIdFromTitle } from "../utils/book-id.js";
@@ -329,209 +327,6 @@ async function withPipelineInteractionTelemetry<T extends { chapterNumber?: numb
}
}
const CREATE_BOOK_TOOL: ToolDefinition = {
name: "create_book",
description: "根据用户描述更新建书草案。系统会将草案按阶段渲染给用户,用户确认后才建书。",
parameters: {
type: "object",
properties: {
title: { type: "string", description: "书名" },
genre: { type: "string", description: "题材标识,如 xuanhuan, urban, romance, scifi, mystery" },
platform: { type: "string", enum: ["tomato", "qidian", "feilu", "other"], description: "发布平台" },
targetChapters: { type: "number", description: "目标章数。运行参数,用户没说就别追问,系统默认 200 并展示在草案里供修改。" },
chapterWordCount: { type: "number", description: "每章字数。运行参数,用户没说就别追问,系统默认 3000 并展示在草案里供修改。" },
language: { type: "string", enum: ["zh", "en"], description: "写作语言,默认 zh" },
brief: { type: "string", description: "面向读者的故事简介。不要把所有设定混成唯一字段;能拆开的内容要分别写入下面字段。" },
worldPremise: { type: "string", description: "世界观、故事发生环境、基本规则。" },
settingNotes: { type: "string", description: "设定补充、时代质感、规则限制、不可变事实。" },
protagonist: { type: "string", description: "主角身份、处境、欲望、压力、初始缺口。" },
supportingCast: { type: "string", description: "关键配角及其利益关系。信息不足可留空。" },
conflictCore: { type: "string", description: "核心冲突、主要压迫、读者期待的回报。" },
volumeOutline: { type: "string", description: "第一卷或第一阶段方向,不要写成全书流水账。" },
constraints: { type: "string", description: "用户明确提出的写作硬约束,如人称、比例、禁忌、节奏。" },
authorIntent: { type: "string", description: "用户向前生效的创作意图和方向控制。" },
currentFocus: { type: "string", description: "下一步最需要展开或确认的焦点。" },
nextQuestion: { type: "string", description: "如果还缺关键信息,只问一个最重要的问题。" },
missingFields: {
type: "array",
items: { type: "string" },
description: "仍缺的故事核心字段 key,例如 worldPremise, protagonist, conflictCore。不要写 targetChapters/chapterWordCount,篇幅有默认值不算缺。",
},
readyToCreate: { type: "boolean", description: "只有故事核心信息齐全时才为 true。篇幅缺失不影响。" },
},
},
};
const BOOK_DRAFT_SYSTEM_PROMPT = [
"你是 InkOS 的建书草案助手。用户会分多轮描述想写的书,你需要调用 create_book 工具更新一份可编辑草案。",
"",
"规则:",
"1. 不要把世界观、主角、冲突、卷纲全部塞进 brief;能拆开的内容必须分别写入 worldPremise、protagonist、conflictCore、volumeOutline 等字段。",
"2. 按阶段收集:基础信息(title/genre/platform/language) -> 世界观(worldPremise/settingNotes) -> 角色(protagonist/supportingCast) -> 冲突(conflictCore/blurb/authorIntent) -> 结构(volumeOutline/currentFocus/constraints)。targetChapters/chapterWordCount 是运行参数,用户没说就别追问,系统会默认 200/3000。",
"3. 用户只给一部分信息时,只更新这部分,不要为了 readyToCreate 编造剩余阶段。",
"4. 信息还不够时,把 missingFields 写清楚,并在 nextQuestion 里只问一个最关键的问题。",
"5. 只有 title、genre、platform、worldPremise、protagonist、conflictCore 都明确时,readyToCreate 才能为 true。篇幅不是必填项。",
"6. 如果用户后续要求修改某些字段,重新调用 create_book 工具,只更新被提到的字段,其余保持不变。",
"7. 不要只回复文字讨论——必须调用 create_book 工具输出结构化草案。",
].join("\n");
/** Map directive field keys to BookCreationDraft property names. */
function applyFieldsToDraft(
existing: BookCreationDraft | undefined,
fields: Readonly<Record<string, unknown>>,
concept: string,
): BookCreationDraft {
const draft: BookCreationDraft = {
concept,
missingFields: [],
readyToCreate: false,
...(existing ?? {}),
};
for (const [key, rawValue] of Object.entries(fields)) {
if (rawValue === undefined || rawValue === null || rawValue === "") continue;
const value = typeof rawValue === "string" ? rawValue.trim() : rawValue;
if (value === "") continue;
switch (key) {
case "title":
if (typeof value === "string") draft.title = value;
break;
case "genre":
if (typeof value === "string") draft.genre = value;
break;
case "platform":
if (typeof value === "string") draft.platform = value;
break;
case "language":
if (value === "zh" || value === "en") draft.language = value;
break;
case "targetChapters": {
const n = typeof value === "number" ? value : parseInt(String(value), 10);
if (!Number.isNaN(n) && n > 0) draft.targetChapters = n;
break;
}
case "chapterWordCount":
case "chapterLength": {
const n = typeof value === "number" ? value : parseInt(String(value), 10);
if (!Number.isNaN(n) && n > 0) draft.chapterWordCount = n;
break;
}
case "brief":
case "blurb":
if (typeof value === "string") draft.blurb = value;
break;
case "worldPremise":
if (typeof value === "string") draft.worldPremise = value;
break;
case "settingNotes":
if (typeof value === "string") draft.settingNotes = value;
break;
case "protagonist":
if (typeof value === "string") draft.protagonist = value;
break;
case "supportingCast":
if (typeof value === "string") draft.supportingCast = value;
break;
case "conflictCore":
if (typeof value === "string") draft.conflictCore = value;
break;
case "volumeOutline":
if (typeof value === "string") draft.volumeOutline = value;
break;
case "constraints":
if (typeof value === "string") draft.constraints = value;
break;
case "authorIntent":
if (typeof value === "string") draft.authorIntent = value;
break;
case "currentFocus":
if (typeof value === "string") draft.currentFocus = value;
break;
case "nextQuestion":
if (typeof value === "string") draft.nextQuestion = value;
break;
case "missingFields":
if (Array.isArray(value)) {
draft.missingFields = value
.filter((field): field is string => typeof field === "string" && field.trim().length > 0)
.map((field) => field.trim());
}
break;
case "readyToCreate":
if (typeof value === "boolean") draft.readyToCreate = value;
break;
// Unknown keys are silently ignored — the LLM may emit
// application-level keys we don't map to the draft struct.
}
}
return draft;
}
// Length is a run parameter, not a story-core field: the user shouldn't be
// blocked on "how many chapters" the way they're blocked on "who's the
// protagonist". We fill editable defaults instead of treating them as
// must-ask fields. These mirror the BookSchema defaults in models/book.ts.
const DEFAULT_DRAFT_TARGET_CHAPTERS = 200;
const DEFAULT_DRAFT_CHAPTER_WORD_COUNT = 3000;
// The story-core fields the user MUST supply before a book can be created.
// Length (targetChapters/chapterWordCount) is intentionally absent — it's
// defaulted in finalizeBookDraft and shown editable in the draft summary.
function missingCoreDraftFields(draft: BookCreationDraft): string[] {
const missing: string[] = [];
if (!draft.title?.trim()) missing.push("title");
if (!draft.genre?.trim()) missing.push("genre");
if (!draft.platform?.trim()) missing.push("platform");
if (!draft.worldPremise?.trim()) missing.push("worldPremise");
if (!draft.protagonist?.trim()) missing.push("protagonist");
if (!draft.conflictCore?.trim()) missing.push("conflictCore");
return missing;
}
function finalizeBookDraft(draft: BookCreationDraft): BookCreationDraft {
// Fill editable length defaults so the draft always carries a concrete,
// user-visible run parameter rather than building from a hidden fallback.
const withDefaults: BookCreationDraft = {
...draft,
targetChapters:
typeof draft.targetChapters === "number" ? draft.targetChapters : DEFAULT_DRAFT_TARGET_CHAPTERS,
chapterWordCount:
typeof draft.chapterWordCount === "number" ? draft.chapterWordCount : DEFAULT_DRAFT_CHAPTER_WORD_COUNT,
};
const coreMissing = missingCoreDraftFields(withDefaults);
const missingFields = Array.from(new Set([...coreMissing, ...(withDefaults.missingFields ?? [])]));
return {
...withDefaults,
missingFields,
readyToCreate: withDefaults.readyToCreate === true && coreMissing.length === 0,
};
}
function formatDraftForUserMessage(
existingDraft: BookCreationDraft | undefined,
userMessage: string,
): string {
const parts: string[] = [];
if (existingDraft) {
parts.push("## 当前草案状态");
const entries = Object.entries(existingDraft).filter(
([, v]) => v !== undefined && v !== "" && !(Array.isArray(v) && v.length === 0),
);
for (const [key, value] of entries) {
parts.push(`- **${key}**: ${typeof value === "object" ? JSON.stringify(value) : String(value)}`);
}
parts.push("");
}
parts.push("## 用户输入");
parts.push(userMessage);
return parts.join("\n");
}
export function createInteractionToolsFromDeps(
pipeline: PipelineLike,
state: StateLike,
@@ -549,71 +344,6 @@ export function createInteractionToolsFromDeps(
return {
listBooks: () => state.listBooks(),
developBookDraft: async (input, existingDraft) => {
const concept = existingDraft?.concept ?? input;
if (!instrumentedPipeline.config?.client || !instrumentedPipeline.config?.model) {
// Fallback: no LLM configured
return {
__interaction: {
responseText: "请先配置 LLM 模型,然后再创建书籍。",
details: {
creationDraft: {
concept,
missingFields: [
"title",
"genre",
"platform",
"worldPremise",
"protagonist",
"conflictCore",
],
readyToCreate: false,
},
},
},
};
}
// Build messages - include existing draft context if present
const userContent = existingDraft
? `当前草案参数:${JSON.stringify(existingDraft, null, 2)}\n\n用户输入:${input}`
: input;
const result = await chatWithTools(
instrumentedPipeline.config.client,
instrumentedPipeline.config.model,
[
{ role: "system", content: BOOK_DRAFT_SYSTEM_PROMPT },
{ role: "user", content: userContent },
],
[CREATE_BOOK_TOOL],
{ temperature: 0.4 },
);
// Extract tool call if present
const toolCall = result.toolCalls[0];
let parsedArgs: Record<string, unknown> = {};
if (toolCall) {
try {
parsedArgs = JSON.parse(toolCall.arguments);
} catch {
// If parsing fails, use empty args
}
}
const draft = finalizeBookDraft(applyFieldsToDraft(existingDraft, parsedArgs, concept));
return {
__interaction: {
responseText: result.content || "已生成建书参数,请确认或修改。",
details: {
creationDraft: draft,
toolCall: toolCall ? { name: toolCall.name, arguments: parsedArgs } : undefined,
},
},
};
},
createBook: async (input) => {
const book = buildBookConfig(input);
if (!pipeline.initBook) {
+1 -66
View File
@@ -2,7 +2,7 @@ import type { AutomationMode } from "./modes.js";
import { routeInteractionRequest } from "./request-router.js";
import type { InteractionRequest } from "./intents.js";
import type { ExecutionState, InteractionEvent } from "./events.js";
import type { PendingDecision, InteractionSession, DraftRound } from "./session.js";
import type { PendingDecision, InteractionSession } from "./session.js";
import {
appendInteractionEvent,
bindActiveBook,
@@ -17,10 +17,6 @@ type RuntimeLanguage = "zh" | "en";
export interface InteractionRuntimeTools {
readonly listBooks: () => Promise<ReadonlyArray<string>>;
readonly developBookDraft?: (
input: string,
existingDraft?: InteractionSession["creationDraft"],
) => Promise<unknown>;
readonly createBook?: (input: {
readonly title: string;
readonly genre?: string;
@@ -177,15 +173,6 @@ function buildTaskStartedState(
en: "preparing chapter inputs",
}),
};
case "develop_book":
return {
status: "planning",
bookId: request.bookId ?? session.activeBookId,
stageLabel: localize(language, {
zh: "收敛创作草案",
en: "developing book draft",
}),
};
case "create_book":
return {
status: "planning",
@@ -358,58 +345,6 @@ async function handleDraftLifecycleRequest(params: {
const { language, addEvent, markCompleted } = helpers;
switch (request.intent) {
case "develop_book": {
if (!tools.developBookDraft) {
throw new Error(localize(language, {
zh: "创作草案会话暂未实现。",
en: "Book-draft ideation is not implemented yet.",
}));
}
if (!request.instruction) {
throw new Error(localize(language, {
zh: "创作草案需要一条用户输入。",
en: "Book-draft ideation requires user input.",
}));
}
const toolResult = await tools.developBookDraft(request.instruction, session.creationDraft);
const metadata = extractToolMetadata(toolResult);
const draft = metadata.details?.creationDraft as InteractionSession["creationDraft"] | undefined;
if (!draft) {
throw new Error(localize(language, {
zh: "创作草案工具没有返回草案数据。",
en: "Book-draft tool did not return draft data.",
}));
}
const newRound: DraftRound = {
roundId: (session.draftRounds?.length ?? 0) + 1,
userMessage: request.instruction ?? "",
assistantRaw: metadata.details?.draftRaw as string ?? "",
fieldsUpdated: (metadata.details?.fieldsUpdated as string[]) ?? [],
summary: metadata.details?.draftSummary as string ?? "",
timestamp: Date.now(),
};
const withDraft = updateCreationDraft(session, draft);
const withRounds = {
...withDraft,
draftRounds: [...(withDraft.draftRounds ?? []), newRound],
};
const nextSession = appendToolEvents(withRounds, metadata.events);
const completed = {
...markCompleted(nextSession),
currentExecution: metadata.currentExecution ?? markCompleted(nextSession).currentExecution,
};
return {
session: addEvent(completed, "task.completed", "completed", localize(language, {
zh: "已更新创作草案。",
en: "Updated the book draft.",
})),
responseText: metadata.responseText ?? localize(language, {
zh: "已更新创作草案。",
en: "Updated the book draft.",
}),
details: metadata.details,
};
}
case "show_book_draft": {
if (!session.creationDraft) {
return {
-208
View File
@@ -1,18 +1,13 @@
import type { LLMConfig } from "../models/project.js";
import {
streamSimple as piStreamSimple,
stream as piStream,
completeSimple as piCompleteSimple,
complete as piComplete,
} from "@mariozechner/pi-ai";
import type {
Api as PiApi,
Model as PiModel,
Context as PiContext,
AssistantMessageEvent,
Tool as PiTool,
TextContent as PiTextContent,
ToolCall as PiToolCall,
} from "@mariozechner/pi-ai";
import { resolveServicePreset } from "./service-presets.js";
import { getEndpoint } from "./providers/index.js";
@@ -145,31 +140,6 @@ export interface LLMClient {
};
}
// === Tool-calling Types ===
export interface ToolDefinition {
readonly name: string;
readonly description: string;
readonly parameters: Record<string, unknown>;
}
export interface ToolCall {
readonly id: string;
readonly name: string;
readonly arguments: string;
}
export type AgentMessage =
| { readonly role: "system"; readonly content: string }
| { readonly role: "user"; readonly content: string }
| { readonly role: "assistant"; readonly content: string | null; readonly toolCalls?: ReadonlyArray<ToolCall> }
| { readonly role: "tool"; readonly toolCallId: string; readonly content: string };
export interface ChatWithToolsResult {
readonly content: string;
readonly toolCalls: ReadonlyArray<ToolCall>;
}
// === Factory ===
export function createLLMClient(config: LLMConfig): LLMClient {
@@ -387,31 +357,6 @@ function estimateLLMMessagesTokens(messages: ReadonlyArray<LLMMessage>): number
return messages.reduce((total, message) => total + estimateTextTokens(message.content), 0);
}
function estimateAgentMessagesTokens(messages: ReadonlyArray<AgentMessage>): number {
let total = 0;
for (const message of messages) {
if (message.role === "assistant") {
total += estimateTextTokens(message.content ?? "");
for (const call of message.toolCalls ?? []) {
total += estimateTextTokens(call.name);
total += estimateTextTokens(call.arguments);
}
continue;
}
if (message.role === "tool") {
total += estimateTextTokens(message.toolCallId);
total += estimateTextTokens(message.content);
continue;
}
total += estimateTextTokens(message.content);
}
return total;
}
function estimateToolsTokens(tools: ReadonlyArray<ToolDefinition>): number {
return estimateTextTokens(JSON.stringify(tools));
}
type PiMessageContent = PiContext["messages"][number]["content"];
function estimatePiContentTokens(content: PiMessageContent): number {
@@ -1235,40 +1180,6 @@ export async function chatCompletion(
}
}
// === Tool-calling Chat (used by agent loop) ===
export async function chatWithTools(
client: LLMClient,
model: string,
messages: ReadonlyArray<AgentMessage>,
tools: ReadonlyArray<ToolDefinition>,
options?: {
readonly temperature?: number;
readonly maxTokens?: number;
},
): Promise<ChatWithToolsResult> {
const errorCtx = { baseUrl: client._piModel?.baseUrl ?? "(unknown)", model, service: client.service };
try {
const resolved = {
temperature: clampTemperatureForModel(
client.service,
model,
options?.temperature ?? client.defaults.temperature,
),
maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
};
assertWithinContextWindow({
piModel: resolvePiModel(client, model),
model,
estimatedInputTokens: estimateAgentMessagesTokens(messages) + estimateToolsTokens(tools),
reservedOutputTokens: resolved.maxTokens,
});
return await chatWithToolsViaPiAi(client, model, messages, tools, resolved);
} catch (error) {
throw wrapLLMError(error, errorCtx);
}
}
// === pi-ai Unified Implementation ===
/**
@@ -1308,66 +1219,6 @@ function toPiContext(messages: ReadonlyArray<LLMMessage>): PiContext {
return { systemPrompt, messages: piMessages };
}
/** Convert inkos AgentMessage[] to pi-ai Context (with tool calls/results). */
function agentMessagesToPiContext(messages: ReadonlyArray<AgentMessage>): PiContext {
const systemParts = messages.filter((m) => m.role === "system").map((m) => (m as { content: string }).content);
const systemPrompt = systemParts.length > 0 ? systemParts.join("\n\n") : undefined;
const piMessages: PiContext["messages"] = [];
for (const msg of messages) {
if (msg.role === "system") continue;
if (msg.role === "user") {
piMessages.push({ role: "user", content: msg.content, timestamp: Date.now() });
continue;
}
if (msg.role === "assistant") {
const content: (PiTextContent | PiToolCall)[] = [];
if (msg.content) content.push({ type: "text", text: msg.content });
if (msg.toolCalls) {
for (const tc of msg.toolCalls) {
content.push({
type: "toolCall",
id: tc.id,
name: tc.name,
arguments: JSON.parse(tc.arguments),
});
}
}
if (content.length === 0) content.push({ type: "text", text: "" });
piMessages.push({
role: "assistant",
content,
api: "openai-completions" as PiApi,
provider: "openai",
model: "",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "stop",
timestamp: Date.now(),
});
continue;
}
if (msg.role === "tool") {
piMessages.push({
role: "toolResult",
toolCallId: msg.toolCallId,
toolName: "",
content: [{ type: "text", text: msg.content }],
isError: false,
timestamp: Date.now(),
});
}
}
return { systemPrompt, messages: piMessages };
}
/** Convert inkos ToolDefinition[] to pi-ai Tool[]. */
function toPiTools(tools: ReadonlyArray<ToolDefinition>): PiTool[] {
return tools.map((t) => ({
name: t.name,
description: t.description,
parameters: t.parameters as PiTool["parameters"],
}));
}
async function chatCompletionViaPiAi(
client: LLMClient,
model: string,
@@ -1463,62 +1314,3 @@ async function chatCompletionViaPiAi(
},
};
}
async function chatWithToolsViaPiAi(
client: LLMClient,
model: string,
messages: ReadonlyArray<AgentMessage>,
tools: ReadonlyArray<ToolDefinition>,
resolved: { readonly temperature: number; readonly maxTokens: number },
): Promise<ChatWithToolsResult> {
const piModel = resolvePiModel(client, model);
const context = agentMessagesToPiContext(messages);
context.tools = toPiTools(tools);
const streamOpts = {
temperature: resolved.temperature,
maxTokens: resolved.maxTokens,
apiKey: client._apiKey,
headers: mergeUserAgent(piModel.headers),
};
if (!client.stream) {
const response = await piComplete(piModel, context, streamOpts);
if (response.stopReason === "error" && response.errorMessage) {
throw new Error(response.errorMessage);
}
const content = response.content
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map((block) => block.text)
.join("");
const toolCalls = response.content
.filter((block): block is PiToolCall => block.type === "toolCall")
.map((block) => ({
id: block.id,
name: block.name,
arguments: JSON.stringify(block.arguments),
}));
return { content, toolCalls };
}
const eventStream = piStream(piModel, context, streamOpts);
let content = "";
const toolCalls: ToolCall[] = [];
for await (const event of eventStream) {
if (event.type === "text_delta") {
content += event.delta;
}
if (event.type === "toolcall_end") {
toolCalls.push({
id: event.toolCall.id,
name: event.toolCall.name,
arguments: JSON.stringify(event.toolCall.arguments),
});
}
if (event.type === "error" && event.error.errorMessage) {
throw new Error(event.error.errorMessage);
}
}
return { content, toolCalls };
}
-695
View File
@@ -1,695 +0,0 @@
import { chatWithTools, type AgentMessage, type ToolDefinition } from "../llm/provider.js";
import { PipelineRunner, type PipelineConfig } from "./runner.js";
import { normalizePlatformOrOther, type Genre } from "../models/book.js";
import { DEFAULT_REVISE_MODE, type ReviseMode } from "../agents/reviser.js";
import { deriveBookIdFromTitle } from "../utils/book-id.js";
import { inferLanguage } from "../utils/language.js";
import { defaultChapterLength } from "../utils/length-metrics.js";
/** Tool definitions for the agent loop. */
const TOOLS: ReadonlyArray<ToolDefinition> = [
{
name: "write_draft",
description: "写【下一章】草稿。只能续写最新章之后的下一章,不能指定章节号,不能补历史空章。生成正文、更新状态卡/账本/伏笔池、保存章节文件。",
parameters: {
type: "object",
properties: {
bookId: { type: "string", description: "书籍ID" },
guidance: { type: "string", description: "本章创作指导(可选,自然语言)" },
},
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、数值、伏笔等问题。",
parameters: {
type: "object",
properties: {
bookId: { type: "string", description: "书籍ID" },
chapterNumber: { type: "number", description: "章节号(不填则审计最新章)" },
},
required: ["bookId"],
},
},
{
name: "revise_chapter",
description: "修订指定章节的文字质量。根据审计问题做局部修正,不改变剧情走向。默认 spot-fix(定点修复最小改动);也支持 polish(润色)、rewrite(改写)、rework(重写)、anti-detect。注意:不能用来补缺失章节、不能改章节号、不能替代 write_draft。",
parameters: {
type: "object",
properties: {
bookId: { type: "string", description: "书籍ID" },
chapterNumber: { type: "number", description: "章节号(不填则修订最新章)" },
mode: { type: "string", enum: ["polish", "rewrite", "rework", "spot-fix", "anti-detect"], description: `修订模式(默认${DEFAULT_REVISE_MODE}` },
},
required: ["bookId"],
},
},
{
name: "scan_market",
description: "扫描市场趋势。从平台排行榜获取实时数据并分析。",
parameters: {
type: "object",
properties: {},
},
},
{
name: "create_book",
description: "创建一本新书。生成世界观、卷纲、文风指南等基础设定。",
parameters: {
type: "object",
properties: {
title: { type: "string", description: "书名" },
genre: { type: "string", enum: ["xuanhuan", "xianxia", "urban", "horror", "other"], description: "题材" },
platform: { type: "string", enum: ["tomato", "feilu", "qidian", "other"], description: "目标平台" },
brief: { type: "string", description: "创作简述/需求(自然语言)" },
},
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: "获取书籍状态概览:章数、字数、最近章节审计情况。",
parameters: {
type: "object",
properties: {
bookId: { type: "string", description: "书籍ID" },
},
required: ["bookId"],
},
},
{
name: "read_truth_files",
description: "读取书籍的长期记忆(状态卡、资源账本、伏笔池)+ 世界观和卷纲。",
parameters: {
type: "object",
properties: {
bookId: { type: "string", description: "书籍ID" },
},
required: ["bookId"],
},
},
{
name: "list_books",
description: "列出所有书籍。",
parameters: {
type: "object",
properties: {},
},
},
{
name: "write_full_pipeline",
description: "完整管线:写草稿 → 审计 → 自动修订(如需要)。一键完成。",
parameters: {
type: "object",
properties: {
bookId: { type: "string", description: "书籍ID" },
count: { type: "number", description: "连续写几章(默认1" },
},
required: ["bookId"],
},
},
{
name: "web_fetch",
description: "抓取指定URL的文本内容。用于读取搜索结果中的详细页面。",
parameters: {
type: "object",
properties: {
url: { type: "string", description: "要抓取的URL" },
maxChars: { type: "number", description: "最大返回字符数(默认8000" },
},
required: ["url"],
},
},
{
name: "import_style",
description: "从参考文本生成文风指南(统计 + LLM定性分析)。生成 style_profile.json 和 style_guide.md。",
parameters: {
type: "object",
properties: {
bookId: { type: "string", description: "目标书籍ID" },
referenceText: { type: "string", description: "参考文本(至少2000字)" },
},
required: ["bookId", "referenceText"],
},
},
{
name: "import_canon",
description: "从正传导入正典参照,生成 parent_canon.md,启用番外写作和审计模式。",
parameters: {
type: "object",
properties: {
targetBookId: { type: "string", description: "番外书籍ID" },
parentBookId: { type: "string", description: "正传书籍ID" },
},
required: ["targetBookId", "parentBookId"],
},
},
{
name: "import_chapters",
description: "【整书重导】导入已有章节。从完整文本中自动分割所有章节,逐章分析并重建全部真相文件。这是整书级操作,不是补某一章的工具。导入后可用 write_draft 续写。",
parameters: {
type: "object",
properties: {
bookId: { type: "string", description: "目标书籍ID" },
text: { type: "string", description: "包含多章的完整文本" },
splitPattern: { type: "string", description: "章节分割正则(可选,默认匹配'第X章'" },
},
required: ["bookId", "text"],
},
},
{
name: "write_truth_file",
description: "【整文件覆盖】直接替换书的真相文件内容。用于扩展大纲、修改世界观、调整规则。注意:这是整文件覆盖写入,不是追加;不要用来改 current_state.md 的章节进度指针或 hack 章节号;不要用来补空章节。book_rules.md / story_bible.md 是 Phase 5 之后的兼容指针,不再作为写入目标——请改写 outline/story_frame.md 的 YAML frontmatter。",
parameters: {
type: "object",
properties: {
bookId: { type: "string", description: "书籍ID" },
fileName: { type: "string", description: "文件名(如 outline/story_frame.md、outline/volume_map.md、outline/节奏原则.md(可选,Phase 5 后节奏原则合并到 volume_map 尾段,仅 legacy / 人工写入时出现)、roles/主要角色/<name>.md、roles/次要角色/<name>.md、current_state.md、pending_hooks.md" },
content: { type: "string", description: "新的完整文件内容" },
},
required: ["bookId", "fileName", "content"],
},
},
];
export interface AgentLoopOptions {
readonly onToolCall?: (name: string, args: Record<string, unknown>) => void;
readonly onToolResult?: (name: string, result: string) => void;
readonly onMessage?: (content: string) => void;
readonly maxTurns?: number;
}
export async function runAgentLoop(
config: PipelineConfig,
instruction: string,
options?: AgentLoopOptions,
): Promise<string> {
const pipeline = new PipelineRunner(config);
const { StateManager } = await import("../state/manager.js");
const state = new StateManager(config.projectRoot);
const messages: AgentMessage[] = [
{
role: "system",
content: `你是 InkOS 小说写作 Agent。用户是小说作者,你帮他管理从建书到成稿的全过程。
## 工具
| 工具 | 作用 |
|------|------|
| list_books | 列出所有书 |
| get_book_status | 查看书的章数、字数、审计状态 |
| read_truth_files | 读取长期记忆(状态卡、资源账本、伏笔池)和设定(世界观、卷纲、本书规则) |
| create_book | 建书,生成世界观、卷纲、本书规则(自动加载题材 genre profile |
| plan_chapter | 先生成 chapter intent,确认本章目标/冲突/优先级 |
| compose_chapter | 再生成 runtime context/rule stack,确认实际输入 |
| write_draft | 写【下一章】草稿(只能续写最新章之后,不能补历史章) |
| audit_chapter | 审计章节(32维度,按题材条件启用,含AI痕迹+敏感词检测) |
| revise_chapter | 修订章节文字质量(不能补空章/改章号,五种模式) |
| update_author_intent | 更新书级长期意图 author_intent.md |
| update_current_focus | 更新当前关注点 current_focus.md |
| write_full_pipeline | 完整管线:写 → 审 → 改(如需要) |
| scan_market | 扫描平台排行榜,分析市场趋势 |
| web_fetch | 抓取指定URL的文本内容 |
| import_style | 从参考文本生成文风指南(统计+LLM分析) |
| import_canon | 从正传导入正典参照,启用番外模式 |
| import_chapters | 【整书重导】导入全部已有章节并重建真相文件 |
| write_truth_file | 【整文件覆盖】替换真相文件内容,不能用来改章节进度 |
## 长期记忆
每本书有两层控制面:
- **author_intent.md** — 这本书长期想成为什么
- **current_focus.md** — 最近 1-3 章要把注意力拉回哪里
以及七个长期记忆文件,是 Agent 写作和审计的事实依据:
- **current_state.md** — 角色位置、关系、已知信息、当前冲突
- **particle_ledger.md** — 物品/资源账本,每笔增减有据可查
- **pending_hooks.md** — 已埋伏笔、推进状态、预期回收时机
- **chapter_summaries.md** — 每章压缩摘要(人物、事件、伏笔、情绪)
- **subplot_board.md** — 支线进度板
- **emotional_arcs.md** — 角色情感弧线
- **character_matrix.md** — 角色交互矩阵与信息边界
## 管线逻辑
- audit 返回 passed=true → 不需要 revise
- audit 返回 passed=false 且有 critical → 调 revise,改完可以再 audit
- write_full_pipeline 会自动走完 写→审→改,适合不需要中间干预的场景
## 规则
- 用户提供了题材/创意但没说要扫描市场 → 跳过 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 续写
## 禁止事项(严格遵守)
- 不要用 write_draft 补历史中间章节。write_draft 只能写【当前最新章之后的下一章】
- 不要用 import_chapters 修补某一个空章。import_chapters 是整书级重导工具
- 不要用 write_truth_file 修改 current_state.md 的章节进度来"骗"系统跳到某一章
- 不要用 revise_chapter 补缺失章节或改章节号。revise 只做文字质量修订
- 用户说"补第 N 章"或"第 N 章是空的"时,先用 get_book_status 和 read_truth_files 判断真实状态,再决定用哪个工具
- 不要在没有确认书籍状态的情况下直接调用写作工具`,
},
{ role: "user", content: instruction },
];
const maxTurns = options?.maxTurns ?? 20;
let lastAssistantMessage = "";
for (let turn = 0; turn < maxTurns; turn++) {
const result = await chatWithTools(config.client, config.model, messages, TOOLS);
// Push assistant message to history
messages.push({
role: "assistant" as const,
content: result.content || null,
...(result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {}),
});
if (result.content) {
lastAssistantMessage = result.content;
options?.onMessage?.(result.content);
}
// If no tool calls, we're done
if (result.toolCalls.length === 0) break;
// Execute tool calls
for (const toolCall of result.toolCalls) {
let toolResult: string;
try {
const args = JSON.parse(toolCall.arguments) as Record<string, unknown>;
options?.onToolCall?.(toolCall.name, args);
toolResult = await executeTool(pipeline, state, config, toolCall.name, args);
} catch (e) {
toolResult = JSON.stringify({ error: String(e) });
}
options?.onToolResult?.(toolCall.name, toolResult);
messages.push({ role: "tool" as const, toolCallId: toolCall.id, content: toolResult });
}
}
return lastAssistantMessage;
}
export async function executeAgentTool(
pipeline: PipelineRunner,
state: import("../state/manager.js").StateManager,
config: PipelineConfig,
name: string,
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 bookId = args.bookId as string;
const writeGuardError = await getSequentialWriteGuardError(state, bookId, "write_draft");
if (writeGuardError) {
return JSON.stringify({ error: writeGuardError });
}
const result = await pipeline.writeDraft(
bookId,
args.guidance as string | undefined,
);
return JSON.stringify(result);
}
case "audit_chapter": {
const result = await pipeline.auditDraft(
args.bookId as string,
args.chapterNumber as number | undefined,
);
return JSON.stringify(result);
}
case "revise_chapter": {
// Guard: target chapter must exist and have content
const bookId = args.bookId as string;
const chapterNum = args.chapterNumber as number | undefined;
if (chapterNum !== undefined) {
const index = await state.loadChapterIndex(bookId);
const chapter = index.find((ch) => ch.number === chapterNum);
if (!chapter) {
return JSON.stringify({ error: `${chapterNum}章不存在。revise_chapter 只能修订已有章节,不能用来补写缺失章节。请用 get_book_status 确认。` });
}
if (chapter.wordCount === 0) {
return JSON.stringify({ error: `${chapterNum}章内容为空(0字)。revise_chapter 不能修订空章节。` });
}
}
const result = await pipeline.reviseDraft(
bookId,
chapterNum,
(args.mode as ReviseMode) ?? DEFAULT_REVISE_MODE,
);
return JSON.stringify(result);
}
case "scan_market": {
const result = await pipeline.runRadar();
return JSON.stringify(result);
}
case "create_book": {
const now = new Date().toISOString();
const title = args.title as string;
const bookId = deriveBookIdFromTitle(title) || `book-${Date.now().toString(36)}`;
const brief = args.brief as string | undefined;
const language = inferLanguage(brief ?? title);
const book = {
id: bookId,
title,
platform: normalizePlatformOrOther(args.platform ?? "tomato"),
genre: ((args.genre as string) ?? "xuanhuan") as Genre,
status: "outlining" as const,
targetChapters: 200,
chapterWordCount: defaultChapterLength(language),
language,
createdAt: now,
updatedAt: now,
};
if (brief) {
const contextPipeline = new PipelineRunner({ ...config, externalContext: brief });
await contextPipeline.initBook(book);
} else {
await pipeline.initBook(book);
}
return JSON.stringify({ bookId, title, status: "created" });
}
case "get_book_status": {
const result = await pipeline.getBookStatus(args.bookId as string);
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);
}
case "list_books": {
const bookIds = await state.listBooks();
const books = await Promise.all(
bookIds.map(async (id) => {
try {
return await pipeline.getBookStatus(id);
} catch {
return { bookId: id, error: "failed to load" };
}
}),
);
return JSON.stringify(books);
}
case "write_full_pipeline": {
const bookId = args.bookId as string;
const writeGuardError = await getSequentialWriteGuardError(state, bookId, "write_full_pipeline");
if (writeGuardError) {
return JSON.stringify({ error: writeGuardError });
}
const count = (args.count as number) ?? 1;
const results = [];
for (let i = 0; i < count; i++) {
const result = await pipeline.writeNextChapter(bookId);
results.push(result);
}
return JSON.stringify(results);
}
case "web_fetch": {
const { fetchUrl } = await import("../utils/web-search.js");
const text = await fetchUrl(args.url as string, (args.maxChars as number) ?? 8000);
return JSON.stringify({ url: args.url, content: text });
}
case "import_style": {
const guide = await pipeline.generateStyleGuide(
args.bookId as string,
args.referenceText as string,
);
return JSON.stringify({
bookId: args.bookId,
statsProfile: "story/style_profile.json",
styleGuide: "story/style_guide.md",
guidePreview: guide.slice(0, 500),
});
}
case "import_canon": {
const canon = await pipeline.importCanon(
args.targetBookId as string,
args.parentBookId as string,
);
return JSON.stringify({
targetBookId: args.targetBookId,
parentBookId: args.parentBookId,
output: "story/parent_canon.md",
canonPreview: canon.slice(0, 500),
});
}
case "import_chapters": {
const { splitChapters } = await import("../utils/chapter-splitter.js");
const chapters = splitChapters(
args.text as string,
args.splitPattern as string | undefined,
);
if (chapters.length === 0) {
return JSON.stringify({ error: "No chapters found. Check text format or provide a splitPattern." });
}
// Guard: import_chapters is a whole-book reimport, not a single-chapter patch
if (chapters.length === 1) {
return JSON.stringify({ error: "import_chapters 是整书重导工具,需要至少 2 个章节。如果只想补一章,请用 write_draft 续写或 revise_chapter 修订。" });
}
const result = await pipeline.importChapters({
bookId: args.bookId as string,
chapters: [...chapters],
});
return JSON.stringify(result);
}
case "write_truth_file": {
const bookId = args.bookId as string;
const fileName = args.fileName as string;
const content = args.content as string;
// Whitelist allowed truth files.
//
// Hotfix: story_bible.md and book_rules.md are back in the whitelist —
// they are authoritative for pre-Phase-5 books. For new-layout books
// (outline/story_frame.md exists) they're compat shims and writes are
// blocked below.
const LEGACY_SHIM_FILES = new Set(["story_bible.md", "book_rules.md"]);
const ALLOWED_FLAT_FILES = [
"story_bible.md", "book_rules.md",
"current_state.md", "particle_ledger.md", "pending_hooks.md",
"chapter_summaries.md", "subplot_board.md", "emotional_arcs.md",
"character_matrix.md", "style_guide.md",
];
// outline/节奏原则.md (zh) / outline/rhythm_principles.md (en) are
// optional after Phase 5 consolidation — rhythm principles normally live
// in the last paragraph of volume_map and writeFoundationFiles skips the
// dedicated file when the block is empty. They remain whitelisted so
// legacy books and manual overrides keep working.
const ALLOWED_OUTLINE_FILES = [
"outline/story_frame.md", "outline/volume_map.md",
"outline/节奏原则.md", "outline/rhythm_principles.md",
];
// Phase hotfix 3: accept both locale dirs so English-layout books can
// be edited via write_truth_file. The reader (utils/outline-paths.ts)
// and Studio (server.ts) accept both — the agent whitelist must match.
const ROLE_PATH_PATTERN = /^roles\/(主要角色|次要角色|major|minor)\/[^/]+\.md$/;
const isAllowed =
ALLOWED_FLAT_FILES.includes(fileName)
|| ALLOWED_OUTLINE_FILES.includes(fileName)
|| ROLE_PATH_PATTERN.test(fileName);
if (!isAllowed) {
const allowedExamples = [
...ALLOWED_FLAT_FILES,
...ALLOWED_OUTLINE_FILES,
"roles/主要角色/<name>.md",
"roles/次要角色/<name>.md",
"roles/major/<name>.md",
"roles/minor/<name>.md",
];
return JSON.stringify({
error:
`不允许修改文件 "${fileName}"。允许的文件:${allowedExamples.join(", ")}`,
});
}
// For new-layout books, story_bible.md / book_rules.md are shims —
// block writes so the agent edits outline/story_frame.md instead.
if (LEGACY_SHIM_FILES.has(fileName)) {
const { isNewLayoutBook } = await import("../utils/outline-paths.js");
const bookDirForCheck = new (await import("../state/manager.js")).StateManager(config.projectRoot).bookDir(bookId);
if (await isNewLayoutBook(bookDirForCheck)) {
return JSON.stringify({
error: `"${fileName}" 是兼容指针(新布局书籍),请改写 outline/story_frame.md。`,
});
}
}
// Path traversal guard — the whitelist already forbids `..`, but we
// re-assert at the write site so this cannot regress.
if (fileName.includes("..") || fileName.startsWith("/") || fileName.includes("\0")) {
return JSON.stringify({ error: `不安全的文件路径:"${fileName}"` });
}
// Guard: block chapter progress manipulation via current_state.md
if (fileName === "current_state.md" && containsProgressManipulation(content)) {
return JSON.stringify({ error: "不允许通过 write_truth_file 修改 current_state.md 中的章节进度。章节进度由系统自动管理。" });
}
const { writeFile, mkdir } = await import("node:fs/promises");
const { join, dirname } = await import("node:path");
const bookDir = new (await import("../state/manager.js")).StateManager(config.projectRoot).bookDir(bookId);
const storyDir = join(bookDir, "story");
const targetPath = join(storyDir, fileName);
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, content, "utf-8");
return JSON.stringify({
bookId,
file: `story/${fileName}`,
written: true,
size: content.length,
});
}
default:
return JSON.stringify({ error: `Unknown tool: ${name}` });
}
}
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);
}
async function getSequentialWriteGuardError(
state: import("../state/manager.js").StateManager,
bookId: string,
toolName: "write_draft" | "write_full_pipeline",
): Promise<string | null> {
const nextNum = await state.getNextChapterNumber(bookId);
const index = await state.loadChapterIndex(bookId);
if (index.length === 0) return null;
const lastIndexedChapter = index[index.length - 1]!.number;
if (lastIndexedChapter === nextNum - 1) return null;
return `${toolName} 只能续写下一章(当前应写第${nextNum}章)。检测到章节索引与运行时进度不一致,请先用 get_book_status 确认状态。`;
}
function containsProgressManipulation(content: string): boolean {
const patterns = [
/\blastAppliedChapter\b/i,
/\|\s*Current Chapter\s*\|\s*\d+\s*\|/i,
/\|\s*当前章(?:节)?\s*\|\s*\d+\s*\|/,
/\bCurrent Chapter\b\s*[:]\s*\d+/i,
/当前章(?:节)?\s*[:]\s*\d+/,
/\bprogress\b\s*[:]\s*\d+/i,
/进度\s*[:]\s*\d+/,
];
return patterns.some((pattern) => pattern.test(content));
}
/** Export tool definitions so external systems can reference them. */
export { TOOLS as AGENT_TOOLS };
-3
View File
@@ -17,7 +17,6 @@ const createLLMClientMock = vi.fn(() => ({}));
const chatCompletionMock = vi.fn();
const loadProjectConfigMock = vi.fn();
const pipelineConfigs: unknown[] = [];
const processProjectInteractionInputMock = vi.fn();
const processProjectInteractionRequestMock = vi.fn();
const createInteractionToolsFromDepsMock = vi.fn(() => ({}));
const loadProjectSessionMock = vi.fn();
@@ -221,7 +220,6 @@ vi.mock("@actalk/inkos-core", async (importOriginal) => {
inferLanguage: actual.inferLanguage,
chatCompletion: chatCompletionMock,
loadProjectConfig: loadProjectConfigMock,
processProjectInteractionInput: processProjectInteractionInputMock,
processProjectInteractionRequest: processProjectInteractionRequestMock,
createInteractionToolsFromDeps: createInteractionToolsFromDepsMock,
loadProjectSession: loadProjectSessionMock,
@@ -362,7 +360,6 @@ describe("createStudioServer daemon lifecycle", () => {
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
});
loadProjectConfigMock.mockReset();
processProjectInteractionInputMock.mockReset();
processProjectInteractionRequestMock.mockReset();
createInteractionToolsFromDepsMock.mockReset();
loadProjectSessionMock.mockReset();