diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index e0e2e71b..949eba09 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -206,45 +206,63 @@ protected function prepareGenerateTextParams(array $prompt): array */ protected function prepareMessagesParam(array $messages, ?string $systemInstruction = null): array { - $messagesParam = array_map( - function (Message $message): array { - // Special case: Function response. - $messageParts = $message->getParts(); - if (count($messageParts) === 1 && $messageParts[0]->getType()->isFunctionResponse()) { - $functionResponse = $messageParts[0]->getFunctionResponse(); + $messagesParam = []; + foreach ($messages as $message) { + $messageParts = $message->getParts(); + + /* + * Special case: Function responses. The API expects one message of role 'tool' + * per function response, so a message carrying several of them (as produced by + * a parallel tool call) expands into several messages. + */ + $functionResponseParts = array_values(array_filter( + $messageParts, + static function (MessagePart $part): bool { + return $part->getType()->isFunctionResponse(); + } + )); + if (count($functionResponseParts) > 0) { + if (count($functionResponseParts) !== count($messageParts)) { + throw new InvalidArgumentException( + 'The API only allows function responses as the only content of the message.' + ); + } + foreach ($functionResponseParts as $functionResponsePart) { + $functionResponse = $functionResponsePart->getFunctionResponse(); if (!$functionResponse) { // This should be impossible due to class internals, but still needs to be checked. throw new RuntimeException( 'The function response typed message part must contain a function response.' ); } - return [ + $messagesParam[] = [ 'role' => 'tool', 'content' => json_encode($functionResponse->getResponse()), 'tool_call_id' => $functionResponse->getId(), ]; } - $messageData = [ - 'role' => $this->getMessageRoleString($message->getRole()), - 'content' => array_values(array_filter(array_map( - [$this, 'getMessagePartContentData'], - $messageParts - ))), - ]; + continue; + } - // Only include tool_calls if there are any (OpenAI rejects empty arrays). - $toolCalls = array_values(array_filter(array_map( - [$this, 'getMessagePartToolCallData'], + $messageData = [ + 'role' => $this->getMessageRoleString($message->getRole()), + 'content' => array_values(array_filter(array_map( + [$this, 'getMessagePartContentData'], $messageParts - ))); - if (!empty($toolCalls)) { - $messageData['tool_calls'] = $toolCalls; - } + ))), + ]; - return $messageData; - }, - $messages - ); + // Only include tool_calls if there are any (OpenAI rejects empty arrays). + $toolCalls = array_values(array_filter(array_map( + [$this, 'getMessagePartToolCallData'], + $messageParts + ))); + if (!empty($toolCalls)) { + $messageData['tool_calls'] = $toolCalls; + } + + $messagesParam[] = $messageData; + } if ($systemInstruction) { array_unshift( diff --git a/tests/integration/ParallelToolCallIntegrationTest.php b/tests/integration/ParallelToolCallIntegrationTest.php new file mode 100644 index 00000000..586ae48b --- /dev/null +++ b/tests/integration/ParallelToolCallIntegrationTest.php @@ -0,0 +1,232 @@ + + */ + public function parallelToolCallProvider(): array + { + return [ + 'anthropic' => ['anthropic', 'ANTHROPIC_API_KEY'], + 'google' => ['google', 'GOOGLE_API_KEY'], + ]; + } + + /** + * Tests that several function responses in one message can be sent back. + * + * @dataProvider parallelToolCallProvider + */ + public function testParallelFunctionResponsesInSingleMessage(string $providerId, string $envVar): void + { + $this->requireApiKey($envVar); + + $task = 'What is the weather and the current local time in Tokyo? ' + . 'Call both get_weather and get_time before answering.'; + + $result1 = AiClient::prompt($task) + ->usingProvider($providerId) + ->usingFunctionDeclarations(...$this->functionDeclarations()) + ->generateTextResult(); + + $modelMessage = $result1->toMessage(); + $functionCalls = $this->extractFunctionCalls($modelMessage); + + if (count($functionCalls) < 2) { + $this->markTestSkipped( + sprintf( + 'The %s model returned %d tool call(s); this test needs a parallel tool call.', + $providerId, + count($functionCalls) + ) + ); + } + + // Build one user message holding every function response, via the public builder API. + $builder = AiClient::prompt() + ->usingProvider($providerId) + ->withHistory( + new UserMessage([new MessagePart($task)]), + new ModelMessage($this->sendableParts($modelMessage)) + ) + ->usingFunctionDeclarations(...$this->functionDeclarations()); + + foreach ($functionCalls as $functionCall) { + $builder->withFunctionResponse( + new FunctionResponse( + $functionCall->getId() ?? 'call_' . $functionCall->getName(), + (string) $functionCall->getName(), + $this->fakeToolResult((string) $functionCall->getName()) + ) + ); + } + + // The builder has now collapsed every response into one user message; that shape is + // pinned by PromptBuilderTest::testWithFunctionResponseCollapsesMultipleResponsesIntoOneMessage(). + try { + $responseText = $builder->generateTextResult()->toText(); + } catch (ClientException $e) { + /* + * The Google provider never round-trips MessagePart::getThoughtSignature(), and Gemini + * rejects any echoed functionCall part that lacks one. That breaks every multi-turn + * function call, single responses included: Google's own + * FunctionCallingIntegrationTest::testMultiTurnFunctionCalling() fails the same way. + * It happens before the function responses are even considered, so this provider + * cannot answer the question this test asks until that gap is fixed. + */ + if (strpos($e->getMessage(), 'thought_signature') !== false) { + $this->markTestSkipped( + sprintf( + 'The %s provider cannot round-trip tool calls yet: %s', + $providerId, + $e->getMessage() + ) + ); + } + throw $e; + } + + $this->assertNotEmpty($responseText, 'Expected a text response that uses both tool results'); + $this->assertTrue( + stripos($responseText, '22') !== false || stripos($responseText, 'sunny') !== false, + 'Expected the model to use the get_weather result. Got: ' . $responseText + ); + $this->assertTrue( + stripos($responseText, '14:30') !== false || stripos($responseText, '2:30') !== false, + 'Expected the model to use the get_time result. Got: ' . $responseText + ); + } + + /** + * The two tools used to provoke a parallel tool call. + * + * @return list + */ + private function functionDeclarations(): array + { + return [ + new FunctionDeclaration( + 'get_weather', + 'Get the current weather for a location', + [ + 'type' => 'object', + 'properties' => ['location' => ['type' => 'string', 'description' => 'City name']], + 'required' => ['location'], + ] + ), + new FunctionDeclaration( + 'get_time', + 'Get the current local time for a location', + [ + 'type' => 'object', + 'properties' => ['location' => ['type' => 'string', 'description' => 'City name']], + 'required' => ['location'], + ] + ), + ]; + } + + /** + * Returns every function call in a message, in order. + * + * @return list + */ + private function extractFunctionCalls(Message $message): array + { + $functionCalls = []; + foreach ($message->getParts() as $part) { + if ($part->getType()->isFunctionCall()) { + $functionCall = $part->getFunctionCall(); + if ($functionCall instanceof FunctionCall) { + $functionCalls[] = $functionCall; + } + } + } + + return $functionCalls; + } + + /** + * Strips parts that cannot be sent back to the provider. + * + * The Anthropic provider cannot round-trip thinking blocks. + * + * @return list + */ + private function sendableParts(Message $message): array + { + $parts = []; + foreach ($message->getParts() as $part) { + if ($part->getChannel()->isThought()) { + continue; + } + $parts[] = $part; + } + + return $parts; + } + + /** + * Canned tool results, so the assertions can look for known values. + * + * @return array + */ + private function fakeToolResult(string $functionName): array + { + if ($functionName === 'get_time') { + return ['time' => '14:30', 'timezone' => 'Asia/Tokyo']; + } + + return ['temperature' => 22, 'unit' => 'celsius', 'condition' => 'sunny']; + } +} diff --git a/tests/unit/Builders/PromptBuilderTest.php b/tests/unit/Builders/PromptBuilderTest.php index 23a58959..79d1e576 100644 --- a/tests/unit/Builders/PromptBuilderTest.php +++ b/tests/unit/Builders/PromptBuilderTest.php @@ -609,6 +609,39 @@ public function testWithFunctionResponse(): void $this->assertSame($functionResponse, $messages[0]->getParts()[0]->getFunctionResponse()); } + /** + * Tests that repeated withFunctionResponse calls collapse into one message. + * + * Relevant to https://github.com/WordPress/php-ai-client/issues/286. Parallel tool calls + * produce several function responses for a single turn, and appending them yields one + * user message holding one part per response. This is the canonical shape: providers + * represent the results of a parallel tool call as parts of one message. + * + * @return void + */ + public function testWithFunctionResponseCollapsesMultipleResponsesIntoOneMessage(): void + { + $first = new FunctionResponse('call_1', 'get_weather', ['temperature' => 22]); + $second = new FunctionResponse('call_2', 'get_time', ['time' => '14:30']); + + $builder = new PromptBuilder($this->registry); + $builder->withFunctionResponse($first)->withFunctionResponse($second); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(1, $messages, 'Both responses belong to the same message'); + $this->assertTrue($messages[0]->getRole()->isUser()); + + $parts = $messages[0]->getParts(); + $this->assertCount(2, $parts); + $this->assertSame($first, $parts[0]->getFunctionResponse()); + $this->assertSame($second, $parts[1]->getFunctionResponse()); + } + /** * Tests withMessageParts method. * diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 6c99c75b..d21bdc1d 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -548,6 +548,50 @@ public function testPrepareMessagesParamFunctionResponse(): void $this->assertEquals('call_1', $prepared[0]['tool_call_id']); } + /** + * Tests prepareMessagesParam() with several function responses in one message. + * + * Regression test for https://github.com/WordPress/php-ai-client/issues/286. + * + * When a model answers with parallel tool calls, every result has to be sent back. + * A caller that executes several tool calls in one turn collects the results into a + * single user message holding one function-response part per call. That is the shape + * `PromptBuilder::withFunctionResponse()` produces when called more than once. + * + * The function-response special case in prepareMessagesParam() only matches a message + * with exactly one part, so such a message falls through to the generic branch and + * getMessagePartContentData() throws "The API only allows a single function response, + * as the only content of the message." + * + * The OpenAI-compatible chat completions API expects one `role: tool` entry per tool + * call, so the message should expand into one entry per function response. + * + * @return void + */ + public function testPrepareMessagesParamMultipleFunctionResponsesInSingleMessage(): void + { + $message = new Message( + MessageRoleEnum::user(), + [ + new MessagePart(new FunctionResponse('call_1', 'get_weather', ['temperature' => 22])), + new MessagePart(new FunctionResponse('call_2', 'get_time', ['time' => '14:30'])), + ] + ); + $model = $this->createModel(); + + $prepared = $model->exposePrepareMessagesParam([$message]); + + $this->assertCount(2, $prepared, 'Expected one API message per function response'); + + $this->assertEquals('tool', $prepared[0]['role']); + $this->assertEquals(json_encode(['temperature' => 22]), $prepared[0]['content']); + $this->assertEquals('call_1', $prepared[0]['tool_call_id']); + + $this->assertEquals('tool', $prepared[1]['role']); + $this->assertEquals(json_encode(['time' => '14:30']), $prepared[1]['content']); + $this->assertEquals('call_2', $prepared[1]['tool_call_id']); + } + /** * Tests getMessageRoleString() method. *