Pārlūkot izejas kodu

fix(agent): sanitize restored tool transcripts

fanghanjun 6 mēneši atpakaļ
vecāks
revīzija
297c93f723

+ 1 - 1
packages/core/src/__tests__/agent-session.test.ts

@@ -269,7 +269,7 @@ describe("runAgentSession cache — bookId switch", () => {
   });
 
   it("把真实 Agent 的 message_end 写入 JSONL,并在 cache 失效后恢复 raw AgentMessage", async () => {
-    const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
+    const model = { provider: "anthropic", id: "fake", api: "anthropic-messages" } as any;
     const pipeline = {} as any;
 
     await runAgentSession(

+ 29 - 0
packages/core/src/__tests__/service-resolver.test.ts

@@ -84,6 +84,35 @@ describe("resolveServiceModel", () => {
     expect(result.temperatureRange).toEqual([0, 2]);
   });
 
+  it("preserves Google OpenAI-compatible overrides on resolved model", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { google: { apiKey: "sk-google" } } }),
+    );
+
+    const result = await resolveServiceModel("google", "gemini-pro-latest", root);
+
+    expect(result.model.api).toBe("openai-completions");
+    expect(result.model.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta/openai");
+    expect(result.model.compat).toMatchObject({
+      supportsStore: false,
+      requiresAssistantAfterToolResult: true,
+    });
+  });
+
+  it("preserves DeepSeek tool-result bridge compatibility on resolved model", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { deepseek: { apiKey: "sk-deep" } } }),
+    );
+
+    const result = await resolveServiceModel("deepseek", "deepseek-v4-pro", root);
+
+    expect(result.model.compat).toMatchObject({ requiresAssistantAfterToolResult: true });
+  });
+
   it("constructs model from preset when getModel returns undefined", async () => {
     await mkdir(join(root, ".inkos"), { recursive: true });
     await writeFile(

+ 269 - 1
packages/core/src/__tests__/session-transcript-restore.test.ts

@@ -4,6 +4,7 @@ import { join } from "node:path";
 import { tmpdir } from "node:os";
 import { appendTranscriptEvent } from "../interaction/session-transcript.js";
 import {
+  adaptRestoredAgentMessagesForModel,
   deriveBookSessionFromTranscript,
   restoreAgentMessagesFromTranscript,
 } from "../interaction/session-transcript-restore.js";
@@ -155,7 +156,7 @@ describe("session transcript restore", () => {
 
     const restored = await restoreAgentMessagesFromTranscript(projectRoot, "s1");
 
-    expect(restored).toHaveLength(2);
+    expect(restored).toHaveLength(3);
     expect(restored[0]).toMatchObject({
       role: "assistant",
       content: [
@@ -164,6 +165,150 @@ describe("session transcript restore", () => {
       ],
     });
     expect(restored[1]).toMatchObject({ role: "toolResult", toolCallId: "tool-1", toolName: "read" });
+    expect(restored[2]).toMatchObject({
+      role: "assistant",
+      content: [{ type: "text", text: "I have processed the tool results." }],
+    });
+  });
+
+  it("恢复中断工具轮次时在 toolResult 后补 assistant bridge", async () => {
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 1,
+      timestamp: 1,
+      input: "tool",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "u1",
+      parentUuid: null,
+      seq: 2,
+      role: "user",
+      timestamp: 2,
+      message: { role: "user", content: "tool", timestamp: 2 },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "a1",
+      parentUuid: "u1",
+      seq: 3,
+      role: "assistant",
+      timestamp: 3,
+      toolCallId: "tool-1",
+      message: {
+        role: "assistant",
+        content: [{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "a.md" } }],
+        api: "openai-completions",
+        provider: "google",
+        model: "gemini-pro-latest",
+        usage,
+        stopReason: "toolUse",
+        timestamp: 3,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "t1",
+      parentUuid: "a1",
+      seq: 4,
+      role: "toolResult",
+      timestamp: 4,
+      toolCallId: "tool-1",
+      sourceToolAssistantUuid: "a1",
+      message: {
+        role: "toolResult",
+        toolCallId: "tool-1",
+        toolName: "read",
+        content: [{ type: "text", text: "资料" }],
+        isError: false,
+        timestamp: 4,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "a2",
+      parentUuid: "t1",
+      seq: 5,
+      role: "assistant",
+      timestamp: 5,
+      message: {
+        role: "assistant",
+        content: [],
+        api: "openai-completions",
+        provider: "google",
+        model: "gemini-pro-latest",
+        usage,
+        stopReason: "error",
+        errorMessage: "400 status code",
+        timestamp: 5,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 6,
+      timestamp: 6,
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r2",
+      seq: 7,
+      timestamp: 7,
+      input: "继续",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r2",
+      uuid: "u2",
+      parentUuid: "a2",
+      seq: 8,
+      role: "user",
+      timestamp: 8,
+      message: { role: "user", content: "继续", timestamp: 8 },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r2",
+      seq: 9,
+      timestamp: 9,
+    });
+
+    const restored = await restoreAgentMessagesFromTranscript(projectRoot, "s1");
+
+    expect(restored.map((message) => message.role)).toEqual([
+      "user",
+      "assistant",
+      "toolResult",
+      "assistant",
+      "user",
+    ]);
+    expect(restored[3]).toMatchObject({
+      role: "assistant",
+      content: [{ type: "text", text: "I have processed the tool results." }],
+    });
   });
 
   it("移除最后 assistant message 的 trailing thinking block", async () => {
@@ -214,6 +359,129 @@ describe("session transcript restore", () => {
     expect((restored[0] as any).content).toEqual([{ type: "text", text: "回答" }]);
   });
 
+  it("跨模型恢复时移除 provider-specific thinking,但保留正文", () => {
+    const messages = [
+      {
+        role: "assistant",
+        content: [
+          { type: "thinking", thinking: "DeepSeek reasoning", thinkingSignature: "reasoning_content" },
+          { type: "text", text: "可见回答" },
+        ],
+        api: "openai-completions",
+        provider: "openai",
+        model: "deepseek-v4-pro",
+        usage,
+        stopReason: "stop",
+        timestamp: 1,
+      },
+    ] as any;
+
+    const adapted = adaptRestoredAgentMessagesForModel(messages, {
+      api: "openai-completions",
+      provider: "openai",
+      id: "gemini-pro-latest",
+    });
+
+    expect((adapted[0] as any).content).toEqual([{ type: "text", text: "可见回答" }]);
+  });
+
+  it("同模型恢复时保留 thinking continuity", () => {
+    const messages = [
+      {
+        role: "assistant",
+        content: [
+          { type: "thinking", thinking: "DeepSeek reasoning", thinkingSignature: "reasoning_content" },
+          { type: "text", text: "可见回答" },
+        ],
+        api: "openai-completions",
+        provider: "openai",
+        model: "deepseek-v4-pro",
+        usage,
+        stopReason: "stop",
+        timestamp: 1,
+      },
+    ] as any;
+
+    const adapted = adaptRestoredAgentMessagesForModel(messages, {
+      api: "openai-completions",
+      provider: "openai",
+      id: "deepseek-v4-pro",
+    });
+
+    expect((adapted[0] as any).content).toEqual(messages[0].content);
+  });
+
+  it("派生 BookSession 时跳过没有正文的 assistant tool-use message", async () => {
+    await appendTranscriptEvent(projectRoot, {
+      type: "session_created",
+      version: 1,
+      sessionId: "s1",
+      seq: 1,
+      timestamp: 1,
+      bookId: null,
+      title: null,
+      createdAt: 1,
+      updatedAt: 1,
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 2,
+      timestamp: 2,
+      input: "你好",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "u1",
+      parentUuid: null,
+      seq: 3,
+      role: "user",
+      timestamp: 3,
+      message: { role: "user", content: "你好", timestamp: 3 },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "a1",
+      parentUuid: "u1",
+      seq: 4,
+      role: "assistant",
+      timestamp: 4,
+      message: {
+        role: "assistant",
+        content: [
+          { type: "thinking", thinking: "内部推理" },
+          { type: "toolCall", id: "read-1", name: "read", arguments: { path: "books/a.md" } },
+        ],
+        api: "openai-completions",
+        provider: "google",
+        model: "gemini-pro-latest",
+        usage,
+        stopReason: "toolUse",
+        timestamp: 4,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 5,
+      timestamp: 5,
+    });
+
+    const session = await deriveBookSessionFromTranscript(projectRoot, "s1");
+
+    expect(session?.messages).toEqual([{ role: "user", content: "你好", timestamp: 3 }]);
+  });
+
   it("从 transcript 派生 BookSession UI 视图", async () => {
     await appendTranscriptEvent(projectRoot, {
       type: "session_created",

+ 8 - 2
packages/core/src/agent/agent-session.ts

@@ -22,7 +22,10 @@ import {
   nextTranscriptSeq,
   readTranscriptEvents,
 } from "../interaction/session-transcript.js";
-import { restoreAgentMessagesFromTranscript } from "../interaction/session-transcript-restore.js";
+import {
+  adaptRestoredAgentMessagesForModel,
+  restoreAgentMessagesFromTranscript,
+} from "../interaction/session-transcript-restore.js";
 import type { TranscriptRole } from "../interaction/session-transcript-schema.js";
 
 // ---------------------------------------------------------------------------
@@ -344,7 +347,10 @@ export async function runAgentSession(
 
   if (!cached) {
     const model = resolveModel(config.model);
-    const restoredMessages = await restoreAgentMessagesFromTranscript(projectRoot, sessionId);
+    const restoredMessages = adaptRestoredAgentMessagesForModel(
+      await restoreAgentMessagesFromTranscript(projectRoot, sessionId),
+      model,
+    );
     const initialAgentMessages = restoredMessages.length > 0
       ? restoredMessages
       : initialMessages && initialMessages.length > 0

+ 87 - 2
packages/core/src/interaction/session-transcript-restore.ts

@@ -59,6 +59,49 @@ function removeTrailingThinking(message: AgentMessage): AgentMessage {
   return { ...message, content } as AgentMessage;
 }
 
+const emptyUsage = {
+  input: 0,
+  output: 0,
+  cacheRead: 0,
+  cacheWrite: 0,
+  totalTokens: 0,
+  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+};
+
+const TOOL_RESULT_BRIDGE_TEXT = "I have processed the tool results.";
+
+function toolResultBridgeMessage(timestamp: number): AgentMessage {
+  return {
+    role: "assistant",
+    content: [{ type: "text", text: TOOL_RESULT_BRIDGE_TEXT }],
+    api: "openai-completions",
+    provider: "inkos",
+    model: "synthetic-tool-result-bridge",
+    usage: emptyUsage,
+    stopReason: "stop",
+    timestamp,
+  } as AgentMessage;
+}
+
+function addToolResultBridges(messages: AgentMessage[]): AgentMessage[] {
+  const bridged: AgentMessage[] = [];
+
+  for (let i = 0; i < messages.length; i++) {
+    const message = messages[i];
+    bridged.push(message);
+
+    if (!isObject(message) || message.role !== "toolResult") continue;
+
+    const next = messages[i + 1];
+    if (isObject(next) && (next.role === "toolResult" || next.role === "assistant")) continue;
+
+    const timestamp = typeof message.timestamp === "number" ? message.timestamp + 1 : Date.now();
+    bridged.push(toolResultBridgeMessage(timestamp));
+  }
+
+  return bridged;
+}
+
 export function cleanRestoredAgentMessages(messages: AgentMessage[]): AgentMessage[] {
   const availableToolCalls = new Set<string>();
   for (const message of messages) {
@@ -84,7 +127,49 @@ export function cleanRestoredAgentMessages(messages: AgentMessage[]): AgentMessa
     cleaned[cleaned.length - 1] = removeTrailingThinking(last);
   }
 
-  return cleaned;
+  return addToolResultBridges(cleaned);
+}
+
+interface TargetModelIdentity {
+  readonly api?: unknown;
+  readonly provider?: unknown;
+  readonly id?: unknown;
+}
+
+function isSameAssistantModel(message: Record<string, unknown>, target: TargetModelIdentity): boolean {
+  return (
+    typeof message.api === "string" &&
+    typeof message.provider === "string" &&
+    typeof message.model === "string" &&
+    message.api === target.api &&
+    message.provider === target.provider &&
+    message.model === target.id
+  );
+}
+
+export function adaptRestoredAgentMessagesForModel(
+  messages: AgentMessage[],
+  target: TargetModelIdentity,
+): AgentMessage[] {
+  return messages
+    .map((message) => {
+      if (
+        !isObject(message) ||
+        message.role !== "assistant" ||
+        !Array.isArray(message.content) ||
+        isSameAssistantModel(message, target)
+      ) {
+        return message;
+      }
+
+      const content = message.content.filter((block) => !isThinkingBlock(block));
+      if (content.length === message.content.length) return message;
+      return { ...message, content } as AgentMessage;
+    })
+    .filter((message) => {
+      if (!isObject(message) || message.role !== "assistant") return true;
+      return hasTextContent(message) || hasToolCallContent(message);
+    });
 }
 
 export function committedMessageEvents(events: TranscriptEvent[]): MessageEvent[] {
@@ -153,7 +238,7 @@ function messageEventToInteractionMessage(event: MessageEvent): InteractionMessa
   if (event.role === "assistant") {
     const content = textFromContent(raw.content);
     const thinking = thinkingFromContent(raw.content) ?? event.legacyDisplay?.thinking;
-    if (!content && !thinking) return null;
+    if (!content) return null;
     return {
       role: "assistant",
       content,

+ 1 - 0
packages/core/src/llm/providers/endpoints/deepseek.ts

@@ -21,6 +21,7 @@ export const DEEPSEEK: InkosEndpoint = {
   api: "openai-completions",
   baseUrl: "https://api.deepseek.com",
   checkModel: "deepseek-v4-flash",
+  compat: { requiresAssistantAfterToolResult: true },
   temperatureRange: [0, 2],
   defaultTemperature: 1,
   writingTemperature: 1.5,

+ 1 - 1
packages/core/src/llm/providers/endpoints/google.ts

@@ -16,7 +16,7 @@ export const GOOGLE: InkosEndpoint = {
   api: "openai-completions",
   baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
   checkModel: "gemini-2.5-flash",
-  compat: { supportsStore: false },
+  compat: { supportsStore: false, requiresAssistantAfterToolResult: true },
   temperatureRange: [0, 2],
   defaultTemperature: 1,
   writingTemperature: 1,

+ 1 - 0
packages/core/src/llm/providers/types.ts

@@ -54,6 +54,7 @@ export interface ProviderCompat {
   readonly supportsStore?: boolean;
   readonly supportsSystemRole?: boolean;
   readonly supportsDeveloperRole?: boolean;
+  readonly requiresAssistantAfterToolResult?: boolean;
 }
 
 export interface ProviderTransportDefaults {

+ 18 - 0
packages/core/src/llm/service-resolver.ts

@@ -2,6 +2,8 @@ import { getModel } from "@mariozechner/pi-ai";
 import type { Model, Api } from "@mariozechner/pi-ai";
 import { resolveServicePiProvider, resolveServicePreset } from "./service-presets.js";
 import { getServiceApiKey } from "./secrets.js";
+import { getEndpoint } from "./providers/index.js";
+import type { InkosEndpoint } from "./providers/types.js";
 
 export interface ResolvedModel {
   model: Model<Api>;
@@ -11,6 +13,17 @@ export interface ResolvedModel {
   temperatureHint?: string;
 }
 
+function resolveProviderCompat(
+  provider: InkosEndpoint | undefined,
+  baseUrl: string,
+): Record<string, unknown> | undefined {
+  const compat = {
+    ...(provider?.compat ?? {}),
+    ...(baseUrl.includes("generativelanguage.googleapis.com") ? { supportsStore: false } : {}),
+  };
+  return Object.keys(compat).length > 0 ? compat : undefined;
+}
+
 export async function resolveServiceModel(
   service: string,
   modelId: string,
@@ -29,6 +42,7 @@ export async function resolveServiceModel(
   // Determine pi-ai provider
   const baseService = service.startsWith("custom:") ? "custom" : service;
   const preset = resolveServicePreset(baseService);
+  const endpoint = getEndpoint(baseService);
   const piProvider = resolveServicePiProvider(baseService) ?? "openai";
   const apiType = service.startsWith("custom:")
     ? (customApiFormat === "responses" ? "openai-responses" : "openai-completions")
@@ -38,6 +52,9 @@ export async function resolveServiceModel(
   // Get pi-ai Model — may return undefined for model IDs not in the built-in registry
   const piModel = getModel(piProvider as any, modelId as any) as Model<Api> | undefined;
   const effectiveBaseUrl = configuredBaseUrl || piModel?.baseUrl || "";
+  const compat = apiType === "openai-completions"
+    ? resolveProviderCompat(endpoint, effectiveBaseUrl)
+    : undefined;
 
   if (!effectiveBaseUrl) {
     throw new Error(
@@ -56,6 +73,7 @@ export async function resolveServiceModel(
     cost: piModel?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
     contextWindow: piModel?.contextWindow ?? 0,
     maxTokens: piModel?.maxTokens ?? 16384,
+    ...(compat ? { compat: compat as Model<Api>["compat"] } : {}),
   };
 
   return {