Skip to content

Commit 38afc15

Browse files
committed
bug #421 [Platform][Gemini] Fix choice conversion logic for executableCode and codeExecutionResult (pentiminax)
This PR was squashed before being merged into the main branch. Discussion ---------- [Platform][Gemini] Fix choice conversion logic for `executableCode` and `codeExecutionResult` | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no | Docs? | no | Issues | Fix #422 | License | MIT This PR fixes a crash in the **Gemini** ResultConverter when handling responses that only contained executableCode and codeExecutionResult parts. Before: - RuntimeException: Unsupported finish reason "STOP" After: - Proper conversion into a TextResult containing formatted code blocks and the last successful OUTCOME_OK output. - No more exceptions for valid Gemini responses. What’s Changed - Enhanced choice conversion logic in ResultConverter::convertChoice(): - Single part → keep legacy behavior (functionCall, text, or one code/output part). - Multiple parts → aggregate: - executableCode → Markdown code blocks. - codeExecutionResult (OUTCOME_OK) → output block. - Parts flagged as thought: true are ignored for text/code, but their successful execution results are preserved. New unit tests: - Covers the failing payload where all parts were marked thought: true. - Ensures the result is a readable TextResult instead of an exception. Why - Prevents crashes when Gemini outputs reasoning steps as thought: true. - Keeps only useful information (code + last valid output). - Maintains backward compatibility for simpler responses. Impact - No BC breaks — existing behavior for single-part responses unchanged. - Improved UX — users now see clean Markdown code + output instead of runtime errors. Commits ------- bbff633 [Platform][Gemini] Fix choice conversion logic for `executableCode` and `codeExecutionResult`
2 parents afe8816 + bbff633 commit 38afc15

File tree

5 files changed

+235
-7
lines changed

5 files changed

+235
-7
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"candidates": [
3+
{
4+
"content": {
5+
"parts": [
6+
{
7+
"text": "First text"
8+
},
9+
{
10+
"executableCode": {
11+
"language": "PYTHON",
12+
"code": "print('Hello, World!')"
13+
}
14+
},
15+
{
16+
"codeExecutionResult": {
17+
"outcome": "OUTCOME_DEADLINE_EXCEEDED",
18+
"output": "An error occurred during code execution."
19+
}
20+
},
21+
{
22+
"text": "Last text"
23+
}
24+
],
25+
"role": "model"
26+
},
27+
"finishReason": "STOP",
28+
"index": 0
29+
}
30+
]
31+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"candidates": [
3+
{
4+
"content": {
5+
"parts": [
6+
{
7+
"text": "First text"
8+
},
9+
{
10+
"executableCode": {
11+
"language": "PYTHON",
12+
"code": "print('Hello, World!')"
13+
}
14+
},
15+
{
16+
"codeExecutionResult": {
17+
"outcome": "OUTCOME_FAILED",
18+
"output": "An error occurred during code execution."
19+
}
20+
},
21+
{
22+
"text": "Last text"
23+
}
24+
],
25+
"role": "model"
26+
},
27+
"finishReason": "STOP",
28+
"index": 0
29+
}
30+
]
31+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"candidates": [
3+
{
4+
"content": {
5+
"parts": [
6+
{
7+
"text": "First text"
8+
},
9+
{
10+
"executableCode": {
11+
"language": "PYTHON",
12+
"code": "print('Hello, World!')"
13+
}
14+
},
15+
{
16+
"codeExecutionResult": {
17+
"outcome": "OUTCOME_OK",
18+
"output": "Hello, World!"
19+
}
20+
},
21+
{
22+
"text": "Second text\n"
23+
},
24+
{
25+
"text": "Third text\n"
26+
},
27+
{
28+
"text": "Fourth text"
29+
}
30+
],
31+
"role": "model"
32+
},
33+
"finishReason": "STOP",
34+
"index": 0
35+
}
36+
]
37+
}

src/platform/src/Bridge/Gemini/Gemini/ResultConverter.php

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
*/
3232
final readonly class ResultConverter implements ResultConverterInterface
3333
{
34+
public const OUTCOME_OK = 'OUTCOME_OK';
35+
public const OUTCOME_FAILED = 'OUTCOME_FAILED';
36+
public const OUTCOME_DEADLINE_EXCEEDED = 'OUTCOME_DEADLINE_EXCEEDED';
37+
3438
public function supports(Model $model): bool
3539
{
3640
return $model instanceof Gemini;
@@ -110,24 +114,55 @@ private function convertStream(HttpResponse $result): \Generator
110114
* name: string,
111115
* args: mixed[]
112116
* },
113-
* text?: string
117+
* text?: string,
118+
* executableCode?: array{
119+
* language?: string,
120+
* code?: string
121+
* },
122+
* codeExecutionResult?: array{
123+
* outcome: self::OUTCOME_*,
124+
* output: string
125+
* }
114126
* }[]
115127
* }
116128
* } $choice
117129
*/
118130
private function convertChoice(array $choice): ToolCallResult|TextResult
119131
{
120-
$contentPart = $choice['content']['parts'][0] ?? [];
132+
$contentParts = $choice['content']['parts'];
133+
134+
if (1 === \count($contentParts)) {
135+
$contentPart = $contentParts[0];
136+
137+
if (isset($contentPart['functionCall'])) {
138+
return new ToolCallResult($this->convertToolCall($contentPart['functionCall']));
139+
}
140+
141+
if (isset($contentPart['text'])) {
142+
return new TextResult($contentPart['text']);
143+
}
121144

122-
if (isset($contentPart['functionCall'])) {
123-
return new ToolCallResult($this->convertToolCall($contentPart['functionCall']));
145+
throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finishReason']));
124146
}
125147

126-
if (isset($contentPart['text'])) {
127-
return new TextResult($contentPart['text']);
148+
$content = '';
149+
$successfulCodeExecutionDetected = false;
150+
foreach ($contentParts as $contentPart) {
151+
if ($this->isSuccessfulCodeExecution($contentPart)) {
152+
$successfulCodeExecutionDetected = true;
153+
continue;
154+
}
155+
156+
if ($successfulCodeExecutionDetected) {
157+
$content .= $contentPart['text'];
158+
}
128159
}
129160

130-
throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finishReason']));
161+
if ('' !== $content) {
162+
return new TextResult($content);
163+
}
164+
165+
throw new RuntimeException('Code execution failed.');
131166
}
132167

133168
/**
@@ -141,4 +176,23 @@ private function convertToolCall(array $toolCall): ToolCall
141176
{
142177
return new ToolCall($toolCall['id'] ?? '', $toolCall['name'], $toolCall['args']);
143178
}
179+
180+
/**
181+
* @param array{
182+
* codeExecutionResult?: array{
183+
* outcome: self::OUTCOME_*,
184+
* output: string
185+
* }
186+
* } $contentPart
187+
*/
188+
private function isSuccessfulCodeExecution(array $contentPart): bool
189+
{
190+
if (!isset($contentPart['codeExecutionResult'])) {
191+
return false;
192+
}
193+
194+
$result = $contentPart['codeExecutionResult'];
195+
196+
return self::OUTCOME_OK === $result['outcome'];
197+
}
144198
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Tests\Bridge\Gemini\CodeExecution;
13+
14+
use PHPUnit\Framework\Attributes\CoversClass;
15+
use PHPUnit\Framework\Attributes\Small;
16+
use PHPUnit\Framework\Attributes\UsesClass;
17+
use PHPUnit\Framework\TestCase;
18+
use Symfony\AI\Platform\Bridge\Gemini\Gemini\ResultConverter;
19+
use Symfony\AI\Platform\Result\RawHttpResult;
20+
use Symfony\AI\Platform\Result\TextResult;
21+
use Symfony\Contracts\HttpClient\ResponseInterface;
22+
23+
#[CoversClass(ResultConverter::class)]
24+
#[Small]
25+
#[UsesClass(TextResult::class)]
26+
#[UsesClass(RawHttpResult::class)]
27+
final class ResultConverterTest extends TestCase
28+
{
29+
public function testItReturnsAggregatedTextOnSuccess()
30+
{
31+
$response = $this->createStub(ResponseInterface::class);
32+
$responseContent = file_get_contents(\dirname(__DIR__, 6).'/fixtures/Bridge/Gemini/code_execution_outcome_ok.json');
33+
34+
$response
35+
->method('toArray')
36+
->willReturn(json_decode($responseContent, true));
37+
38+
$converter = new ResultConverter();
39+
40+
$result = $converter->convert(new RawHttpResult($response));
41+
$this->assertInstanceOf(TextResult::class, $result);
42+
43+
$this->assertEquals("Second text\nThird text\nFourth text", $result->getContent());
44+
}
45+
46+
public function testItThrowsExceptionOnFailure()
47+
{
48+
$response = $this->createStub(ResponseInterface::class);
49+
$responseContent = file_get_contents(\dirname(__DIR__, 6).'/fixtures/Bridge/Gemini/code_execution_outcome_failed.json');
50+
51+
$response
52+
->method('toArray')
53+
->willReturn(json_decode($responseContent, true));
54+
55+
$converter = new ResultConverter();
56+
57+
$this->expectException(\RuntimeException::class);
58+
$converter->convert(new RawHttpResult($response));
59+
}
60+
61+
public function testItThrowsExceptionOnTimeout()
62+
{
63+
$response = $this->createStub(ResponseInterface::class);
64+
$responseContent = file_get_contents(\dirname(__DIR__, 6).'/fixtures/Bridge/Gemini/code_execution_outcome_deadline_exceeded.json');
65+
66+
$response
67+
->method('toArray')
68+
->willReturn(json_decode($responseContent, true));
69+
70+
$converter = new ResultConverter();
71+
72+
$this->expectException(\RuntimeException::class);
73+
$converter->convert(new RawHttpResult($response));
74+
}
75+
}

0 commit comments

Comments
 (0)