mirror of
https://github.com/symfony/ai.git
synced 2026-03-23 23:42:18 +01:00
[Platform][Anthropic] Handle tool calls in streaming mode
This commit is contained in:
committed by
Christopher Hertel
parent
6984b8ff1d
commit
97c2563673
41
examples/anthropic/toolcall-stream.php
Normal file
41
examples/anthropic/toolcall-stream.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
use Symfony\AI\Agent\Agent;
|
||||
use Symfony\AI\Agent\Bridge\Wikipedia\Wikipedia;
|
||||
use Symfony\AI\Agent\Toolbox\AgentProcessor;
|
||||
use Symfony\AI\Agent\Toolbox\Toolbox;
|
||||
use Symfony\AI\Platform\Bridge\Anthropic\PlatformFactory;
|
||||
use Symfony\AI\Platform\Message\Message;
|
||||
use Symfony\AI\Platform\Message\MessageBag;
|
||||
|
||||
require_once dirname(__DIR__).'/bootstrap.php';
|
||||
|
||||
$platform = PlatformFactory::create(env('ANTHROPIC_API_KEY'), httpClient: http_client());
|
||||
|
||||
$wikipedia = new Wikipedia(http_client());
|
||||
$toolbox = new Toolbox([$wikipedia], logger: logger());
|
||||
$processor = new AgentProcessor($toolbox);
|
||||
$agent = new Agent($platform, 'claude-sonnet-4-5-20250929', [$processor], [$processor]);
|
||||
$messages = new MessageBag(Message::ofUser(<<<TXT
|
||||
First, define unicorn in 30 words.
|
||||
Then lookup at Wikipedia what the irish history looks like in 2 sentences.
|
||||
Please tell me before you call tools.
|
||||
TXT));
|
||||
$result = $agent->call($messages, [
|
||||
'stream' => true, // enable streaming of response text
|
||||
]);
|
||||
|
||||
foreach ($result->getContent() as $word) {
|
||||
echo $word;
|
||||
}
|
||||
|
||||
echo \PHP_EOL;
|
||||
@@ -84,12 +84,60 @@ class ResultConverter implements ResultConverterInterface
|
||||
|
||||
private function convertStream(RawResultInterface $result): \Generator
|
||||
{
|
||||
$toolCalls = [];
|
||||
$currentToolCall = null;
|
||||
$currentToolCallJson = '';
|
||||
|
||||
foreach ($result->getDataStream() as $data) {
|
||||
if ('content_block_delta' != $data['type'] || !isset($data['delta']['text'])) {
|
||||
$type = $data['type'] ?? '';
|
||||
|
||||
// Handle text content deltas
|
||||
if ('content_block_delta' === $type && isset($data['delta']['text'])) {
|
||||
yield $data['delta']['text'];
|
||||
continue;
|
||||
}
|
||||
|
||||
yield $data['delta']['text'];
|
||||
// Handle tool_use content block start
|
||||
if ('content_block_start' === $type
|
||||
&& isset($data['content_block']['type'])
|
||||
&& 'tool_use' === $data['content_block']['type']
|
||||
) {
|
||||
$currentToolCall = [
|
||||
'id' => $data['content_block']['id'],
|
||||
'name' => $data['content_block']['name'],
|
||||
];
|
||||
$currentToolCallJson = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle tool_use input JSON deltas
|
||||
if ('content_block_delta' === $type
|
||||
&& isset($data['delta']['type'])
|
||||
&& 'input_json_delta' === $data['delta']['type']
|
||||
) {
|
||||
$currentToolCallJson .= $data['delta']['partial_json'] ?? '';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle content block stop - finalize current tool call
|
||||
if ('content_block_stop' === $type && null !== $currentToolCall) {
|
||||
$input = '' !== $currentToolCallJson
|
||||
? json_decode($currentToolCallJson, true, flags: \JSON_THROW_ON_ERROR)
|
||||
: [];
|
||||
$toolCalls[] = new ToolCall(
|
||||
$currentToolCall['id'],
|
||||
$currentToolCall['name'],
|
||||
$input
|
||||
);
|
||||
$currentToolCall = null;
|
||||
$currentToolCallJson = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle message stop - yield tool calls if any were collected
|
||||
if ('message_stop' === $type && [] !== $toolCalls) {
|
||||
yield new ToolCallResult(...$toolCalls);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,13 @@ use PHPUnit\Framework\TestCase;
|
||||
use Symfony\AI\Platform\Bridge\Anthropic\ResultConverter;
|
||||
use Symfony\AI\Platform\Exception\RuntimeException;
|
||||
use Symfony\AI\Platform\Result\RawHttpResult;
|
||||
use Symfony\AI\Platform\Result\RawResultInterface;
|
||||
use Symfony\AI\Platform\Result\StreamResult;
|
||||
use Symfony\AI\Platform\Result\ToolCallResult;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\JsonMockResponse;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
final class ResultConverterTest extends TestCase
|
||||
{
|
||||
@@ -74,4 +77,137 @@ final class ResultConverterTest extends TestCase
|
||||
|
||||
$converter->convert(new RawHttpResult($response));
|
||||
}
|
||||
|
||||
public function testStreamingToolCallsYieldsToolCallResult()
|
||||
{
|
||||
$converter = new ResultConverter();
|
||||
|
||||
$httpResponse = self::createMock(ResponseInterface::class);
|
||||
$httpResponse->method('getStatusCode')->willReturn(200);
|
||||
|
||||
$events = [
|
||||
['type' => 'message_start', 'message' => ['id' => 'msg_123', 'type' => 'message', 'role' => 'assistant', 'content' => []]],
|
||||
['type' => 'content_block_start', 'index' => 0, 'content_block' => ['type' => 'tool_use', 'id' => 'toolu_01ABC123', 'name' => 'get_weather']],
|
||||
['type' => 'content_block_delta', 'index' => 0, 'delta' => ['type' => 'input_json_delta', 'partial_json' => '{"loc']],
|
||||
['type' => 'content_block_delta', 'index' => 0, 'delta' => ['type' => 'input_json_delta', 'partial_json' => 'ation": "']],
|
||||
['type' => 'content_block_delta', 'index' => 0, 'delta' => ['type' => 'input_json_delta', 'partial_json' => 'Berlin"}']],
|
||||
['type' => 'content_block_stop', 'index' => 0],
|
||||
['type' => 'message_delta', 'delta' => ['stop_reason' => 'tool_use']],
|
||||
['type' => 'message_stop'],
|
||||
];
|
||||
|
||||
$raw = new class($httpResponse, $events) implements RawResultInterface {
|
||||
/**
|
||||
* @param array<array<string, mixed>> $events
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ResponseInterface $response,
|
||||
private readonly array $events,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getData(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getDataStream(): iterable
|
||||
{
|
||||
foreach ($this->events as $event) {
|
||||
yield $event;
|
||||
}
|
||||
}
|
||||
|
||||
public function getObject(): object
|
||||
{
|
||||
return $this->response;
|
||||
}
|
||||
};
|
||||
|
||||
$streamResult = $converter->convert($raw, ['stream' => true]);
|
||||
|
||||
$this->assertInstanceOf(StreamResult::class, $streamResult);
|
||||
|
||||
$chunks = [];
|
||||
foreach ($streamResult->getContent() as $part) {
|
||||
$chunks[] = $part;
|
||||
}
|
||||
|
||||
$this->assertCount(1, $chunks);
|
||||
$this->assertInstanceOf(ToolCallResult::class, $chunks[0]);
|
||||
|
||||
$toolCalls = $chunks[0]->getContent();
|
||||
$this->assertCount(1, $toolCalls);
|
||||
$this->assertSame('toolu_01ABC123', $toolCalls[0]->getId());
|
||||
$this->assertSame('get_weather', $toolCalls[0]->getName());
|
||||
$this->assertSame(['location' => 'Berlin'], $toolCalls[0]->getArguments());
|
||||
}
|
||||
|
||||
public function testStreamingTextAndToolCallsYieldsBoth()
|
||||
{
|
||||
$converter = new ResultConverter();
|
||||
|
||||
$httpResponse = self::createMock(ResponseInterface::class);
|
||||
$httpResponse->method('getStatusCode')->willReturn(200);
|
||||
|
||||
$events = [
|
||||
['type' => 'message_start', 'message' => ['id' => 'msg_123', 'type' => 'message', 'role' => 'assistant', 'content' => []]],
|
||||
['type' => 'content_block_start', 'index' => 0, 'content_block' => ['type' => 'text', 'text' => '']],
|
||||
['type' => 'content_block_delta', 'index' => 0, 'delta' => ['type' => 'text_delta', 'text' => 'Let me check ']],
|
||||
['type' => 'content_block_delta', 'index' => 0, 'delta' => ['type' => 'text_delta', 'text' => 'the weather.']],
|
||||
['type' => 'content_block_stop', 'index' => 0],
|
||||
['type' => 'content_block_start', 'index' => 1, 'content_block' => ['type' => 'tool_use', 'id' => 'toolu_01XYZ789', 'name' => 'get_weather']],
|
||||
['type' => 'content_block_delta', 'index' => 1, 'delta' => ['type' => 'input_json_delta', 'partial_json' => '{"city": "Munich"}']],
|
||||
['type' => 'content_block_stop', 'index' => 1],
|
||||
['type' => 'message_delta', 'delta' => ['stop_reason' => 'tool_use']],
|
||||
['type' => 'message_stop'],
|
||||
];
|
||||
|
||||
$raw = new class($httpResponse, $events) implements RawResultInterface {
|
||||
/**
|
||||
* @param array<array<string, mixed>> $events
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ResponseInterface $response,
|
||||
private readonly array $events,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getData(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getDataStream(): iterable
|
||||
{
|
||||
foreach ($this->events as $event) {
|
||||
yield $event;
|
||||
}
|
||||
}
|
||||
|
||||
public function getObject(): object
|
||||
{
|
||||
return $this->response;
|
||||
}
|
||||
};
|
||||
|
||||
$streamResult = $converter->convert($raw, ['stream' => true]);
|
||||
|
||||
$this->assertInstanceOf(StreamResult::class, $streamResult);
|
||||
|
||||
$chunks = [];
|
||||
foreach ($streamResult->getContent() as $part) {
|
||||
$chunks[] = $part;
|
||||
}
|
||||
|
||||
$this->assertSame('Let me check ', $chunks[0]);
|
||||
$this->assertSame('the weather.', $chunks[1]);
|
||||
$this->assertInstanceOf(ToolCallResult::class, $chunks[2]);
|
||||
|
||||
$toolCalls = $chunks[2]->getContent();
|
||||
$this->assertCount(1, $toolCalls);
|
||||
$this->assertSame('toolu_01XYZ789', $toolCalls[0]->getId());
|
||||
$this->assertSame('get_weather', $toolCalls[0]->getName());
|
||||
$this->assertSame(['city' => 'Munich'], $toolCalls[0]->getArguments());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user