fix(ai): preserved anthropic tool-search replay blocks

- Retained tool-search server calls and opaque results in signed assistant history across direct streams, gateways, and custom-endpoint projection.

- Added replay regressions for interleaved thinking and client tool continuations.

Fixes #8559
This commit is contained in:
roboomp
2026-08-14 14:34:27 +00:00
parent ffd53ff92a
commit bee1d44bf9
10 changed files with 265 additions and 22 deletions
+2 -2
View File
@@ -470,8 +470,8 @@ function computeMessageTokens(message: AgentMessage, options?: { excludeEncrypte
if (!options?.excludeEncryptedReasoning) fragments.push(block.data);
} else if (block.type === "anthropicServerTool") {
// Native Anthropic server-tool call/result replayed verbatim on the
// wire (server_tool_use input, web_search_tool_result
// encrypted_content). Opaque provider-replay state the provider still
// wire (server_tool_use input and opaque result content). This opaque
// provider-replay state the provider still
// bills for on same-provider replay; excluded from the compaction
// floor like other encrypted reasoning because its local byte size
// diverges from provider billing.
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed Anthropic custom signing-proxy continuations dropping native tool-search call/result blocks from signed assistant history, preserving interleaved thinking for byte-identical replay ([#8559](https://github.com/can1357/oh-my-pi/issues/8559)).
## [17.3.4] - 2026-08-14
### Fixed
@@ -27,7 +27,7 @@ import {
type AnthropicUserContentBlock,
anthropicMessagesRequestSchema,
} from "./anthropic-messages-server-schema";
import { isAnthropicWebSearchHistoryBlock } from "./anthropic-wire";
import { isAnthropicServerToolHistoryBlock } from "./anthropic-wire";
/**
* Anthropic Messages API (https://docs.anthropic.com/en/api/messages) ↔ pi-ai
@@ -216,10 +216,10 @@ function walkAssistantContent(
break;
case "server_tool_use":
case "web_search_tool_result":
if (isAnthropicWebSearchHistoryBlock(block)) {
// Native web-search call/result. Anthropic requires these
// replayed verbatim (encrypted_content included), so retain
// the block instead of flattening it to text.
case "tool_search_tool_result":
if (isAnthropicServerToolHistoryBlock(block)) {
// Anthropic requires supported server-tool call/results replayed
// verbatim, so retain each opaque block instead of flattening it.
out.push({ type: "anthropicServerTool", block: { ...block } });
} else {
// Other server tools use distinct result block types that omp
+31 -5
View File
@@ -77,6 +77,11 @@ export type ServerToolUseBlockParam = {
/** Web-search server-tool call whose matching result is replayable by omp. */
export type WebSearchServerToolUseBlockParam = ServerToolUseBlockParam & { name: "web_search" };
/** Tool-search server-tool call whose matching result is replayable by omp. */
export type ToolSearchServerToolUseBlockParam = ServerToolUseBlockParam & {
name: "tool_search_tool_regex" | "tool_search_tool_bm25";
};
/** Native web-search result replayed inside an assistant turn. */
export type WebSearchToolResultBlockParam = {
type: "web_search_tool_result";
@@ -85,18 +90,37 @@ export type WebSearchToolResultBlockParam = {
[key: string]: unknown;
};
/** True for the complete native web-search history variants omp can replay. */
export function isAnthropicWebSearchHistoryBlock(block: {
/** Native tool-search result replayed inside an assistant turn. */
export type ToolSearchToolResultBlockParam = {
type: "tool_search_tool_result";
tool_use_id: string;
content: unknown;
[key: string]: unknown;
};
/** Anthropic server-tool history variants omp can replay atomically. */
export type AnthropicServerToolHistoryBlockParam =
| WebSearchServerToolUseBlockParam
| WebSearchToolResultBlockParam
| ToolSearchServerToolUseBlockParam
| ToolSearchToolResultBlockParam;
/** True when a block is complete Anthropic server-tool history omp can replay. */
export function isAnthropicServerToolHistoryBlock(block: {
type: string;
name?: unknown;
id?: unknown;
tool_use_id?: unknown;
content?: unknown;
}): block is WebSearchServerToolUseBlockParam | WebSearchToolResultBlockParam {
}): block is AnthropicServerToolHistoryBlockParam {
if (block.type === "server_tool_use") {
return block.name === "web_search" && typeof block.id === "string" && block.id.length > 0;
const supportedName =
block.name === "web_search" ||
block.name === "tool_search_tool_regex" ||
block.name === "tool_search_tool_bm25";
return supportedName && typeof block.id === "string" && block.id.length > 0;
}
if (block.type === "web_search_tool_result") {
if (block.type === "web_search_tool_result" || block.type === "tool_search_tool_result") {
return typeof block.tool_use_id === "string" && block.tool_use_id.length > 0 && Object.hasOwn(block, "content");
}
return false;
@@ -132,6 +156,7 @@ export type ContentBlockParam =
| ToolResultBlockParam
| ServerToolUseBlockParam
| WebSearchToolResultBlockParam
| ToolSearchToolResultBlockParam
| ThinkingBlockParam
| RedactedThinkingBlockParam
| FallbackBlockParam;
@@ -319,6 +344,7 @@ export type ResponseContentBlock =
| { type: "tool_use"; id: string; name: string; input?: Record<string, unknown> | null }
| ServerToolUseBlockParam
| WebSearchToolResultBlockParam
| ToolSearchToolResultBlockParam
| { type: "fallback"; from: { model: string }; to: { model: string } };
export type ContentBlockDelta =
+6 -3
View File
@@ -79,7 +79,7 @@ import {
type Usage as AnthropicWireUsage,
type ContentBlockParam,
type FallbackParam,
isAnthropicWebSearchHistoryBlock,
isAnthropicServerToolHistoryBlock,
type MessageCreateParams,
type MessageCreateParamsStreaming,
type MessageParam,
@@ -2429,8 +2429,11 @@ const streamAnthropicOnce = (
kind: "redactedThinking",
});
} else if (
isAnthropicWebSearchHistoryBlock(event.content_block) &&
umansGatewayWebSearchHeader === undefined
isAnthropicServerToolHistoryBlock(event.content_block) &&
(umansGatewayWebSearchHeader === undefined ||
(event.content_block.type === "server_tool_use"
? event.content_block.name !== "web_search"
: event.content_block.type !== "web_search_tool_result"))
) {
streamedReplayUnsafeContent = true;
const block: Block = {
+5 -4
View File
@@ -716,8 +716,9 @@ export interface AnthropicFallbackContent {
}
/**
* Verbatim Anthropic web-search call/result retained for same-provider
* history replay. Other providers discard it in `transformMessages`.
* Verbatim Anthropic web-search or tool-search call/result retained for
* same-provider history replay. Other providers discard it in
* `transformMessages`.
*/
export interface AnthropicServerToolContent {
type: "anthropicServerTool";
@@ -725,12 +726,12 @@ export interface AnthropicServerToolContent {
| {
type: "server_tool_use";
id: string;
name: "web_search";
name: "web_search" | "tool_search_tool_regex" | "tool_search_tool_bm25";
input?: Record<string, unknown> | null;
[key: string]: unknown;
}
| {
type: "web_search_tool_result";
type: "web_search_tool_result" | "tool_search_tool_result";
tool_use_id: string;
content: unknown;
[key: string]: unknown;
@@ -25,7 +25,7 @@
* events are forwarded verbatim.
*/
import { isAnthropicWebSearchHistoryBlock } from "../providers/anthropic-wire";
import { isAnthropicServerToolHistoryBlock } from "../providers/anthropic-wire";
import type {
AnthropicServerToolContent,
AssistantMessage,
@@ -430,7 +430,7 @@ class LeakedThinkingProjector {
const pairedIndexes = new Set<number>();
for (let srcIndex = 0; srcIndex < message.content.length; srcIndex++) {
const content = message.content[srcIndex];
if (content?.type !== "anthropicServerTool" || !isAnthropicWebSearchHistoryBlock(content.block)) continue;
if (content?.type !== "anthropicServerTool" || !isAnthropicServerToolHistoryBlock(content.block)) continue;
if (content.block.type === "server_tool_use") {
pendingCalls.set(content.block.id, srcIndex);
continue;
@@ -6,7 +6,10 @@ import {
type AnthropicMessagesClientLike,
type AnthropicRequestOptions,
} from "@oh-my-pi/pi-ai/providers/anthropic-client";
import type { WebSearchToolResultBlockParam } from "@oh-my-pi/pi-ai/providers/anthropic-wire";
import type {
ToolSearchToolResultBlockParam,
WebSearchToolResultBlockParam,
} from "@oh-my-pi/pi-ai/providers/anthropic-wire";
import type { AssistantMessageEvent, Context, Model, ModelSpec, ProviderSessionState } from "@oh-my-pi/pi-ai/types";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { structuredCloneJSON } from "@oh-my-pi/pi-utils";
@@ -676,6 +679,117 @@ describe("anthropic stream envelope handling", () => {
]);
});
it("replays tool-search server blocks between signed thinking and a client tool call", async () => {
const toolSearchResult: ToolSearchToolResultBlockParam = {
type: "tool_search_tool_result",
tool_use_id: "srvtoolu_search",
content: {
type: "tool_search_tool_search_result",
tool_references: [{ type: "tool_reference", tool_name: "_read" }],
},
};
vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(
() =>
createMockRequest([
{
type: "message_start",
message: {
id: "msg_tool_search",
usage: {
input_tokens: 12,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
{ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } },
{ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "Find read." } },
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig-1" } },
{ type: "content_block_stop", index: 0 },
{
type: "content_block_start",
index: 1,
content_block: {
type: "server_tool_use",
id: "srvtoolu_search",
name: "tool_search_tool_regex",
},
},
{
type: "content_block_delta",
index: 1,
delta: { type: "input_json_delta", partial_json: '{"pattern":"read"}' },
},
{ type: "content_block_stop", index: 1 },
{ type: "content_block_start", index: 2, content_block: toolSearchResult },
{ type: "content_block_stop", index: 2 },
{ type: "content_block_start", index: 3, content_block: { type: "thinking", thinking: "" } },
{ type: "content_block_delta", index: 3, delta: { type: "thinking_delta", thinking: "Use read." } },
{ type: "content_block_delta", index: 3, delta: { type: "signature_delta", signature: "sig-2" } },
{ type: "content_block_stop", index: 3 },
{
type: "content_block_start",
index: 4,
content_block: { type: "tool_use", id: "tool_1", name: "_read", input: {} },
},
{
type: "content_block_delta",
index: 4,
delta: { type: "input_json_delta", partial_json: '{"path":"notes.txt"}' },
},
{ type: "content_block_stop", index: 4 },
{
type: "message_delta",
delta: { stop_reason: "tool_use" },
usage: {
input_tokens: 12,
output_tokens: 20,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
{ type: "message_stop" },
]) as never,
);
const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" });
for await (const _ of stream) {
// drain stream
}
const result = structuredCloneJSON(await stream.result());
const replay = convertAnthropicMessages(
[
context.messages[0],
result,
{
role: "toolResult",
toolCallId: "tool_1",
toolName: "_read",
content: [{ type: "text", text: "notes" }],
isError: false,
timestamp: 2,
},
],
model,
false,
);
const assistant = replay.find(message => message.role === "assistant");
expect(assistant?.content).toEqual([
{ type: "thinking", thinking: "Find read.", signature: "sig-1" },
{
type: "server_tool_use",
id: "srvtoolu_search",
name: "tool_search_tool_regex",
input: { pattern: "read" },
},
toolSearchResult,
{ type: "thinking", thinking: "Use read.", signature: "sig-2" },
{ type: "tool_use", id: "tool_1", name: "_read", input: { path: "notes.txt" } },
]);
});
it("does not persist a code-execution call without its unsupported result block", async () => {
vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(
() =>
@@ -1,6 +1,8 @@
import { describe, expect, it } from "bun:test";
import { encodeResponse, encodeStream, parseRequest } from "@oh-my-pi/pi-ai/providers/anthropic-messages-server";
import type {
ToolSearchServerToolUseBlockParam,
ToolSearchToolResultBlockParam,
WebSearchServerToolUseBlockParam,
WebSearchToolResultBlockParam,
} from "@oh-my-pi/pi-ai/providers/anthropic-wire";
@@ -344,6 +346,47 @@ describe("anthropic-messages parseRequest", () => {
]);
});
it("preserves inbound assistant tool-search call/result blocks verbatim", () => {
const serverToolUse: ToolSearchServerToolUseBlockParam = {
type: "server_tool_use",
id: "srvtoolu_search",
name: "tool_search_tool_regex",
input: { pattern: "read" },
};
const searchResult: ToolSearchToolResultBlockParam = {
type: "tool_search_tool_result",
tool_use_id: "srvtoolu_search",
content: {
type: "tool_search_tool_search_result",
tool_references: [{ type: "tool_reference", tool_name: "_read" }],
},
};
const parsed = parseRequest({
model: "claude-opus-4-7",
max_tokens: 8,
messages: [
{ role: "user", content: "read notes" },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "find read", signature: "sig-1" },
serverToolUse,
searchResult,
{ type: "text", text: "tool loaded" },
],
},
],
});
const assistant = parsed.context.messages.find(message => message.role === "assistant");
expect(assistant?.content).toEqual([
{ type: "thinking", thinking: "find read", thinkingSignature: "sig-1" },
{ type: "anthropicServerTool", block: serverToolUse },
{ type: "anthropicServerTool", block: searchResult },
{ type: "text", text: "tool loaded" },
]);
});
it("flattens malformed web-search history blocks instead of preserving invalid replay state", () => {
const parsed = parseRequest({
model: "claude-opus-4-7",
@@ -419,6 +419,58 @@ describe("wrapLeakedThinkingStream", () => {
expect(result.content.slice(1, 3)).toEqual(serverBlocks);
});
it("preserves complete Anthropic tool-search history through the custom-endpoint projector", async () => {
const firstThinking: ThinkingContent = {
type: "thinking",
thinking: "find the deferred tool",
thinkingSignature: "sig-1",
};
const serverBlocks: AnthropicServerToolContent[] = [
{
type: "anthropicServerTool",
block: {
type: "server_tool_use",
id: "srvtoolu_search",
name: "tool_search_tool_regex",
input: { pattern: "read" },
},
},
{
type: "anthropicServerTool",
block: {
type: "tool_search_tool_result",
tool_use_id: "srvtoolu_search",
content: {
type: "tool_search_tool_search_result",
tool_references: [{ type: "tool_reference", tool_name: "_read" }],
},
},
},
];
const secondThinking: ThinkingContent = {
type: "thinking",
thinking: "use the discovered tool",
thinkingSignature: "sig-2",
};
const call: ToolCall = {
type: "toolCall",
id: "toolu_read",
name: "_read",
arguments: { path: "notes.txt" },
};
const content: AssistantMessage["content"] = [firstThinking, ...serverBlocks, secondThinking, call];
const terminal = msg({ content, stopReason: "toolUse" });
const { result } = await runWrapper(inner => {
inner.push({ type: "start", partial: msg() });
inner.push({ type: "toolcall_start", contentIndex: 4, partial: terminal });
inner.push({ type: "toolcall_end", contentIndex: 4, toolCall: call, partial: terminal });
inner.push({ type: "done", reason: "toolUse", message: terminal });
});
expect(result.content).toEqual(content);
});
it("drops incomplete Anthropic web-search history instead of replaying orphan blocks", async () => {
const content: AssistantMessage["content"] = [
{