Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/browser/features/Tools/WebSearchToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type ToolStatus,
} from "./Shared/toolUtils";
import { JsonHighlight } from "./Shared/HighlightedCode";
import { stripEncryptedContent } from "@/common/utils/messages/stripEncryptedContent";

interface WebSearchToolCallProps {
args: { query?: string }; // Anthropic puts query in args
Expand Down Expand Up @@ -102,7 +103,9 @@ export const WebSearchToolCall: React.FC<WebSearchToolCallProps> = ({
<DetailSection>
<DetailLabel>Results</DetailLabel>
<div className="bg-code-bg max-h-[300px] overflow-y-auto rounded px-3 py-2 text-[12px]">
<JsonHighlight value={result} />
{/* Anthropic results keep their ciphertext for native replay (#5887): it is
opaque and large, so the transcript shows the rest. */}
<JsonHighlight value={stripEncryptedContent(result)} />
</div>
</DetailSection>
)}
Expand Down
27 changes: 27 additions & 0 deletions src/browser/utils/messages/modelMessageTransform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,33 @@ describe("modelMessageTransform", () => {
expect(result.valid).toBe(true);
});

it("accepts a provider-executed call whose result rides in the same message (#5887)", () => {
const messages: ModelMessage[] = [
{ role: "user", content: [{ type: "text", text: "search" }] },
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "srvtoolu_1",
toolName: "web_search",
input: { query: "xum" },
providerExecuted: true,
},
{
type: "tool-result",
toolCallId: "srvtoolu_1",
toolName: "web_search",
output: { type: "json", value: [] },
},
{ type: "text", text: "found it" },
],
},
];

expect(validateAnthropicCompliance(messages)).toEqual({ valid: true });
});

it("should detect tool calls without results", () => {
const assistantMsg1: AssistantModelMessage = {
role: "assistant",
Expand Down
19 changes: 15 additions & 4 deletions src/browser/utils/messages/modelMessageTransform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,11 @@ function splitMixedContentMessages(messages: ModelMessage[]): ModelMessage[] {
continue;
}

const toolCallParts = assistantMsg.content.filter((c) => c.type === "tool-call");
// A provider-executed call carries its result inline in this message (Anthropic
// server_tool_use + web_search_tool_result, #5887): it stays with the content around it.
const isClientToolCall = (part: (typeof assistantMsg.content)[number]) =>
part.type === "tool-call" && part.providerExecuted !== true;
const toolCallParts = assistantMsg.content.filter(isClientToolCall);

if (toolCallParts.length === 0) {
result.push(msg);
Expand All @@ -355,7 +359,7 @@ function splitMixedContentMessages(messages: ModelMessage[]): ModelMessage[] {
let currentGroup: { type: "text" | "tool-call"; parts: ContentArray } | null = null;

for (const part of assistantMsg.content) {
const partType = part.type === "tool-call" ? "tool-call" : "text";
const partType = isClientToolCall(part) ? "tool-call" : "text";

// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
if (!currentGroup || currentGroup.type !== partType) {
Expand Down Expand Up @@ -1139,7 +1143,12 @@ function ensureAnthropicThinkingBeforeToolCalls(messages: ModelMessage[]): Model
// interleaved thinking, text can sit between two thinking blocks, and moving a block
// edits the prefix every later block is bound to (#5887). Preserved thinking: "send it
// back unchanged ... in the order received".
if (!hasToolCall || content[0]?.type === "reasoning") {
// The same holds for a message that opens with a natively replayed server tool (#5887): the
// API itself started the response with server_tool_use, and the thinking after the search is
// bound to it.
const opensWithServerTool =
content[0]?.type === "tool-call" && content[0].providerExecuted === true;
if (!hasToolCall || content[0]?.type === "reasoning" || opensWithServerTool) {
result.push(msg);
continue;
}
Expand Down Expand Up @@ -1321,7 +1330,9 @@ export function validateAnthropicCompliance(messages: ModelMessage[]): {

// Track any tool calls in this message
for (const content of assistantMsg.content) {
if (content.type === "tool-call") {
// A provider-executed call (Anthropic server tool, #5887) carries its result in the
// same assistant message and needs no tool message after it.
if (content.type === "tool-call" && content.providerExecuted !== true) {
pendingToolCalls.set(content.toolCallId, i);
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/common/orpc/schemas/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ const MuxToolPartBase = z.object({
workflowRun: WorkflowRunToolAttachmentSchema.optional(),
// Host-authored display data must not enter the model-visible output.
mcpServer: MCPToolCallDisplaySchema.optional().catch(undefined),
// The provider ran the tool server-side (Anthropic web_search, ...). Stored only for the
// Anthropic Messages wire, so history can replay it natively (#5887).
providerExecuted: z.boolean().optional(),
});

/**
Expand Down
152 changes: 152 additions & 0 deletions src/common/utils/messages/anthropicNativeServerTools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { describe, expect, test } from "bun:test";
import type { DynamicToolPart } from "@/common/types/toolParts";
import { ANTHROPIC_NATIVE_SERVER_TOOL_MAX_ROW_CIPHERTEXT_CHARS } from "@/constants/anthropicServerTools";
import { createMuxMessage, type MuxMessage } from "@/common/types/message";
import {
isNativeAnthropicReplayable,
projectAnthropicServerTools,
rowCiphertextChars,
toStoredServerToolPart,
} from "./anthropicNativeServerTools";

/** A completed Anthropic web_search part whose results carry these ciphertext lengths. */
function webSearchPart(id: string, ciphertextLengths: number[]): DynamicToolPart {
return {
type: "dynamic-tool",
toolCallId: id,
toolName: "web_search",
state: "output-available",
input: { query: "xum" },
providerExecuted: true,
output: ciphertextLengths.map((length, index) => ({
type: "web_search_result",
url: `https://example.com/${index}`,
title: "Xum",
pageAge: null,
encryptedContent: "e".repeat(length),
})),
};
}

/** Ciphertext lengths of at most 12,000 chars (the generic sanitizer bound) that add up to `total`. */
function chunks(total: number): number[] {
const out: number[] = [];
for (let left = total; left > 0; left -= 12_000) out.push(Math.min(left, 12_000));
return out;
}

function hasCiphertext(part: DynamicToolPart): boolean {
return part.state === "output-available" && JSON.stringify(part.output).includes("eee");
}

/** Stores the parts in order, the way StreamManager completes them within one row. */
function storeRow(parts: DynamicToolPart[]): DynamicToolPart[] {
const stored: DynamicToolPart[] = [];
for (const part of parts) {
stored.push(
toStoredServerToolPart(part, {
resultFollowsCall: true,
rowCiphertextChars: rowCiphertextChars(stored),
})
);
}
return stored;
}

describe("toStoredServerToolPart ciphertext bound (#5887)", () => {
const limit = ANTHROPIC_NATIVE_SERVER_TOOL_MAX_ROW_CIPHERTEXT_CHARS;

test("keeps native replay when the row's summed ciphertext is at the limit", () => {
// Three results over two calls: the bound sums every result of every search in the row.
const row = storeRow([
webSearchPart("srvtoolu_1", [...chunks(limit - 30), 10]),
webSearchPart("srvtoolu_2", [20]),
]);
expect(row.map((part) => part.providerExecuted)).toEqual([true, true]);
expect(row.every(hasCiphertext)).toBe(true);
expect(rowCiphertextChars(row)).toBe(limit);
});

test("stores the search that crosses the limit as the client pair without ciphertext", () => {
const row = storeRow([
webSearchPart("srvtoolu_1", [...chunks(limit - 30), 10]),
webSearchPart("srvtoolu_2", [21]),
// A later small search still fits: only the ciphertext kept counts.
webSearchPart("srvtoolu_3", [20]),
]);
expect(row.map((part) => part.providerExecuted)).toEqual([true, undefined, true]);
expect(hasCiphertext(row[1])).toBe(false);
// The search itself stays in history (URLs and titles), only the ciphertext is dropped.
expect(row[1].state === "output-available" && Array.isArray(row[1].output)).toBe(true);
expect(rowCiphertextChars(row)).toBe(limit);
});
});

describe("isNativeAnthropicReplayable fields (#5887)", () => {
// Native replay sends every field back as the API returned it, so a field the generic
// provider-output sanitizer would rewrite (too long, control text) cannot replay natively.
function withTitle(title: string): DynamicToolPart {
const part = webSearchPart("srvtoolu_1", [10]);
if (part.state !== "output-available" || !Array.isArray(part.output)) throw new Error("setup");
return { ...part, output: [{ ...(part.output[0] as object), title }] };
}

test("a plain title replays natively", () => {
expect(isNativeAnthropicReplayable(withTitle("Xum"))).toBe(true);
});

function withResult(fields: Record<string, unknown>): DynamicToolPart {
const part = webSearchPart("srvtoolu_1", [10]);
if (part.state !== "output-available" || !Array.isArray(part.output)) throw new Error("setup");
return { ...part, output: [{ ...(part.output[0] as object), ...fields }] };
}

test("ciphertext the sanitizer would rewrite does not replay natively", () => {
// Older builds still run the sanitizer on stored native rows: only unchanged bytes are safe.
expect(isNativeAnthropicReplayable(withResult({ encryptedContent: "e".repeat(12_000) }))).toBe(
true
);
expect(isNativeAnthropicReplayable(withResult({ encryptedContent: "e".repeat(12_001) }))).toBe(
false
);
});

test("a result without pageAge does not replay natively", () => {
// The SDK's replay schema requires pageAge as a string or null: undefined throws.
const part = withResult({});
if (part.state !== "output-available" || !Array.isArray(part.output)) throw new Error("setup");
const { pageAge: _dropped, ...withoutPageAge } = part.output[0] as Record<string, unknown>;
expect(isNativeAnthropicReplayable({ ...part, output: [withoutPageAge] })).toBe(false);
});

test("a title the sanitizer would rewrite does not", () => {
expect(isNativeAnthropicReplayable(withTitle("t".repeat(12_001)))).toBe(false);
expect(isNativeAnthropicReplayable(withTitle("bad\u0000title"))).toBe(false);
});
});

describe("projectAnthropicServerTools demotion across rows (#5887)", () => {
// Preserved thinking binds each thinking block to everything before it, earlier rows too.
function rows(): MuxMessage[] {
return [
createMuxMessage("user-1", "user", "search", { historySequence: 0 }),
createMuxMessage("assistant-1", "assistant", "", { historySequence: 1 }, [
webSearchPart("srvtoolu_1", [10]),
{ type: "text", text: "found" },
]),
createMuxMessage("user-2", "user", "more", { historySequence: 2 }),
createMuxMessage("assistant-2", "assistant", "", { historySequence: 3 }, [
{ type: "reasoning", text: "read", providerOptions: { anthropic: { signature: "sig" } } },
{ type: "text", text: "done" },
]),
];
}

test("thinking in a later row after a demoted search strips thinking", () => {
expect(projectAnthropicServerTools(rows(), false).demotedBeforeThinking).toBe(true);
});

test("the same rows replayed natively keep thinking", () => {
expect(projectAnthropicServerTools(rows(), true).demotedBeforeThinking).toBe(false);
});
});
Loading
Loading