From a90580c6d138056fecb380e47e1f42a8d22c233d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 7 Jun 2026 22:23:29 -0400 Subject: [PATCH] Parse function_call-wrapped and bare JSON tool calls The text tool-call fallback in WP_Agent_Provider_Turn_Result dropped tool calls emitted as a bare JSON object (no tag or ```json fence) and did not recognize the `{"type":"function_call","function_call":{...}}` wrapper that some providers emit. Both shapes resulted in zero extracted tool calls, so the model's tool call never executed. Treat a trimmed message that is a single JSON object as a payload candidate, and unwrap an inner `function_call` object before reading the tool name and arguments. Adds smoke coverage for the wrapper shape. --- .../class-wp-agent-provider-turn-result.php | 17 +++++++++++++++++ tests/provider-turn-adapter-smoke.php | 8 ++++++++ 2 files changed, 25 insertions(+) diff --git a/src/Runtime/class-wp-agent-provider-turn-result.php b/src/Runtime/class-wp-agent-provider-turn-result.php index 6674013..79aeea4 100644 --- a/src/Runtime/class-wp-agent-provider-turn-result.php +++ b/src/Runtime/class-wp-agent-provider-turn-result.php @@ -274,6 +274,16 @@ private static function extract_json_tool_calls( string $text ): array { } } + // Some providers emit a bare JSON tool-call object as the entire message + // with no fence or tag wrapper. Treat a trimmed text that is a single JSON + // object as a payload candidate so those calls are not dropped. + if ( empty( $payloads ) ) { + $trimmed = trim( $text ); + if ( '' !== $trimmed && '{' === $trimmed[0] && '}' === substr( $trimmed, -1 ) ) { + $payloads[] = $trimmed; + } + } + return self::tool_calls_from_json_payloads( $payloads, 'json-tool-call' ); } @@ -363,6 +373,13 @@ private static function tool_calls_from_json_payloads( array $payloads, string $ continue; } + // Some providers wrap the call in a `function_call` object, often + // tagged with `{"type":"function_call", ...}`. Use that inner object + // as the call source so its name/arguments are read correctly. + if ( isset( $call['function_call'] ) && is_array( $call['function_call'] ) ) { + $call = $call['function_call']; + } + $function = isset( $call['function'] ) && is_array( $call['function'] ) ? $call['function'] : array(); $raw_name = $function['name'] ?? ( $call['name'] ?? '' ); $name = is_string( $raw_name ) ? self::clean_tool_name( $raw_name ) : ''; diff --git a/tests/provider-turn-adapter-smoke.php b/tests/provider-turn-adapter-smoke.php index de3a3d8..7597775 100644 --- a/tests/provider-turn-adapter-smoke.php +++ b/tests/provider-turn-adapter-smoke.php @@ -160,6 +160,14 @@ public function getText(): string { agents_api_smoke_assert_equals( 'json-1', $json_turn['tool_calls'][0]['id'] ?? '', 'fenced JSON fallback preserves id', $failures, $passes ); agents_api_smoke_assert_equals( 'json', $json_turn['tool_calls'][0]['parameters']['query'] ?? '', 'fenced JSON fallback decodes function arguments', $failures, $passes ); +$function_call_turn = AgentsAPI\AI\WP_Agent_Provider_Turn_Result::normalize( + array( + 'content' => '{"type":"function_call","function_call":{"name":"client/lookup","arguments":{"query":"wrapped"}}}', + ) +); +agents_api_smoke_assert_equals( 'client/lookup', $function_call_turn['tool_calls'][0]['name'] ?? '', 'function_call wrapper extracts tool name', $failures, $passes ); +agents_api_smoke_assert_equals( 'wrapped', $function_call_turn['tool_calls'][0]['parameters']['query'] ?? '', 'function_call wrapper decodes object arguments', $failures, $passes ); + $tag_turn = AgentsAPI\AI\WP_Agent_Provider_Turn_Result::normalize( array( 'content' => '{"query":"tag"}',