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

feat: add Anthropic SDK native support alongside OpenAI

- provider.ts: LLMClient abstraction wrapping both OpenAI and Anthropic SDKs
- chatCompletion() dispatches based on config.provider
- chatWithTools() unified tool-calling for agent loop (both SDKs)
- Anthropic streaming: text + tool_use blocks, message format conversion
- Auto-strip /v1 from Anthropic baseUrl to prevent double-pathing
majx_mac 8 месяцев назад
Родитель
Сommit
1f03c3eff4

+ 2 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@actalk/inkos-core",
   "name": "@actalk/inkos-core",
-  "version": "0.3.0",
+  "version": "0.3.1",
   "description": "InkOS core: models, agents, pipeline, LLM integration",
   "description": "InkOS core: models, agents, pipeline, LLM integration",
   "type": "module",
   "type": "module",
   "main": "dist/index.js",
   "main": "dist/index.js",
@@ -28,6 +28,7 @@
     "typecheck": "tsc --noEmit"
     "typecheck": "tsc --noEmit"
   },
   },
   "dependencies": {
   "dependencies": {
+    "@anthropic-ai/sdk": "^0.78.0",
     "js-yaml": "^4.1.1",
     "js-yaml": "^4.1.1",
     "openai": "^4.80.0",
     "openai": "^4.80.0",
     "zod": "^3.24.0"
     "zod": "^3.24.0"

+ 2 - 3
packages/core/src/agents/base.ts

@@ -1,9 +1,8 @@
-import type OpenAI from "openai";
-import type { LLMMessage, LLMResponse } from "../llm/provider.js";
+import type { LLMClient, LLMMessage, LLMResponse } from "../llm/provider.js";
 import { chatCompletion } from "../llm/provider.js";
 import { chatCompletion } from "../llm/provider.js";
 
 
 export interface AgentContext {
 export interface AgentContext {
-  readonly client: OpenAI;
+  readonly client: LLMClient;
   readonly model: string;
   readonly model: string;
   readonly projectRoot: string;
   readonly projectRoot: string;
   readonly bookId?: string;
   readonly bookId?: string;

+ 2 - 2
packages/core/src/index.ts

@@ -7,7 +7,7 @@ export { type GenreProfile, type ParsedGenreProfile, GenreProfileSchema, parseGe
 export { type BookRules, type ParsedBookRules, BookRulesSchema, parseBookRules } from "./models/book-rules.js";
 export { type BookRules, type ParsedBookRules, BookRulesSchema, parseBookRules } from "./models/book-rules.js";
 
 
 // LLM
 // LLM
-export { createLLMClient, chatCompletion, type LLMResponse, type LLMMessage } from "./llm/provider.js";
+export { createLLMClient, chatCompletion, chatWithTools, type LLMClient, type LLMResponse, type LLMMessage, type ToolDefinition, type ToolCall, type AgentMessage, type ChatWithToolsResult } from "./llm/provider.js";
 
 
 // Agents
 // Agents
 export { BaseAgent, type AgentContext } from "./agents/base.js";
 export { BaseAgent, type AgentContext } from "./agents/base.js";
@@ -23,7 +23,7 @@ export { buildWriterSystemPrompt } from "./agents/writer-prompts.js";
 // Pipeline
 // Pipeline
 export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type ReviseResult, type TruthFiles, type BookStatusInfo } from "./pipeline/runner.js";
 export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type ReviseResult, type TruthFiles, type BookStatusInfo } from "./pipeline/runner.js";
 export { Scheduler, type SchedulerConfig } from "./pipeline/scheduler.js";
 export { Scheduler, type SchedulerConfig } from "./pipeline/scheduler.js";
-export { runAgentLoop, AGENT_TOOLS, type AgentLoopOptions } from "./pipeline/agent.js";
+export { runAgentLoop, AGENT_TOOLS as AGENT_TOOLS, type AgentLoopOptions } from "./pipeline/agent.js";
 
 
 // State
 // State
 export { StateManager } from "./state/manager.js";
 export { StateManager } from "./state/manager.js";

+ 325 - 13
packages/core/src/llm/provider.ts

@@ -1,6 +1,9 @@
 import OpenAI from "openai";
 import OpenAI from "openai";
+import Anthropic from "@anthropic-ai/sdk";
 import type { LLMConfig } from "../models/project.js";
 import type { LLMConfig } from "../models/project.js";
 
 
+// === Shared Types ===
+
 export interface LLMResponse {
 export interface LLMResponse {
   readonly content: string;
   readonly content: string;
   readonly usage: {
   readonly usage: {
@@ -15,15 +18,59 @@ export interface LLMMessage {
   readonly content: string;
   readonly content: string;
 }
 }
 
 
-export function createLLMClient(config: LLMConfig): OpenAI {
-  return new OpenAI({
-    apiKey: config.apiKey,
-    baseURL: config.baseUrl,
-  });
+export interface LLMClient {
+  readonly provider: "openai" | "anthropic";
+  readonly _openai?: OpenAI;
+  readonly _anthropic?: Anthropic;
+}
+
+// === 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 {
+  if (config.provider === "anthropic") {
+    // Anthropic SDK appends /v1/ internally — strip if user included it
+    const baseURL = config.baseUrl.replace(/\/v1\/?$/, "");
+    return {
+      provider: "anthropic",
+      _anthropic: new Anthropic({ apiKey: config.apiKey, baseURL }),
+    };
+  }
+  // openai or custom — both use OpenAI SDK
+  return {
+    provider: "openai",
+    _openai: new OpenAI({ apiKey: config.apiKey, baseURL: config.baseUrl }),
+  };
 }
 }
 
 
+// === Simple Chat (used by all agents via BaseAgent.chat()) ===
+
 export async function chatCompletion(
 export async function chatCompletion(
-  client: OpenAI,
+  client: LLMClient,
   model: string,
   model: string,
   messages: ReadonlyArray<LLMMessage>,
   messages: ReadonlyArray<LLMMessage>,
   options?: {
   options?: {
@@ -31,7 +78,38 @@ export async function chatCompletion(
     readonly maxTokens?: number;
     readonly maxTokens?: number;
   },
   },
 ): Promise<LLMResponse> {
 ): Promise<LLMResponse> {
-  // Use streaming mode — some providers (codex-for.me) require it
+  if (client.provider === "anthropic") {
+    return chatCompletionAnthropic(client._anthropic!, model, messages, options);
+  }
+  return chatCompletionOpenAI(client._openai!, model, messages, options);
+}
+
+// === 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> {
+  if (client.provider === "anthropic") {
+    return chatWithToolsAnthropic(client._anthropic!, model, messages, tools, options);
+  }
+  return chatWithToolsOpenAI(client._openai!, model, messages, tools, options);
+}
+
+// === OpenAI Implementation ===
+
+async function chatCompletionOpenAI(
+  client: OpenAI,
+  model: string,
+  messages: ReadonlyArray<LLMMessage>,
+  options?: { readonly temperature?: number; readonly maxTokens?: number },
+): Promise<LLMResponse> {
   const stream = await client.chat.completions.create({
   const stream = await client.chat.completions.create({
     model,
     model,
     messages: messages.map((m) => ({ role: m.role, content: m.content })),
     messages: messages.map((m) => ({ role: m.role, content: m.content })),
@@ -47,9 +125,7 @@ export async function chatCompletion(
 
 
   for await (const chunk of stream) {
   for await (const chunk of stream) {
     const delta = chunk.choices[0]?.delta?.content;
     const delta = chunk.choices[0]?.delta?.content;
-    if (delta) {
-      chunks.push(delta);
-    }
+    if (delta) chunks.push(delta);
     if (chunk.usage) {
     if (chunk.usage) {
       promptTokens = chunk.usage.prompt_tokens ?? 0;
       promptTokens = chunk.usage.prompt_tokens ?? 0;
       completionTokens = chunk.usage.completion_tokens ?? 0;
       completionTokens = chunk.usage.completion_tokens ?? 0;
@@ -58,12 +134,248 @@ export async function chatCompletion(
   }
   }
 
 
   const content = chunks.join("");
   const content = chunks.join("");
-  if (!content) {
-    throw new Error("LLM returned empty response");
+  if (!content) throw new Error("LLM returned empty response");
+
+  return { content, usage: { promptTokens, completionTokens, totalTokens } };
+}
+
+async function chatWithToolsOpenAI(
+  client: OpenAI,
+  model: string,
+  messages: ReadonlyArray<AgentMessage>,
+  tools: ReadonlyArray<ToolDefinition>,
+  options?: { readonly temperature?: number; readonly maxTokens?: number },
+): Promise<ChatWithToolsResult> {
+  const openaiMessages = messages.map(agentMessageToOpenAI);
+  const openaiTools = tools.map((t) => ({
+    type: "function" as const,
+    function: { name: t.name, description: t.description, parameters: t.parameters },
+  }));
+
+  const stream = await client.chat.completions.create({
+    model,
+    messages: openaiMessages,
+    tools: openaiTools,
+    tool_choice: "auto",
+    stream: true,
+  });
+
+  let content = "";
+  const toolCallMap = new Map<number, { id: string; name: string; arguments: string }>();
+
+  for await (const chunk of stream) {
+    const delta = chunk.choices[0]?.delta;
+    if (!delta) continue;
+    if (delta.content) content += delta.content;
+    if (delta.tool_calls) {
+      for (const tc of delta.tool_calls) {
+        const existing = toolCallMap.get(tc.index);
+        if (existing) {
+          if (tc.function?.arguments) existing.arguments += tc.function.arguments;
+        } else {
+          toolCallMap.set(tc.index, {
+            id: tc.id ?? "",
+            name: tc.function?.name ?? "",
+            arguments: tc.function?.arguments ?? "",
+          });
+        }
+      }
+    }
+  }
+
+  const toolCalls = [...toolCallMap.entries()]
+    .sort(([a], [b]) => a - b)
+    .map(([, tc]) => ({ id: tc.id, name: tc.name, arguments: tc.arguments }));
+
+  return { content, toolCalls };
+}
+
+function agentMessageToOpenAI(msg: AgentMessage): OpenAI.Chat.Completions.ChatCompletionMessageParam {
+  if (msg.role === "system") return { role: "system", content: msg.content };
+  if (msg.role === "user") return { role: "user", content: msg.content };
+  if (msg.role === "tool") return { role: "tool", content: msg.content, tool_call_id: msg.toolCallId };
+  // assistant
+  if (msg.toolCalls && msg.toolCalls.length > 0) {
+    return {
+      role: "assistant",
+      content: msg.content,
+      tool_calls: msg.toolCalls.map((tc) => ({
+        id: tc.id,
+        type: "function" as const,
+        function: { name: tc.name, arguments: tc.arguments },
+      })),
+    };
+  }
+  return { role: "assistant", content: msg.content };
+}
+
+// === Anthropic Implementation ===
+
+async function chatCompletionAnthropic(
+  client: Anthropic,
+  model: string,
+  messages: ReadonlyArray<LLMMessage>,
+  options?: { readonly temperature?: number; readonly maxTokens?: number },
+): Promise<LLMResponse> {
+  const systemText = messages
+    .filter((m) => m.role === "system")
+    .map((m) => m.content)
+    .join("\n\n");
+  const nonSystem = messages.filter((m) => m.role !== "system");
+
+  const stream = await client.messages.create({
+    model,
+    ...(systemText ? { system: systemText } : {}),
+    messages: nonSystem.map((m) => ({
+      role: m.role as "user" | "assistant",
+      content: m.content,
+    })),
+    temperature: options?.temperature ?? 0.7,
+    max_tokens: options?.maxTokens ?? 8192,
+    stream: true,
+  });
+
+  const chunks: string[] = [];
+  let inputTokens = 0;
+  let outputTokens = 0;
+
+  for await (const event of stream) {
+    if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
+      chunks.push(event.delta.text);
+    }
+    if (event.type === "message_start") {
+      inputTokens = event.message.usage?.input_tokens ?? 0;
+    }
+    if (event.type === "message_delta") {
+      outputTokens = ((event as unknown as { usage?: { output_tokens?: number } }).usage?.output_tokens) ?? 0;
+    }
   }
   }
 
 
+  const content = chunks.join("");
+  if (!content) throw new Error("LLM returned empty response");
+
   return {
   return {
     content,
     content,
-    usage: { promptTokens, completionTokens, totalTokens },
+    usage: {
+      promptTokens: inputTokens,
+      completionTokens: outputTokens,
+      totalTokens: inputTokens + outputTokens,
+    },
   };
   };
 }
 }
+
+async function chatWithToolsAnthropic(
+  client: Anthropic,
+  model: string,
+  messages: ReadonlyArray<AgentMessage>,
+  tools: ReadonlyArray<ToolDefinition>,
+  options?: { readonly temperature?: number; readonly maxTokens?: number },
+): Promise<ChatWithToolsResult> {
+  const systemText = messages
+    .filter((m) => m.role === "system")
+    .map((m) => (m as { content: string }).content)
+    .join("\n\n");
+  const nonSystem = messages.filter((m) => m.role !== "system");
+
+  const anthropicMessages = agentMessagesToAnthropic(nonSystem);
+  const anthropicTools = tools.map((t) => ({
+    name: t.name,
+    description: t.description,
+    input_schema: t.parameters as Anthropic.Messages.Tool.InputSchema,
+  }));
+
+  const stream = await client.messages.create({
+    model,
+    ...(systemText ? { system: systemText } : {}),
+    messages: anthropicMessages,
+    tools: anthropicTools,
+    max_tokens: options?.maxTokens ?? 8192,
+    stream: true,
+  });
+
+  let content = "";
+  const toolCalls: ToolCall[] = [];
+  let currentBlock: { id: string; name: string; input: string } | null = null;
+
+  for await (const event of stream) {
+    if (event.type === "content_block_start" && event.content_block.type === "tool_use") {
+      currentBlock = {
+        id: event.content_block.id,
+        name: event.content_block.name,
+        input: "",
+      };
+    }
+    if (event.type === "content_block_delta") {
+      if (event.delta.type === "text_delta") {
+        content += event.delta.text;
+      }
+      if (event.delta.type === "input_json_delta" && currentBlock) {
+        currentBlock.input += event.delta.partial_json;
+      }
+    }
+    if (event.type === "content_block_stop" && currentBlock) {
+      toolCalls.push({
+        id: currentBlock.id,
+        name: currentBlock.name,
+        arguments: currentBlock.input,
+      });
+      currentBlock = null;
+    }
+  }
+
+  return { content, toolCalls };
+}
+
+function agentMessagesToAnthropic(
+  messages: ReadonlyArray<AgentMessage>,
+): Anthropic.Messages.MessageParam[] {
+  const result: Anthropic.Messages.MessageParam[] = [];
+
+  for (const msg of messages) {
+    if (msg.role === "system") continue;
+
+    if (msg.role === "user") {
+      result.push({ role: "user", content: msg.content });
+      continue;
+    }
+
+    if (msg.role === "assistant") {
+      const blocks: Anthropic.Messages.ContentBlockParam[] = [];
+      if (msg.content) {
+        blocks.push({ type: "text", text: msg.content });
+      }
+      if (msg.toolCalls) {
+        for (const tc of msg.toolCalls) {
+          blocks.push({
+            type: "tool_use",
+            id: tc.id,
+            name: tc.name,
+            input: JSON.parse(tc.arguments),
+          });
+        }
+      }
+      if (blocks.length === 0) {
+        blocks.push({ type: "text", text: "" });
+      }
+      result.push({ role: "assistant", content: blocks });
+      continue;
+    }
+
+    if (msg.role === "tool") {
+      const toolResult: Anthropic.Messages.ToolResultBlockParam = {
+        type: "tool_result",
+        tool_use_id: msg.toolCallId,
+        content: msg.content,
+      };
+      // Merge consecutive tool results into one user message (Anthropic requires alternating roles)
+      const prev = result[result.length - 1];
+      if (prev && prev.role === "user" && Array.isArray(prev.content)) {
+        (prev.content as Anthropic.Messages.ToolResultBlockParam[]).push(toolResult);
+      } else {
+        result.push({ role: "user", content: [toolResult] });
+      }
+    }
+  }
+
+  return result;
+}

+ 91 - 176
packages/core/src/pipeline/agent.ts

@@ -1,136 +1,109 @@
-import type OpenAI from "openai";
+import { chatWithTools, type AgentMessage, type ToolDefinition } from "../llm/provider.js";
 import { PipelineRunner, type PipelineConfig } from "./runner.js";
 import { PipelineRunner, type PipelineConfig } from "./runner.js";
 import type { Platform, Genre } from "../models/book.js";
 import type { Platform, Genre } from "../models/book.js";
 import type { ReviseMode } from "../agents/reviser.js";
 import type { ReviseMode } from "../agents/reviser.js";
 
 
-/** Tool definitions for the agent loop (OpenAI function calling format). */
-const TOOLS: OpenAI.Chat.Completions.ChatCompletionTool[] = [
+/** Tool definitions for the agent loop. */
+const TOOLS: ReadonlyArray<ToolDefinition> = [
   {
   {
-    type: "function",
-    function: {
-      name: "write_draft",
-      description: "写一章草稿。生成正文、更新状态卡/账本/伏笔池、保存章节文件。",
-      parameters: {
-        type: "object",
-        properties: {
-          bookId: { type: "string", description: "书籍ID" },
-          guidance: { type: "string", description: "本章创作指导(可选,自然语言)" },
-        },
-        required: ["bookId"],
+    name: "write_draft",
+    description: "写一章草稿。生成正文、更新状态卡/账本/伏笔池、保存章节文件。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "书籍ID" },
+        guidance: { type: "string", description: "本章创作指导(可选,自然语言)" },
       },
       },
+      required: ["bookId"],
     },
     },
   },
   },
   {
   {
-    type: "function",
-    function: {
-      name: "audit_chapter",
-      description: "审计指定章节。检查连续性、OOC、数值、伏笔等问题。",
-      parameters: {
-        type: "object",
-        properties: {
-          bookId: { type: "string", description: "书籍ID" },
-          chapterNumber: { type: "number", description: "章节号(不填则审计最新章)" },
-        },
-        required: ["bookId"],
+    name: "audit_chapter",
+    description: "审计指定章节。检查连续性、OOC、数值、伏笔等问题。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "书籍ID" },
+        chapterNumber: { type: "number", description: "章节号(不填则审计最新章)" },
       },
       },
+      required: ["bookId"],
     },
     },
   },
   },
   {
   {
-    type: "function",
-    function: {
-      name: "revise_chapter",
-      description: "修订指定章节。根据审计问题修正。支持三种模式:polish(润色)、rewrite(改写)、rework(重写)。",
-      parameters: {
-        type: "object",
-        properties: {
-          bookId: { type: "string", description: "书籍ID" },
-          chapterNumber: { type: "number", description: "章节号(不填则修订最新章)" },
-          mode: { type: "string", enum: ["polish", "rewrite", "rework"], description: "修订模式(默认rewrite)" },
-        },
-        required: ["bookId"],
+    name: "revise_chapter",
+    description: "修订指定章节。根据审计问题修正。支持三种模式:polish(润色)、rewrite(改写)、rework(重写)。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "书籍ID" },
+        chapterNumber: { type: "number", description: "章节号(不填则修订最新章)" },
+        mode: { type: "string", enum: ["polish", "rewrite", "rework"], description: "修订模式(默认rewrite)" },
       },
       },
+      required: ["bookId"],
     },
     },
   },
   },
   {
   {
-    type: "function",
-    function: {
-      name: "scan_market",
-      description: "扫描市场趋势。从平台排行榜获取实时数据并分析。",
-      parameters: {
-        type: "object",
-        properties: {},
-      },
+    name: "scan_market",
+    description: "扫描市场趋势。从平台排行榜获取实时数据并分析。",
+    parameters: {
+      type: "object",
+      properties: {},
     },
     },
   },
   },
   {
   {
-    type: "function",
-    function: {
-      name: "create_book",
-      description: "创建一本新书。生成世界观、卷纲、文风指南等基础设定。",
-      parameters: {
-        type: "object",
-        properties: {
-          title: { type: "string", description: "书名" },
-          genre: { type: "string", enum: ["xuanhuan", "xianxia", "urban", "game", "fanfic", "horror", "short", "other"], description: "题材" },
-          platform: { type: "string", enum: ["tomato", "feilu", "qidian", "other"], description: "目标平台" },
-          brief: { type: "string", description: "创作简述/需求(自然语言)" },
-        },
-        required: ["title", "genre", "platform"],
+    name: "create_book",
+    description: "创建一本新书。生成世界观、卷纲、文风指南等基础设定。",
+    parameters: {
+      type: "object",
+      properties: {
+        title: { type: "string", description: "书名" },
+        genre: { type: "string", enum: ["xuanhuan", "xianxia", "urban", "game", "fanfic", "horror", "short", "other"], description: "题材" },
+        platform: { type: "string", enum: ["tomato", "feilu", "qidian", "other"], description: "目标平台" },
+        brief: { type: "string", description: "创作简述/需求(自然语言)" },
       },
       },
+      required: ["title", "genre", "platform"],
     },
     },
   },
   },
   {
   {
-    type: "function",
-    function: {
-      name: "get_book_status",
-      description: "获取书籍状态概览:章数、字数、最近章节审计情况。",
-      parameters: {
-        type: "object",
-        properties: {
-          bookId: { type: "string", description: "书籍ID" },
-        },
-        required: ["bookId"],
+    name: "get_book_status",
+    description: "获取书籍状态概览:章数、字数、最近章节审计情况。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "书籍ID" },
       },
       },
+      required: ["bookId"],
     },
     },
   },
   },
   {
   {
-    type: "function",
-    function: {
-      name: "read_truth_files",
-      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"],
     },
     },
   },
   },
   {
   {
-    type: "function",
-    function: {
-      name: "list_books",
-      description: "列出所有书籍。",
-      parameters: {
-        type: "object",
-        properties: {},
-      },
+    name: "list_books",
+    description: "列出所有书籍。",
+    parameters: {
+      type: "object",
+      properties: {},
     },
     },
   },
   },
   {
   {
-    type: "function",
-    function: {
-      name: "write_full_pipeline",
-      description: "完整管线:写草稿 → 审计 → 自动修订(如需要)。一键完成。",
-      parameters: {
-        type: "object",
-        properties: {
-          bookId: { type: "string", description: "书籍ID" },
-          count: { type: "number", description: "连续写几章(默认1)" },
-        },
-        required: ["bookId"],
+    name: "write_full_pipeline",
+    description: "完整管线:写草稿 → 审计 → 自动修订(如需要)。一键完成。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "书籍ID" },
+        count: { type: "number", description: "连续写几章(默认1)" },
       },
       },
+      required: ["bookId"],
     },
     },
   },
   },
 ];
 ];
@@ -151,7 +124,7 @@ export async function runAgentLoop(
   const { StateManager } = await import("../state/manager.js");
   const { StateManager } = await import("../state/manager.js");
   const state = new StateManager(config.projectRoot);
   const state = new StateManager(config.projectRoot);
 
 
-  const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
+  const messages: AgentMessage[] = [
     {
     {
       role: "system",
       role: "system",
       content: `你是 InkOS 小说写作 Agent。用户是小说作者,你帮他管理从建书到成稿的全过程。
       content: `你是 InkOS 小说写作 Agent。用户是小说作者,你帮他管理从建书到成稿的全过程。
@@ -165,7 +138,7 @@ export async function runAgentLoop(
 | read_truth_files | 读取长期记忆(状态卡、资源账本、伏笔池)和设定(世界观、卷纲、本书规则) |
 | read_truth_files | 读取长期记忆(状态卡、资源账本、伏笔池)和设定(世界观、卷纲、本书规则) |
 | create_book | 建书,生成世界观、卷纲、本书规则(自动加载题材 genre profile) |
 | create_book | 建书,生成世界观、卷纲、本书规则(自动加载题材 genre profile) |
 | write_draft | 写一章草稿(自动加载 genre profile + book_rules) |
 | write_draft | 写一章草稿(自动加载 genre profile + book_rules) |
-| audit_chapter | 审计章节(18维度,按题材条件启用) |
+| audit_chapter | 审计章节(19维度,按题材条件启用) |
 | revise_chapter | 修订章节(支持 polish/rewrite/rework 三种模式) |
 | revise_chapter | 修订章节(支持 polish/rewrite/rework 三种模式) |
 | write_full_pipeline | 完整管线:写 → 审 → 改(如需要) |
 | write_full_pipeline | 完整管线:写 → 审 → 改(如需要) |
 | scan_market | 扫描平台排行榜,分析市场趋势 |
 | scan_market | 扫描平台排行榜,分析市场趋势 |
@@ -196,94 +169,37 @@ export async function runAgentLoop(
   let lastAssistantMessage = "";
   let lastAssistantMessage = "";
 
 
   for (let turn = 0; turn < maxTurns; turn++) {
   for (let turn = 0; turn < maxTurns; turn++) {
-    // Use streaming (some providers like codex-for.me require it)
-    const stream = await config.client.chat.completions.create({
-      model: config.model,
-      messages,
-      tools: TOOLS,
-      tool_choice: "auto",
-      stream: true,
-    });
-
-    // Accumulate streamed response into a complete message
-    let content = "";
-    const toolCalls: Map<number, { id: string; name: string; arguments: string }> = new Map();
-
-    for await (const chunk of stream) {
-      const delta = chunk.choices[0]?.delta;
-      if (!delta) continue;
-
-      if (delta.content) {
-        content += delta.content;
-      }
-
-      if (delta.tool_calls) {
-        for (const tc of delta.tool_calls) {
-          const existing = toolCalls.get(tc.index);
-          if (existing) {
-            if (tc.function?.arguments) {
-              existing.arguments += tc.function.arguments;
-            }
-          } else {
-            toolCalls.set(tc.index, {
-              id: tc.id ?? "",
-              name: tc.function?.name ?? "",
-              arguments: tc.function?.arguments ?? "",
-            });
-          }
-        }
-      }
-    }
-
-    // Build the assistant message for history
-    const assembledToolCalls = toolCalls.size > 0
-      ? [...toolCalls.entries()]
-          .sort(([a], [b]) => a - b)
-          .map(([, tc]) => ({
-            id: tc.id,
-            type: "function" as const,
-            function: { name: tc.name, arguments: tc.arguments },
-          }))
-      : undefined;
+    const result = await chatWithTools(config.client, config.model, messages, TOOLS);
 
 
+    // Push assistant message to history
     messages.push({
     messages.push({
       role: "assistant" as const,
       role: "assistant" as const,
-      content: content || null,
-      ...(assembledToolCalls ? { tool_calls: assembledToolCalls } : {}),
-    } as OpenAI.Chat.Completions.ChatCompletionMessageParam);
-
-    // If model produced text, emit it
-    if (content) {
-      lastAssistantMessage = content;
-      options?.onMessage?.(content);
+      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 no tool calls, we're done
-    if (!assembledToolCalls || assembledToolCalls.length === 0) {
-      break;
-    }
+    if (result.toolCalls.length === 0) break;
 
 
     // Execute tool calls
     // Execute tool calls
-    for (const toolCall of assembledToolCalls) {
-      const fn = toolCall.function;
-      const args = JSON.parse(fn.arguments) as Record<string, unknown>;
+    for (const toolCall of result.toolCalls) {
+      const args = JSON.parse(toolCall.arguments) as Record<string, unknown>;
+      options?.onToolCall?.(toolCall.name, args);
 
 
-      options?.onToolCall?.(fn.name, args);
-
-      let result: string;
+      let toolResult: string;
       try {
       try {
-        result = await executeTool(pipeline, state, config, fn.name, args);
+        toolResult = await executeTool(pipeline, state, config, toolCall.name, args);
       } catch (e) {
       } catch (e) {
-        result = JSON.stringify({ error: String(e) });
+        toolResult = JSON.stringify({ error: String(e) });
       }
       }
 
 
-      options?.onToolResult?.(fn.name, result);
-
-      messages.push({
-        role: "tool" as const,
-        content: result,
-        tool_call_id: toolCall.id,
-      });
+      options?.onToolResult?.(toolCall.name, toolResult);
+      messages.push({ role: "tool" as const, toolCallId: toolCall.id, content: toolResult });
     }
     }
   }
   }
 
 
@@ -349,7 +265,6 @@ async function executeTool(
         updatedAt: now,
         updatedAt: now,
       };
       };
 
 
-      // If user provided a brief, create a pipeline with that context
       const brief = args.brief as string | undefined;
       const brief = args.brief as string | undefined;
       if (brief) {
       if (brief) {
         const contextPipeline = new PipelineRunner({ ...config, externalContext: brief });
         const contextPipeline = new PipelineRunner({ ...config, externalContext: brief });
@@ -400,5 +315,5 @@ async function executeTool(
   }
   }
 }
 }
 
 
-/** Export tool definitions so OpenClaw or other systems can reference them. */
+/** Export tool definitions so external systems can reference them. */
 export { TOOLS as AGENT_TOOLS };
 export { TOOLS as AGENT_TOOLS };

+ 2 - 2
packages/core/src/pipeline/runner.ts

@@ -1,4 +1,4 @@
-import type OpenAI from "openai";
+import type { LLMClient } from "../llm/provider.js";
 import type { BookConfig } from "../models/book.js";
 import type { BookConfig } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
 import type { ChapterMeta } from "../models/chapter.js";
 import type { NotifyChannel } from "../models/project.js";
 import type { NotifyChannel } from "../models/project.js";
@@ -19,7 +19,7 @@ import { readFile, readdir, writeFile } from "node:fs/promises";
 import { join } from "node:path";
 import { join } from "node:path";
 
 
 export interface PipelineConfig {
 export interface PipelineConfig {
-  readonly client: OpenAI;
+  readonly client: LLMClient;
   readonly model: string;
   readonly model: string;
   readonly projectRoot: string;
   readonly projectRoot: string;
   readonly notifyChannels?: ReadonlyArray<NotifyChannel>;
   readonly notifyChannels?: ReadonlyArray<NotifyChannel>;

+ 38 - 0
pnpm-lock.yaml

@@ -32,6 +32,9 @@ importers:
 
 
   packages/core:
   packages/core:
     dependencies:
     dependencies:
+      '@anthropic-ai/sdk':
+        specifier: ^0.78.0
+        version: 0.78.0(zod@3.25.76)
       js-yaml:
       js-yaml:
         specifier: ^4.1.1
         specifier: ^4.1.1
         version: 4.1.1
         version: 4.1.1
@@ -54,6 +57,19 @@ importers:
 
 
 packages:
 packages:
 
 
+  '@anthropic-ai/sdk@0.78.0':
+    resolution: {integrity: sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==}
+    hasBin: true
+    peerDependencies:
+      zod: ^3.25.0 || ^4.0.0
+    peerDependenciesMeta:
+      zod:
+        optional: true
+
+  '@babel/runtime@7.28.6':
+    resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
+    engines: {node: '>=6.9.0'}
+
   '@esbuild/aix-ppc64@0.27.3':
   '@esbuild/aix-ppc64@0.27.3':
     resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
     resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
     engines: {node: '>=18'}
     engines: {node: '>=18'}
@@ -565,6 +581,10 @@ packages:
     resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
     resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
     hasBin: true
     hasBin: true
 
 
+  json-schema-to-ts@3.1.1:
+    resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
+    engines: {node: '>=16'}
+
   loupe@3.2.1:
   loupe@3.2.1:
     resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
     resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
 
 
@@ -681,6 +701,9 @@ packages:
   tr46@0.0.3:
   tr46@0.0.3:
     resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
     resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
 
 
+  ts-algebra@2.0.0:
+    resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
+
   typescript@5.9.3:
   typescript@5.9.3:
     resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
     resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
     engines: {node: '>=14.17'}
     engines: {node: '>=14.17'}
@@ -785,6 +808,14 @@ packages:
 
 
 snapshots:
 snapshots:
 
 
+  '@anthropic-ai/sdk@0.78.0(zod@3.25.76)':
+    dependencies:
+      json-schema-to-ts: 3.1.1
+    optionalDependencies:
+      zod: 3.25.76
+
+  '@babel/runtime@7.28.6': {}
+
   '@esbuild/aix-ppc64@0.27.3':
   '@esbuild/aix-ppc64@0.27.3':
     optional: true
     optional: true
 
 
@@ -1177,6 +1208,11 @@ snapshots:
     dependencies:
     dependencies:
       argparse: 2.0.1
       argparse: 2.0.1
 
 
+  json-schema-to-ts@3.1.1:
+    dependencies:
+      '@babel/runtime': 7.28.6
+      ts-algebra: 2.0.0
+
   loupe@3.2.1: {}
   loupe@3.2.1: {}
 
 
   magic-string@0.30.21:
   magic-string@0.30.21:
@@ -1289,6 +1325,8 @@ snapshots:
 
 
   tr46@0.0.3: {}
   tr46@0.0.3: {}
 
 
+  ts-algebra@2.0.0: {}
+
   typescript@5.9.3: {}
   typescript@5.9.3: {}
 
 
   undici-types@5.26.5: {}
   undici-types@5.26.5: {}