Skip to content
Open
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
11 changes: 7 additions & 4 deletions src/Providers/Anthropic/Handlers/Stream.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
42 changes: 42 additions & 0 deletions src/Providers/Anthropic/Handlers/Text.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $data
* @return array<string, array<int, array<string, mixed>>>
*/
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<int|string,mixed>
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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"}}
Original file line number Diff line number Diff line change
@@ -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"}}
Original file line number Diff line number Diff line change
@@ -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"}

Original file line number Diff line number Diff line change
@@ -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"}

56 changes: 56 additions & 0 deletions tests/Providers/Anthropic/AnthropicTextTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
50 changes: 50 additions & 0 deletions tests/Providers/Anthropic/StreamTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading