diff --git a/src/Providers/Anthropic/Handlers/Stream.php b/src/Providers/Anthropic/Handlers/Stream.php index 20652744c..82baf7ed5 100644 --- a/src/Providers/Anthropic/Handlers/Stream.php +++ b/src/Providers/Anthropic/Handlers/Stream.php @@ -513,10 +513,13 @@ protected function handleToolCalls(Request $request, int $depth): Generator $request->addMessage(new AssistantMessage( content: $this->state->currentText(), toolCalls: $toolCalls, - additionalContent: in_array($this->state->currentThinking(), ['', '0'], true) ? [] : [ - 'thinking' => $this->state->currentThinking(), - 'thinking_signature' => $this->state->currentThinkingSignature(), - ] + additionalContent: Arr::whereNotNull([ + 'thinking' => $this->state->currentThinking() ?: null, + 'thinking_signature' => $this->state->currentThinkingSignature() ?: null, + 'citations' => $this->state->citations() ?: null, + 'provider_tool_calls' => array_values($this->state->providerToolCalls()) ?: null, + 'provider_tool_results' => array_values($this->state->providerToolResults()) ?: null, + ]), )); $request->addMessage(new ToolResultMessage($toolResults)); diff --git a/src/Providers/Anthropic/Handlers/Text.php b/src/Providers/Anthropic/Handlers/Text.php index a88d3ff1e..4f8f7b1c4 100644 --- a/src/Providers/Anthropic/Handlers/Text.php +++ b/src/Providers/Anthropic/Handlers/Text.php @@ -170,10 +170,52 @@ protected function prepareTempResponse(): void additionalContent: Arr::whereNotNull([ 'citations' => $this->extractCitations($data), ...$this->extractThinking($data), + ...$this->extractProviderToolContent($data), ]) ); } + /** + * Extract server tool use and result content blocks from the response. + * + * These must be preserved in additionalContent so that MessageMap can + * replay them alongside citations during multi-step tool loops. + * + * @param array $data + * @return array>> + */ + protected function extractProviderToolContent(array $data): array + { + $providerToolCalls = []; + $providerToolResults = []; + + foreach (data_get($data, 'content', []) as $content) { + $type = data_get($content, 'type'); + + if ($type === 'server_tool_use') { + $providerToolCalls[] = [ + 'type' => $type, + 'id' => data_get($content, 'id'), + 'name' => data_get($content, 'name'), + 'input' => json_encode(data_get($content, 'input', [])), + ]; + } + + if (str_ends_with((string) $type, '_tool_result')) { + $providerToolResults[] = [ + 'type' => $type, + 'tool_use_id' => data_get($content, 'tool_use_id'), + 'content' => data_get($content, 'content'), + ]; + } + } + + return Arr::whereNotNull([ + 'provider_tool_calls' => $providerToolCalls !== [] ? $providerToolCalls : null, + 'provider_tool_results' => $providerToolResults !== [] ? $providerToolResults : null, + ]); + } + /** * @return array */ diff --git a/tests/Fixtures/anthropic/generate-text-with-web-search-and-tool-call-1.json b/tests/Fixtures/anthropic/generate-text-with-web-search-and-tool-call-1.json new file mode 100644 index 000000000..17feafb7d --- /dev/null +++ b/tests/Fixtures/anthropic/generate-text-with-web-search-and-tool-call-1.json @@ -0,0 +1 @@ +{"id":"msg_01ABC123WebSearchToolCall","type":"message","role":"assistant","model":"claude-3-5-haiku-20241022","content":[{"type":"server_tool_use","id":"srvtoolu_01WebSearch123","name":"web_search","input":{"query":"London weather today"}},{"type":"web_search_tool_result","tool_use_id":"srvtoolu_01WebSearch123","content":[{"type":"web_search_result","title":"London Weather Today","url":"https://www.example.com/london-weather","encrypted_content":"EncryptedContentPlaceholder123","page_age":"1 hour ago"}]},{"type":"text","text":"Based on the search results, London's weather today is around 18°C. ","citations":[{"type":"web_search_result_location","cited_text":"London's current temperature is 18°C with partly cloudy skies.","url":"https://www.example.com/london-weather","title":"London Weather Today","encrypted_index":"EncryptedIndexPlaceholder123"}]},{"type":"text","text":"Let me also check the detailed forecast for you."},{"type":"tool_use","id":"toolu_01WeatherTool123","name":"weather","input":{"city":"London"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":500,"output_tokens":150,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"service_tier":"standard"}} diff --git a/tests/Fixtures/anthropic/generate-text-with-web-search-and-tool-call-2.json b/tests/Fixtures/anthropic/generate-text-with-web-search-and-tool-call-2.json new file mode 100644 index 000000000..f60820aff --- /dev/null +++ b/tests/Fixtures/anthropic/generate-text-with-web-search-and-tool-call-2.json @@ -0,0 +1 @@ +{"id":"msg_01DEF456WebSearchToolCall","type":"message","role":"assistant","model":"claude-3-5-haiku-20241022","content":[{"type":"text","text":"Based on the web search and the detailed forecast, London's weather today is 18°C with partly cloudy skies. The detailed forecast shows a high of 21°C and a low of 14°C, with a chance of light rain in the afternoon."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":700,"output_tokens":60,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"service_tier":"standard"}} diff --git a/tests/Fixtures/anthropic/stream-with-web-search-and-tool-call-1.sse b/tests/Fixtures/anthropic/stream-with-web-search-and-tool-call-1.sse new file mode 100644 index 000000000..7a3bafd0b --- /dev/null +++ b/tests/Fixtures/anthropic/stream-with-web-search-and-tool-call-1.sse @@ -0,0 +1,63 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_01StreamWebSearchToolCall","type":"message","role":"assistant","model":"claude-3-5-haiku-20241022","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":500,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":2}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srvtoolu_01StreamWebSearch","name":"web_search","input":{}}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\": \"London weather today\"}"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_01StreamWebSearch","content":[{"type":"web_search_result","title":"London Weather Today","url":"https://www.example.com/london-weather","encrypted_content":"EncryptedContentPlaceholder123","page_age":"1 hour ago"}]}} + +event: content_block_stop +data: {"type":"content_block_stop","index":1} + +event: content_block_start +data: {"type":"content_block_start","index":2,"content_block":{"citations":[],"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":2,"delta":{"type":"citations_delta","citation":{"type":"web_search_result_location","cited_text":"London's current temperature is 18°C with partly cloudy skies.","url":"https://www.example.com/london-weather","title":"London Weather Today","encrypted_index":"EncryptedIndexPlaceholder123"}}} + +event: content_block_delta +data: {"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"Based on the search results, London"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"'s weather today is around 18°C."}} + +event: content_block_stop +data: {"type":"content_block_stop","index":2} + +event: content_block_start +data: {"type":"content_block_start","index":3,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":" Let me also check the detailed forecast."}} + +event: content_block_stop +data: {"type":"content_block_stop","index":3} + +event: content_block_start +data: {"type":"content_block_start","index":4,"content_block":{"type":"tool_use","id":"toolu_01StreamWeatherTool","name":"weather","input":{}}} + +event: content_block_delta +data: {"type":"content_block_delta","index":4,"delta":{"type":"input_json_delta","partial_json":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":4,"delta":{"type":"input_json_delta","partial_json":"{\"city\": \"London\"}"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":4} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":150,"server_tool_use":{"web_search_requests":1}}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/tests/Fixtures/anthropic/stream-with-web-search-and-tool-call-2.sse b/tests/Fixtures/anthropic/stream-with-web-search-and-tool-call-2.sse new file mode 100644 index 000000000..766cf963c --- /dev/null +++ b/tests/Fixtures/anthropic/stream-with-web-search-and-tool-call-2.sse @@ -0,0 +1,24 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_02StreamWebSearchToolCall","type":"message","role":"assistant","model":"claude-3-5-haiku-20241022","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":700,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":2}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Based on the web search and the detailed forecast, London"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"'s weather today is 18°C with partly cloudy skies."}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" The detailed forecast shows a high of 21°C."}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":60}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/tests/Providers/Anthropic/AnthropicTextTest.php b/tests/Providers/Anthropic/AnthropicTextTest.php index 0773966b2..9188a67eb 100644 --- a/tests/Providers/Anthropic/AnthropicTextTest.php +++ b/tests/Providers/Anthropic/AnthropicTextTest.php @@ -208,6 +208,62 @@ expect($response->toolCalls[0]->name)->toBe('weather'); }); + + it('preserves server tool content blocks during multi-step tool loop with web search', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-web-search-and-tool-call'); + + $tools = [ + Tool::as('weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'The city that you want the weather for') + ->using(fn (string $city): string => 'The weather will be 21° and partly cloudy'), + ]; + + $response = Prism::text() + ->using('anthropic', 'claude-3-5-haiku-latest') + ->withTools($tools) + ->withProviderTools([new ProviderTool(type: 'web_search_20250305', name: 'web_search')]) + ->withMaxSteps(3) + ->withPrompt('What is the weather in London?') + ->asText(); + + // The tool loop should complete without a "Could not find search result for citation index" error + expect($response->steps)->toHaveCount(2); + expect($response->text)->toContain('18°C'); + + // Step 1 should have the tool call and citations in additionalContent + $firstStep = $response->steps[0]; + expect($firstStep->toolCalls)->toHaveCount(1); + expect($firstStep->toolCalls[0]->name)->toBe('weather'); + expect($firstStep->additionalContent)->toHaveKey('citations'); + expect($firstStep->additionalContent)->toHaveKey('provider_tool_calls'); + expect($firstStep->additionalContent)->toHaveKey('provider_tool_results'); + + // The assistant message replayed in step 2 should contain the server tool blocks + $secondStep = $response->steps[1]; + expect($secondStep->messages)->toHaveCount(3); + expect($secondStep->messages[1])->toBeInstanceOf(AssistantMessage::class); + expect($secondStep->messages[1]->additionalContent)->toHaveKey('citations'); + expect($secondStep->messages[1]->additionalContent)->toHaveKey('provider_tool_calls'); + expect($secondStep->messages[1]->additionalContent)->toHaveKey('provider_tool_results'); + + // Verify the second HTTP request includes server tool blocks in the replayed assistant message + $requests = Http::recorded(); + expect($requests)->toHaveCount(2); + + $secondRequestPayload = $requests[1][0]->data(); + $assistantMessage = Arr::first( + $secondRequestPayload['messages'], + fn (array $msg): bool => $msg['role'] === 'assistant' + ); + + // The assistant message should contain server_tool_use and web_search_tool_result blocks + $contentTypes = array_column($assistantMessage['content'], 'type'); + expect($contentTypes)->toContain('server_tool_use'); + expect($contentTypes)->toContain('web_search_tool_result'); + expect($contentTypes)->toContain('text'); + expect($contentTypes)->toContain('tool_use'); + }); }); it('can calculate cache usage correctly', function (): void { diff --git a/tests/Providers/Anthropic/StreamTest.php b/tests/Providers/Anthropic/StreamTest.php index 6927430ac..b00ffce48 100644 --- a/tests/Providers/Anthropic/StreamTest.php +++ b/tests/Providers/Anthropic/StreamTest.php @@ -401,6 +401,56 @@ expect($providerToolResults[0]['content'])->toBeArray(); expect($providerToolResults[0]['tool_use_id'])->toBe($providerToolUses[0]['id']); }); + + it('preserves server tool content blocks during multi-step tool loop with web search', function (): void { + FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-web-search-and-tool-call'); + + $tools = [ + Tool::as('weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'The city that you want the weather for') + ->using(fn (string $city): string => 'The weather will be 21° and partly cloudy'), + ]; + + $response = Prism::text() + ->using(Provider::Anthropic, 'claude-3-5-haiku-20241022') + ->withTools($tools) + ->withProviderTools([new ProviderTool(type: 'web_search_20250305', name: 'web_search')]) + ->withMaxSteps(3) + ->withPrompt('What is the weather in London?') + ->asStream(); + + $text = ''; + $events = []; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof TextDeltaEvent) { + $text .= $event->delta; + } + } + + // The tool loop should complete without a "Could not find search result for citation index" error + $lastEvent = end($events); + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->finishReason)->toBe(FinishReason::Stop); + expect($text)->toContain('18°C'); + + // Verify the second HTTP request includes server tool blocks in the replayed assistant message + $requests = Http::recorded(); + expect($requests)->toHaveCount(2); + + $secondRequestPayload = $requests[1][0]->data(); + $assistantMessage = collect($secondRequestPayload['messages']) + ->first(fn (array $msg): bool => $msg['role'] === 'assistant'); + + $contentTypes = array_column($assistantMessage['content'], 'type'); + expect($contentTypes)->toContain('server_tool_use'); + expect($contentTypes)->toContain('web_search_tool_result'); + expect($contentTypes)->toContain('text'); + expect($contentTypes)->toContain('tool_use'); + }); }); describe('citations', function (): void {