Skip to content

Commit 5f71f36

Browse files
authored
fix: preserve idless Gemini tool cycles
* fix: preserve idless Gemini tool cycles * fix: count idless tool responses
1 parent 5ac7c81 commit 5f71f36

3 files changed

Lines changed: 87 additions & 7 deletions

File tree

includes/Core/AbilityFunctionResolver.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ public function __construct( ...$abilities ) {
6161
*/
6262
public function execute_ability( FunctionCall $call ): FunctionResponse {
6363
$function_name = $call->getName() ?? 'unknown';
64-
$function_id = $call->getId() ?? 'unknown';
64+
// Older Gemini models omit function call IDs. Keep an empty internal ID
65+
// so the response pairs with the idless call in conversation history;
66+
// the Google provider omits empty IDs when serializing the next request.
67+
$function_id = $call->getId() ?? '';
6568

6669
if ( ! $this->is_ability_call( $call ) ) {
6770
return new FunctionResponse(

includes/Core/ConversationTrimmer.php

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1239,8 +1239,6 @@ public static function validate_tool_pairs( array $history ): array {
12391239
}
12401240
++$call_end;
12411241
}
1242-
$tool_call_ids = array_values( array_unique( $tool_call_ids ) );
1243-
12441242
// Collect the tool-response messages that follow the entire call cluster.
12451243
$response_ids = [];
12461244
$response_start = $call_end;
@@ -1258,10 +1256,10 @@ public static function validate_tool_pairs( array $history ): array {
12581256
}
12591257
}
12601258

1261-
// Check if ALL tool_call IDs have matching responses.
1262-
$missing = array_diff( $tool_call_ids, $response_ids );
1263-
1264-
if ( empty( $missing ) ) {
1259+
// Check if every tool call has its own matching response. Counts matter
1260+
// because older Gemini models may omit IDs from parallel calls, causing
1261+
// multiple calls and responses to share the empty-string compatibility ID.
1262+
if ( self::has_matching_tool_responses( $tool_call_ids, $response_ids ) ) {
12651263
// All tool calls have responses — keep the entire split cycle.
12661264
for ( $j = $call_start; $j < $call_end; $j++ ) {
12671265
$result[] = $history[ $j ];
@@ -1279,6 +1277,26 @@ public static function validate_tool_pairs( array $history ): array {
12791277
return self::strip_orphan_tool_responses( $result );
12801278
}
12811279

1280+
/**
1281+
* Determine whether each tool call ID has a distinct matching response ID.
1282+
*
1283+
* @param string[] $tool_call_ids Tool call IDs, including duplicate empty IDs.
1284+
* @param string[] $response_ids Tool response IDs, including duplicate empty IDs.
1285+
*/
1286+
private static function has_matching_tool_responses( array $tool_call_ids, array $response_ids ): bool {
1287+
$response_counts = array_count_values( $response_ids );
1288+
1289+
foreach ( $tool_call_ids as $tool_call_id ) {
1290+
if ( empty( $response_counts[ $tool_call_id ] ) ) {
1291+
return false;
1292+
}
1293+
1294+
--$response_counts[ $tool_call_id ];
1295+
}
1296+
1297+
return true;
1298+
}
1299+
12821300
/**
12831301
* Strip FunctionResponse parts whose tool_use_id has no matching tool_use.
12841302
*

tests/SdAiAgent/Core/AbilityFunctionResolverTest.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,13 @@
1515
use SdAiAgent\Abilities\KnowledgeAbilities;
1616
use SdAiAgent\Core\AbilityRegistry;
1717
use SdAiAgent\Core\AbilityFunctionResolver;
18+
use SdAiAgent\Core\ConversationTrimmer;
1819
use SdAiAgent\Core\IdenticalFailureTracker;
20+
use WordPress\AiClient\Messages\DTO\MessagePart;
21+
use WordPress\AiClient\Messages\DTO\ModelMessage;
22+
use WordPress\AiClient\Messages\DTO\UserMessage;
1923
use WordPress\AiClient\Tools\DTO\FunctionCall;
24+
use WordPress\AiClient\Tools\DTO\FunctionResponse;
2025
use WP_UnitTestCase;
2126

2227
final class ThrowingValidationAbility extends \WP_Ability {
@@ -128,6 +133,60 @@ public function test_empty_arguments_return_schema_guidance_without_dispatching(
128133
$this->assertStringContainsString( 'Do not retry with empty arguments', $payload['hint'] );
129134
}
130135

136+
/**
137+
* Test that an idless Gemini call and response survive history validation.
138+
*/
139+
public function test_idless_function_call_response_remains_in_gemini_history(): void {
140+
$this->skip_if_resolver_unavailable();
141+
142+
$ability = $this->register_schema_thrower_ability();
143+
$this->assertNotNull( $ability );
144+
145+
$function_name = \WP_AI_Client_Ability_Function_Resolver::ability_name_to_function_name( 'test-plugin/schema-thrower' );
146+
$call = new FunctionCall( null, $function_name, array( 'query' => 'trigger callback validation exception' ) );
147+
$resolver = new AbilityFunctionResolver( $ability );
148+
$response = $resolver->execute_ability( $call );
149+
150+
$this->assertNull( $call->getId() );
151+
$this->assertSame( '', $response->getId() );
152+
153+
$history = array(
154+
new ModelMessage( array( new MessagePart( $call ) ) ),
155+
new UserMessage( array( new MessagePart( $response ) ) ),
156+
);
157+
158+
$validated = ConversationTrimmer::validate_tool_pairs( $history );
159+
$this->assertCount( 2, $validated );
160+
$this->assertSame( $history[0], $validated[0] );
161+
$this->assertSame( $history[1], $validated[1] );
162+
}
163+
164+
/**
165+
* Test parallel idless calls require one response per call.
166+
*/
167+
public function test_parallel_idless_calls_require_distinct_responses(): void {
168+
$first_user_message = new UserMessage( array( new MessagePart( 'Do two things' ) ) );
169+
$first_call = new FunctionCall( null, 'tool-a', array() );
170+
$second_call = new FunctionCall( null, 'tool-b', array() );
171+
$call_message = new ModelMessage(
172+
array(
173+
new MessagePart( $first_call ),
174+
new MessagePart( $second_call ),
175+
)
176+
);
177+
$response_message = new UserMessage(
178+
array( new MessagePart( new FunctionResponse( '', 'tool-a', '{"success":true}' ) ) )
179+
);
180+
$next_user_message = new UserMessage( array( new MessagePart( 'What happened?' ) ) );
181+
$history = array( $first_user_message, $call_message, $response_message, $next_user_message );
182+
183+
$validated = ConversationTrimmer::validate_tool_pairs( $history );
184+
185+
$this->assertCount( 2, $validated );
186+
$this->assertSame( $first_user_message, $validated[0] );
187+
$this->assertSame( $next_user_message, $validated[1] );
188+
}
189+
131190
public function test_public_knowledge_search_hydrates_empty_args_from_customer_query(): void {
132191
$this->skip_if_resolver_unavailable();
133192
$this->ensure_knowledge_search_registered();

0 commit comments

Comments
 (0)