Skip to content

Commit 537511c

Browse files
authored
Merge pull request #470 from manuelkiessling/fix/gemini3-functioncall-detection
Fix Gemini 3 function-call detection in HandleChat
2 parents 8d9cbb2 + 1aa45ec commit 537511c

File tree

3 files changed

+175
-2
lines changed

3 files changed

+175
-2
lines changed

src/Providers/Gemini/Gemini.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
use function array_filter;
2020
use function array_map;
21+
use function array_values;
2122

2223
class Gemini implements AIProviderInterface
2324
{
@@ -105,9 +106,11 @@ protected function createToolCallMessage(array $message): Message
105106
->setCallId($item['functionCall']['name']);
106107
}, $message['parts']);
107108

109+
// array_values() reindexes so tools are always a 0-based sequential array,
110+
// even when text/thought parts precede functionCall parts.
108111
$result = new ToolCallMessage(
109112
$message['content'] ?? null,
110-
array_filter($tools)
113+
array_values(array_filter($tools))
111114
);
112115

113116
if ($signature !== null) {

src/Providers/Gemini/HandleChat.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,18 @@ public function chatAsync(array $messages): PromiseInterface
9393

9494
$parts = $content['parts'];
9595

96-
if (array_key_exists('functionCall', $parts[0]) && !empty($parts[0]['functionCall'])) {
96+
// Scan ALL parts for functionCall, not just parts[0].
97+
// Gemini 3 models may return text or thought parts before functionCall parts.
98+
// See https://ai.google.dev/gemini-api/docs/thought-signatures
99+
$hasFunctionCall = false;
100+
foreach ($parts as $part) {
101+
if (array_key_exists('functionCall', $part) && !empty($part['functionCall'])) {
102+
$hasFunctionCall = true;
103+
break;
104+
}
105+
}
106+
107+
if ($hasFunctionCall) {
97108
$response = $this->createToolCallMessage($content);
98109
} else {
99110
$response = new AssistantMessage($parts[0]['text'] ?? '');

tests/Providers/GeminiTest.php

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use NeuronAI\Chat\Attachments\Image;
1414
use NeuronAI\Chat\Enums\AttachmentContentType;
1515
use NeuronAI\Chat\Messages\AssistantMessage;
16+
use NeuronAI\Chat\Messages\ToolCallMessage;
1617
use NeuronAI\Chat\Messages\UserMessage;
1718
use NeuronAI\Providers\Gemini\Gemini;
1819
use NeuronAI\Tools\PropertyType;
@@ -21,6 +22,7 @@
2122
use PHPUnit\Framework\TestCase;
2223

2324
use function json_decode;
25+
use function json_encode;
2426

2527
class GeminiTest extends TestCase
2628
{
@@ -385,4 +387,161 @@ public function test_chat_with_attachment_response(): void
385387
$this->assertSame('application/pdf', $attachmentList[1]->mediaType);
386388
$this->assertSame('321cba', $attachmentList[1]->content);
387389
}
390+
391+
public function test_function_call_in_first_part(): void
392+
{
393+
$body = json_encode([
394+
'candidates' => [[
395+
'content' => [
396+
'parts' => [
397+
['functionCall' => ['name' => 'my_tool', 'args' => ['prop' => 'hello']]],
398+
],
399+
],
400+
'finishReason' => 'STOP',
401+
]],
402+
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 5],
403+
]);
404+
405+
$mockHandler = new MockHandler([new Response(status: 200, body: $body)]);
406+
$client = new Client(['handler' => HandlerStack::create($mockHandler)]);
407+
408+
$provider = (new Gemini('', 'gemini-2.0-flash'))
409+
->setTools([$this->makeSimpleTool('my_tool')])
410+
->setClient($client);
411+
412+
$response = $provider->chat([new UserMessage('test')]);
413+
414+
$this->assertInstanceOf(ToolCallMessage::class, $response);
415+
$this->assertCount(1, $response->getTools());
416+
$this->assertSame('my_tool', $response->getTools()[0]->getName());
417+
}
418+
419+
public function test_function_call_after_text_part(): void
420+
{
421+
$body = json_encode([
422+
'candidates' => [[
423+
'content' => [
424+
'parts' => [
425+
['text' => 'Let me call the tool.'],
426+
['functionCall' => ['name' => 'my_tool', 'args' => ['prop' => 'data']]],
427+
],
428+
],
429+
'finishReason' => 'STOP',
430+
]],
431+
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 5],
432+
]);
433+
434+
$mockHandler = new MockHandler([new Response(status: 200, body: $body)]);
435+
$client = new Client(['handler' => HandlerStack::create($mockHandler)]);
436+
437+
$provider = (new Gemini('', 'gemini-3-pro-preview'))
438+
->setTools([$this->makeSimpleTool('my_tool')])
439+
->setClient($client);
440+
441+
$response = $provider->chat([new UserMessage('test')]);
442+
443+
$this->assertInstanceOf(ToolCallMessage::class, $response);
444+
$this->assertCount(1, $response->getTools());
445+
$this->assertSame('my_tool', $response->getTools()[0]->getName());
446+
}
447+
448+
public function test_multiple_function_calls_after_text_part(): void
449+
{
450+
$body = json_encode([
451+
'candidates' => [[
452+
'content' => [
453+
'parts' => [
454+
['text' => 'I will call both tools.'],
455+
['functionCall' => ['name' => 'tool_a', 'args' => ['prop' => '1']]],
456+
['functionCall' => ['name' => 'tool_b', 'args' => ['prop' => '2']]],
457+
],
458+
],
459+
'finishReason' => 'STOP',
460+
]],
461+
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 5],
462+
]);
463+
464+
$mockHandler = new MockHandler([new Response(status: 200, body: $body)]);
465+
$client = new Client(['handler' => HandlerStack::create($mockHandler)]);
466+
467+
$provider = (new Gemini('', 'gemini-3-pro-preview'))
468+
->setTools([
469+
$this->makeSimpleTool('tool_a'),
470+
$this->makeSimpleTool('tool_b'),
471+
])
472+
->setClient($client);
473+
474+
$response = $provider->chat([new UserMessage('test')]);
475+
476+
$this->assertInstanceOf(ToolCallMessage::class, $response);
477+
$this->assertCount(2, $response->getTools());
478+
$this->assertSame('tool_a', $response->getTools()[0]->getName());
479+
$this->assertSame('tool_b', $response->getTools()[1]->getName());
480+
}
481+
482+
public function test_no_function_call_returns_assistant_message(): void
483+
{
484+
$body = json_encode([
485+
'candidates' => [[
486+
'content' => [
487+
'parts' => [
488+
['text' => 'Here is a plain text response.'],
489+
],
490+
],
491+
'finishReason' => 'STOP',
492+
]],
493+
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 5],
494+
]);
495+
496+
$mockHandler = new MockHandler([new Response(status: 200, body: $body)]);
497+
$client = new Client(['handler' => HandlerStack::create($mockHandler)]);
498+
499+
$provider = (new Gemini('', 'gemini-3-pro-preview'))
500+
->setTools([$this->makeSimpleTool('my_tool')])
501+
->setClient($client);
502+
503+
$response = $provider->chat([new UserMessage('test')]);
504+
505+
$this->assertInstanceOf(AssistantMessage::class, $response);
506+
$this->assertNotInstanceOf(ToolCallMessage::class, $response);
507+
$this->assertSame('Here is a plain text response.', $response->getContent());
508+
}
509+
510+
public function test_function_call_with_thought_signature(): void
511+
{
512+
$body = json_encode([
513+
'candidates' => [[
514+
'content' => [
515+
'parts' => [
516+
['text' => 'Thinking...'],
517+
[
518+
'functionCall' => ['name' => 'my_tool', 'args' => ['prop' => 'value']],
519+
'thoughtSignature' => 'sig-abc123',
520+
],
521+
],
522+
],
523+
'finishReason' => 'STOP',
524+
]],
525+
'usageMetadata' => ['promptTokenCount' => 10, 'candidatesTokenCount' => 5],
526+
]);
527+
528+
$mockHandler = new MockHandler([new Response(status: 200, body: $body)]);
529+
$client = new Client(['handler' => HandlerStack::create($mockHandler)]);
530+
531+
$provider = (new Gemini('', 'gemini-3-pro-preview'))
532+
->setTools([$this->makeSimpleTool('my_tool')])
533+
->setClient($client);
534+
535+
$response = $provider->chat([new UserMessage('test')]);
536+
537+
$this->assertInstanceOf(ToolCallMessage::class, $response);
538+
$this->assertSame('sig-abc123', $response->getMetadata('thoughtSignature'));
539+
}
540+
541+
private function makeSimpleTool(string $name): Tool
542+
{
543+
return Tool::make($name, "A test tool called {$name}.")
544+
->addProperty(new ToolProperty('prop', PropertyType::STRING, 'A test property', true))
545+
->setCallable(static fn (string $prop): string => "result: {$prop}");
546+
}
388547
}

0 commit comments

Comments
 (0)