Skip to content

Commit 9713edc

Browse files
committed
fix and refactor
1 parent 44ccc3c commit 9713edc

File tree

5 files changed

+191
-39
lines changed

5 files changed

+191
-39
lines changed

src/Agents/Stages/HandleToolCalls.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ public function __construct(
3939
public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed
4040
{
4141
return match (true) {
42-
$payload instanceof ChatGenerationChunk && $payload->type === ChunkType::ToolInputEnd => $this->handleStreamingChunk($payload, $config, $next),
42+
// We trigger on isFinal (not ToolInputEnd) so that AddMessageToMemoryMiddleware
43+
// has already added the assistant message before we process tool calls.
44+
$payload instanceof ChatGenerationChunk && $payload->isFinal && $payload->message->hasToolCalls() => $this->handleStreamingChunk($payload, $config, $next),
4345
$payload instanceof ChatStreamResult => $this->handleStreamingResult($payload),
4446
default => $this->handleNonStreaming($payload, $config, $next),
4547
};
@@ -158,7 +160,7 @@ protected function getGeneration(mixed $payload): ChatGeneration|ChatGenerationC
158160
{
159161
return match (true) {
160162
$payload instanceof ChatGeneration => $payload,
161-
$payload instanceof ChatGenerationChunk && $payload->type === ChunkType::ToolInputEnd => $payload,
163+
$payload instanceof ChatGenerationChunk && $payload->isFinal && $payload->message->hasToolCalls() => $payload,
162164
$payload instanceof ChatResult => $payload->generation,
163165
default => null,
164166
};

src/LLM/Drivers/Anthropic/AnthropicChat.php

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,6 @@ protected function buildParams(array $additionalParameters): array
9898

9999
if ($this->structuredOutputConfig !== null) {
100100
$this->structuredOutputMode = StructuredOutputMode::Tool;
101-
$params['betas'] ??= [];
102-
$params['betas'][] = 'structured-outputs-2025-11-13';
103101

104102
$params['output_format'] = [
105103
'type' => 'json_schema',
@@ -148,6 +146,11 @@ protected function buildParams(array $additionalParameters): array
148146
throw new LLMException('`max_tokens` parameter is required for Anthropic.');
149147
}
150148

149+
if ($this->structuredOutputConfig !== null) {
150+
$finalParams['betas'] ??= [];
151+
$finalParams['betas'][] = 'structured-outputs-2025-11-13';
152+
}
153+
151154
return $finalParams;
152155
}
153156

@@ -162,10 +165,15 @@ public static function fake(
162165
): self {
163166
$client = Anthropic::fake($responses, $apiKey);
164167

165-
return new self(
168+
$instance = new self(
166169
$client,
167170
$model ?? 'claude-4-5-sonnet-20250926',
168171
$modelProvider ?? ModelProvider::Anthropic,
169172
);
173+
174+
// Set a default max_tokens for testing
175+
$instance->withMaxTokens(8096);
176+
177+
return $instance;
170178
}
171179
}

src/LLM/Drivers/Anthropic/Concerns/MapStreamResponse.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,10 +316,10 @@ private function buildChunk(
316316
finishReason: $finishReason,
317317
usage: $usage,
318318
processingTime: $meta?->processingTime,
319-
providerMetadata: $meta?->raw ?? [],
319+
providerMetadata: $meta->raw ?? [],
320320
),
321321
),
322-
createdAt: $meta?->createdAt ?? new DateTimeImmutable(),
322+
createdAt: $meta->createdAt ?? new DateTimeImmutable(),
323323
finishReason: $finishReason,
324324
usage: $usage,
325325
contentSoFar: $this->buildContentSnapshot(),

src/LLM/Drivers/Anthropic/Concerns/MapsMessages.php

Lines changed: 55 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66

77
use Cortex\Support\DataUrl;
88
use Cortex\LLM\Contracts\Message;
9-
use Cortex\ModelInfo\Enums\ModelFeature;
109
use Cortex\LLM\Enums\MessageRole;
10+
use Cortex\ModelInfo\Enums\ModelFeature;
1111
use Cortex\LLM\Data\Messages\ToolMessage;
1212
use Cortex\LLM\Data\Messages\AssistantMessage;
1313
use Cortex\LLM\Data\Messages\MessageCollection;
@@ -20,49 +20,80 @@ trait MapsMessages
2020
/**
2121
* Map the given messages to the Anthropic API format.
2222
*
23+
* Anthropic requires consecutive tool results to be merged into a single
24+
* user message, as it doesn't allow consecutive messages with the same role.
25+
*
2326
* @return array<int, array<string, mixed>>
2427
*/
2528
protected function mapMessagesForInput(MessageCollection $messages): array
2629
{
27-
return $messages
28-
->map(fn (Message $message): array => $this->mapMessage($message))
29-
->values()
30-
->toArray();
30+
$mapped = [];
31+
$pendingToolResults = [];
32+
33+
foreach ($messages as $message) {
34+
if ($message instanceof ToolMessage) {
35+
$pendingToolResults[] = $this->mapToolResultBlock($message);
36+
37+
continue;
38+
}
39+
40+
// Flush any pending tool results before adding the next message
41+
if ($pendingToolResults !== []) {
42+
$mapped[] = $this->createToolResultsMessage($pendingToolResults);
43+
$pendingToolResults = [];
44+
}
45+
46+
$mapped[] = $this->mapMessage($message);
47+
}
48+
49+
// Flush any remaining tool results at the end
50+
if ($pendingToolResults !== []) {
51+
$mapped[] = $this->createToolResultsMessage($pendingToolResults);
52+
}
53+
54+
return $mapped;
3155
}
3256

3357
/**
58+
* Create a single user message containing all tool results.
59+
*
60+
* @param array<int, array<string, mixed>> $toolResults
61+
*
3462
* @return array<string, mixed>
3563
*/
36-
private function mapMessage(Message $message): array
64+
private function createToolResultsMessage(array $toolResults): array
3765
{
38-
return match (true) {
39-
$message instanceof ToolMessage => $this->mapToolMessage($message),
40-
$message instanceof AssistantMessage => $this->mapAssistantMessage($message),
41-
default => $this->mapGenericMessage($message),
42-
};
66+
return [
67+
'role' => MessageRole::User->value,
68+
'content' => $toolResults,
69+
];
4370
}
4471

4572
/**
46-
* Map a tool message (tool result) to Anthropic format.
47-
*
48-
* Tool results must be sent as a user message with tool_result content blocks.
73+
* Map a tool message to a tool_result content block.
4974
*
5075
* @return array<string, mixed>
5176
*/
52-
private function mapToolMessage(ToolMessage $message): array
77+
private function mapToolResultBlock(ToolMessage $message): array
5378
{
5479
return [
55-
'role' => MessageRole::User->value,
56-
'content' => [
57-
[
58-
'type' => 'tool_result',
59-
'tool_use_id' => $message->id,
60-
'content' => $message->text() ?? '',
61-
],
62-
],
80+
'type' => 'tool_result',
81+
'tool_use_id' => $message->id,
82+
'content' => $message->text() ?? '',
6383
];
6484
}
6585

86+
/**
87+
* @return array<string, mixed>
88+
*/
89+
private function mapMessage(Message $message): array
90+
{
91+
return match (true) {
92+
$message instanceof AssistantMessage => $this->mapAssistantMessage($message),
93+
default => $this->mapGenericMessage($message),
94+
};
95+
}
96+
6697
/**
6798
* Map an assistant message to Anthropic format.
6899
*
@@ -130,7 +161,7 @@ private function mapMessageContent(string|array|null $content): array
130161
}
131162

132163
return array_values(array_filter(
133-
array_map(fn (mixed $item): ?array => $this->mapContentBlock($item), $content),
164+
array_map(fn(mixed $item): ?array => $this->mapContentBlock($item), $content),
134165
));
135166
}
136167

@@ -196,4 +227,3 @@ private function isDataUrl(string $url): bool
196227
return str_starts_with($url, 'data:');
197228
}
198229
}
199-

tests/Unit/LLM/Drivers/Anthropic/AnthropicChatTest.php

Lines changed: 119 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,9 @@ public function __construct(
498498
new UserMessage('What is the weather?'),
499499
new AssistantMessage(
500500
toolCalls: new ToolCallCollection([
501-
new ToolCall('tool_123', new FunctionCall('get_weather', ['location' => 'London'])),
501+
new ToolCall('tool_123', new FunctionCall('get_weather', [
502+
'location' => 'London',
503+
])),
502504
]),
503505
),
504506
new ToolMessage('The weather in London is sunny and 22°C', 'tool_123'),
@@ -527,7 +529,9 @@ public function __construct(
527529
new AssistantMessage(
528530
content: 'Let me check the weather for you.',
529531
toolCalls: new ToolCallCollection([
530-
new ToolCall('tool_abc', new FunctionCall('get_weather', ['location' => 'Paris'])),
532+
new ToolCall('tool_abc', new FunctionCall('get_weather', [
533+
'location' => 'Paris',
534+
])),
531535
]),
532536
),
533537
new ToolMessage('Rainy, 15°C', 'tool_abc'),
@@ -551,7 +555,9 @@ public function __construct(
551555
if ($block['type'] === 'tool_use'
552556
&& $block['id'] === 'tool_abc'
553557
&& $block['name'] === 'get_weather'
554-
&& $block['input'] === ['location' => 'Paris']) {
558+
&& $block['input'] === [
559+
'location' => 'Paris',
560+
]) {
555561
$hasToolUse = true;
556562
}
557563
}
@@ -569,8 +575,12 @@ public function __construct(
569575
new UserMessage('Compare weather in London and Paris'),
570576
new AssistantMessage(
571577
toolCalls: new ToolCallCollection([
572-
new ToolCall('tool_1', new FunctionCall('get_weather', ['location' => 'London'])),
573-
new ToolCall('tool_2', new FunctionCall('get_weather', ['location' => 'Paris'])),
578+
new ToolCall('tool_1', new FunctionCall('get_weather', [
579+
'location' => 'London',
580+
])),
581+
new ToolCall('tool_2', new FunctionCall('get_weather', [
582+
'location' => 'Paris',
583+
])),
574584
]),
575585
),
576586
new ToolMessage('Sunny, 20°C', 'tool_1'),
@@ -585,7 +595,7 @@ public function __construct(
585595
// Should have two tool_use blocks
586596
$toolUseBlocks = array_filter(
587597
$assistantMessage['content'],
588-
fn ($block) => $block['type'] === 'tool_use',
598+
fn(array $block): bool => $block['type'] === 'tool_use',
589599
);
590600

591601
return count($toolUseBlocks) === 2;
@@ -658,7 +668,7 @@ public function __construct(
658668
$llm->addFeature(ModelFeature::Vision);
659669

660670
$base64Data = base64_encode('fake-image-data');
661-
$dataUrl = "data:image/png;base64,{$base64Data}";
671+
$dataUrl = 'data:image/png;base64,' . $base64Data;
662672

663673
$llm->invoke([
664674
new UserMessage([
@@ -725,3 +735,105 @@ public function __construct(
725735
&& $assistantMessage['content'][0]['text'] === 'Hi there!';
726736
});
727737
});
738+
739+
test('it merges consecutive tool messages into a single user message', function (): void {
740+
$llm = AnthropicChat::fake([
741+
CreateMessage::class => MockResponse::fixture('anthropic/messages/simple'),
742+
]);
743+
744+
$llm->invoke([
745+
new UserMessage('Compare weather in London and Paris'),
746+
new AssistantMessage(
747+
toolCalls: new ToolCallCollection([
748+
new ToolCall('tool_1', new FunctionCall('get_weather', [
749+
'location' => 'London',
750+
])),
751+
new ToolCall('tool_2', new FunctionCall('get_weather', [
752+
'location' => 'Paris',
753+
])),
754+
]),
755+
),
756+
new ToolMessage('Sunny, 20°C', 'tool_1'),
757+
new ToolMessage('Rainy, 15°C', 'tool_2'),
758+
]);
759+
760+
MockClient::getGlobal()->assertSent(function (CreateMessage $request): bool {
761+
$messages = $request->body()->get('messages');
762+
763+
// Should have exactly 3 messages: user, assistant, user (merged tool results)
764+
if (count($messages) !== 3) {
765+
return false;
766+
}
767+
768+
// Third message should be a single user message with both tool results
769+
$toolResultsMessage = $messages[2];
770+
771+
if ($toolResultsMessage['role'] !== 'user') {
772+
return false;
773+
}
774+
775+
// Should have 2 tool_result content blocks
776+
if (count($toolResultsMessage['content']) !== 2) {
777+
return false;
778+
}
779+
780+
$firstResult = $toolResultsMessage['content'][0];
781+
$secondResult = $toolResultsMessage['content'][1];
782+
783+
return $firstResult['type'] === 'tool_result'
784+
&& $firstResult['tool_use_id'] === 'tool_1'
785+
&& $firstResult['content'] === 'Sunny, 20°C'
786+
&& $secondResult['type'] === 'tool_result'
787+
&& $secondResult['tool_use_id'] === 'tool_2'
788+
&& $secondResult['content'] === 'Rainy, 15°C';
789+
});
790+
});
791+
792+
test('it does not merge non-consecutive tool messages', function (): void {
793+
$llm = AnthropicChat::fake([
794+
CreateMessage::class => MockResponse::fixture('anthropic/messages/simple'),
795+
]);
796+
797+
$llm->invoke([
798+
new UserMessage('What is the weather?'),
799+
new AssistantMessage(
800+
toolCalls: new ToolCallCollection([
801+
new ToolCall('tool_1', new FunctionCall('get_weather', [
802+
'location' => 'London',
803+
])),
804+
]),
805+
),
806+
new ToolMessage('Sunny', 'tool_1'),
807+
new AssistantMessage('The weather in London is sunny. Want to check another city?'),
808+
new UserMessage('Yes, check Paris'),
809+
new AssistantMessage(
810+
toolCalls: new ToolCallCollection([
811+
new ToolCall('tool_2', new FunctionCall('get_weather', [
812+
'location' => 'Paris',
813+
])),
814+
]),
815+
),
816+
new ToolMessage('Rainy', 'tool_2'),
817+
]);
818+
819+
MockClient::getGlobal()->assertSent(function (CreateMessage $request): bool {
820+
$messages = $request->body()->get('messages');
821+
822+
// Should have 7 messages (tool messages not merged because they're not consecutive)
823+
if (count($messages) !== 7) {
824+
return false;
825+
}
826+
827+
// First tool result at index 2
828+
$firstToolResult = $messages[2];
829+
// Second tool result at index 6
830+
$secondToolResult = $messages[6];
831+
832+
return $firstToolResult['role'] === 'user'
833+
&& count($firstToolResult['content']) === 1
834+
&& $firstToolResult['content'][0]['tool_use_id'] === 'tool_1'
835+
&& $secondToolResult['role'] === 'user'
836+
&& count($secondToolResult['content']) === 1
837+
&& $secondToolResult['content'][0]['tool_use_id'] === 'tool_2';
838+
});
839+
});

0 commit comments

Comments
 (0)