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

fix: gate tool result bridge by provider compat

fanghanjun 6 месяцев назад
Родитель
Сommit
eff8a60578

+ 5 - 0
docs/superpowers/specs/2026-04-27-pi-agent-jsonl-persistence-design.md

@@ -225,6 +225,11 @@ tool result 必须作为一等 `message` event 进入 transcript。
 
 JSONL transcript 通过持久化原始 message 消除这种差异。恢复后的 `Agent.state.messages` 包含 committed request 结束时内存中存在的 user、assistant、toolResult message。
 
+`restoreAgentMessagesFromTranscript()` 只恢复 committed 且清理后的 raw `AgentMessage`。
+provider-specific 兼容投影发生在 `adaptRestoredAgentMessagesForModel()`:
+跨模型会把外来 tool call/result 文本化;只有目标 provider 的 `compat.requiresAssistantAfterToolResult`
+为 true 时,才在 restored `toolResult` 后补 synthetic assistant bridge。
+
 `sourceToolAssistantUuid` 在可获得时指向发出对应 tool call 的 assistant message。后续图恢复可以借此把 tool result 绑定到来源 tool use,降低对顺序恢复的依赖。
 
 ## 9. 缓存角色

+ 68 - 11
packages/core/src/__tests__/session-transcript-restore.test.ts

@@ -7,6 +7,7 @@ import {
   adaptRestoredAgentMessagesForModel,
   deriveBookSessionFromTranscript,
   restoreAgentMessagesFromTranscript,
+  TOOL_RESULT_BRIDGE_TEXT,
 } from "../interaction/session-transcript-restore.js";
 import type { MessageEvent } from "../interaction/session-transcript-schema.js";
 
@@ -156,7 +157,7 @@ describe("session transcript restore", () => {
 
     const restored = await restoreAgentMessagesFromTranscript(projectRoot, "s1");
 
-    expect(restored).toHaveLength(3);
+    expect(restored).toHaveLength(2);
     expect(restored[0]).toMatchObject({
       role: "assistant",
       content: [
@@ -165,13 +166,10 @@ 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." }],
-    });
+    expect(JSON.stringify(restored)).not.toContain(TOOL_RESULT_BRIDGE_TEXT);
   });
 
-  it("恢复中断工具轮次时在 toolResult 后补 assistant bridge", async () => {
+  it("恢复中断工具轮次时只清理空 assistant,不在 raw restore 阶段补 bridge", async () => {
     await appendTranscriptEvent(projectRoot, {
       type: "request_started",
       version: 1,
@@ -302,13 +300,9 @@ describe("session transcript restore", () => {
       "user",
       "assistant",
       "toolResult",
-      "assistant",
       "user",
     ]);
-    expect(restored[3]).toMatchObject({
-      role: "assistant",
-      content: [{ type: "text", text: "I have processed the tool results." }],
-    });
+    expect(JSON.stringify(restored)).not.toContain(TOOL_RESULT_BRIDGE_TEXT);
   });
 
   it("移除最后 assistant message 的 trailing thinking block", async () => {
@@ -445,6 +439,69 @@ describe("session transcript restore", () => {
     expect(adapted).toEqual(messages);
   });
 
+  it("does not add synthetic toolResult bridge when target model does not require it", () => {
+    const messages = [
+      {
+        role: "assistant",
+        content: [{ type: "toolCall", id: "tool-1", name: "read", arguments: {} }],
+        api: "anthropic-messages",
+        provider: "anthropic",
+        model: "claude",
+        usage,
+        stopReason: "toolUse",
+        timestamp: 1,
+      },
+      {
+        role: "toolResult",
+        toolCallId: "tool-1",
+        toolName: "read",
+        content: [{ type: "text", text: "result" }],
+        isError: false,
+        timestamp: 2,
+      },
+    ] as any[];
+
+    const adapted = adaptRestoredAgentMessagesForModel(messages, {
+      api: "anthropic-messages",
+      provider: "anthropic",
+      id: "claude",
+    });
+
+    expect(JSON.stringify(adapted)).not.toContain(TOOL_RESULT_BRIDGE_TEXT);
+  });
+
+  it("adds synthetic toolResult bridge when target model compat requires it", () => {
+    const messages = [
+      {
+        role: "assistant",
+        content: [{ type: "toolCall", id: "tool-1", name: "read", arguments: {} }],
+        api: "openai-completions",
+        provider: "openai",
+        model: "deepseek-v4-pro",
+        usage,
+        stopReason: "toolUse",
+        timestamp: 1,
+      },
+      {
+        role: "toolResult",
+        toolCallId: "tool-1",
+        toolName: "read",
+        content: [{ type: "text", text: "result" }],
+        isError: false,
+        timestamp: 2,
+      },
+    ] as any[];
+
+    const adapted = adaptRestoredAgentMessagesForModel(messages, {
+      api: "openai-completions",
+      provider: "openai",
+      id: "deepseek-v4-pro",
+      compat: { requiresAssistantAfterToolResult: true },
+    });
+
+    expect(JSON.stringify(adapted)).toContain(TOOL_RESULT_BRIDGE_TEXT);
+  });
+
   it("跨模型恢复时把原生工具回合降级为 user 文本", () => {
     const messages = [
       {

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

@@ -127,13 +127,22 @@ export function cleanRestoredAgentMessages(messages: AgentMessage[]): AgentMessa
     cleaned[cleaned.length - 1] = removeTrailingThinking(last);
   }
 
-  return addToolResultBridges(cleaned);
+  return cleaned;
 }
 
 interface TargetModelIdentity {
   readonly api?: unknown;
   readonly provider?: unknown;
   readonly id?: unknown;
+  readonly compat?: unknown;
+}
+
+function requiresAssistantAfterToolResult(target: TargetModelIdentity): boolean {
+  return !!(
+    target.compat &&
+    typeof target.compat === "object" &&
+    (target.compat as { requiresAssistantAfterToolResult?: unknown }).requiresAssistantAfterToolResult === true
+  );
 }
 
 function isSameAssistantModel(message: Record<string, unknown>, target: TargetModelIdentity): boolean {
@@ -290,10 +299,14 @@ export function adaptRestoredAgentMessagesForModel(
     adapted.push(message);
   }
 
-  return adapted.filter((message) => {
+  const filtered = adapted.filter((message) => {
     if (!isObject(message) || message.role !== "assistant") return true;
     return hasTextContent(message) || hasToolCallContent(message);
   });
+
+  return requiresAssistantAfterToolResult(target)
+    ? addToolResultBridges(filtered)
+    : filtered;
 }
 
 export function committedMessageEvents(events: TranscriptEvent[]): MessageEvent[] {

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

@@ -55,6 +55,7 @@ export interface ProviderCompat {
   readonly supportsStore?: boolean;
   readonly supportsSystemRole?: boolean;
   readonly supportsDeveloperRole?: boolean;
+  /** Some OpenAI-compatible providers reject restored histories ending in toolResult; only those providers get a synthetic assistant bridge during context projection. */
   readonly requiresAssistantAfterToolResult?: boolean;
 }