diff --git a/src/Runtime/class-wp-agent-conversation-loop.php b/src/Runtime/class-wp-agent-conversation-loop.php index 931c2be..832a175 100644 --- a/src/Runtime/class-wp-agent-conversation-loop.php +++ b/src/Runtime/class-wp-agent-conversation-loop.php @@ -863,8 +863,10 @@ private static function mediate_tool_calls( break; } - // Consult completion policy. - if ( null !== $policy ) { + // Consult completion policy. A complete decision stops future model turns, + // but the current provider turn may contain more tool calls that still + // require paired tool results before the transcript can be replayed. + if ( null !== $policy && ! $complete ) { $decision = $policy->recordToolResult( $tool_name, $tool_definition, @@ -884,7 +886,7 @@ private static function mediate_tool_calls( 'context' => $decision->context(), ), ); - break; + continue; } $continuation = self::completion_policy_continuation( $decision, $tool_name, $turn ); diff --git a/src/Runtime/class-wp-agent-provider-turn-result.php b/src/Runtime/class-wp-agent-provider-turn-result.php index 6674013..e3909e1 100644 --- a/src/Runtime/class-wp-agent-provider-turn-result.php +++ b/src/Runtime/class-wp-agent-provider-turn-result.php @@ -182,29 +182,37 @@ private static function extract_structured_tool_calls( $result, array &$texts ): return array(); } - $message = self::call_no_args( reset( $candidates ), 'getMessage' ); - $parts = self::call_no_args( $message, 'getParts' ); - if ( ! is_array( $parts ) ) { - return array(); - } + $tool_calls = array(); + $scanned_parts = 0; + foreach ( $candidates as $candidate ) { + $message = self::call_no_args( $candidate, 'getMessage' ); + $parts = self::call_no_args( $message, 'getParts' ); + if ( ! is_array( $parts ) ) { + continue; + } - $tool_calls = array(); - foreach ( array_slice( $parts, 0, 64 ) as $part ) { - $function_call = self::call_no_args( $part, 'getFunctionCall' ); - if ( null !== $function_call ) { - $name = self::call_no_args( $function_call, 'getName' ); - if ( is_string( $name ) && '' !== $name ) { - $tool_calls[] = array( - 'name' => $name, - 'parameters' => self::normalize_function_args( self::call_no_args( $function_call, 'getArgs' ) ), - 'id' => self::call_no_args( $function_call, 'getId' ), - ); + foreach ( $parts as $part ) { + if ( $scanned_parts >= 64 ) { + break 2; } - } - $text = self::call_no_args( $part, 'getText' ); - if ( is_string( $text ) ) { - $texts[] = $text; + ++$scanned_parts; + $function_call = self::call_no_args( $part, 'getFunctionCall' ); + if ( null !== $function_call ) { + $name = self::call_no_args( $function_call, 'getName' ); + if ( is_string( $name ) && '' !== $name ) { + $tool_calls[] = array( + 'name' => $name, + 'parameters' => self::normalize_function_args( self::call_no_args( $function_call, 'getArgs' ) ), + 'id' => self::call_no_args( $function_call, 'getId' ), + ); + } + } + + $text = self::call_no_args( $part, 'getText' ); + if ( is_string( $text ) ) { + $texts[] = $text; + } } } diff --git a/src/Tools/class-wp-agent-tool-declaration.php b/src/Tools/class-wp-agent-tool-declaration.php index 3ec650a..9768108 100644 --- a/src/Tools/class-wp-agent-tool-declaration.php +++ b/src/Tools/class-wp-agent-tool-declaration.php @@ -98,6 +98,12 @@ public static function normalize( array $declaration ): array { $name = is_string( $declaration['name'] ?? null ) ? $declaration['name'] : ''; $source = self::sourceFromName( $name ); + if ( '' === $source && is_string( $declaration['source'] ?? null ) ) { + $source = trim( $declaration['source'] ); + } + if ( '' === $source ) { + $source = self::SOURCE_CLIENT; + } $description = $declaration['description'] ?? ''; $normalized = array( @@ -206,15 +212,17 @@ public static function validate( array $declaration ): array { $errors = array(); $name = $declaration['name'] ?? null; - if ( - ! is_string( $name ) - || '' === $name - || ! preg_match( '/^[a-z][a-z0-9_-]*\/[a-z][a-z0-9_-]*$/', $name ) - ) { + if ( ! is_string( $name ) || '' === $name || ! self::isValidHostToolName( $name ) ) { $errors[] = 'name'; } $source = is_string( $name ) ? self::sourceFromName( $name ) : ''; + if ( '' === $source && is_string( $declaration['source'] ?? null ) ) { + $source = trim( $declaration['source'] ); + } + if ( '' === $source ) { + $source = self::SOURCE_CLIENT; + } if ( self::SOURCE_CLIENT !== $source ) { $errors[] = 'source'; } diff --git a/tests/conversation-loop-completion-policy-smoke.php b/tests/conversation-loop-completion-policy-smoke.php index d1422ee..9153900 100644 --- a/tests/conversation-loop-completion-policy-smoke.php +++ b/tests/conversation-loop-completion-policy-smoke.php @@ -160,6 +160,104 @@ static function ( array $messages ): array { agents_api_smoke_assert_equals( 1, count( $policy_log ), 'policy was called once', $failures, $passes ); agents_api_smoke_assert_equals( 0, $continue_called, 'should_continue was not called when policy stopped the loop', $failures, $passes ); +echo "\n[2b] Structured provider extraction preserves multi-candidate tool calls:\n"; + +$function_call_part = static function ( string $id, string $name, array $args ) { + $function_call = new class( $id, $name, $args ) { + private string $id; + private string $name; + private array $args; + + public function __construct( string $id, string $name, array $args ) { + $this->id = $id; + $this->name = $name; + $this->args = $args; + } + + public function getId(): string { + return $this->id; + } + + public function getName(): string { + return $this->name; + } + + public function getArgs(): array { + return $this->args; + } + }; + + return new class( $function_call ) { + private object $function_call; + + public function __construct( object $function_call ) { + $this->function_call = $function_call; + } + + public function getFunctionCall(): object { + return $this->function_call; + } + + public function getText(): string { + return ''; + } + }; +}; + +$candidate = static function ( object $part ) { + $message = new class( $part ) { + private object $part; + + public function __construct( object $part ) { + $this->part = $part; + } + + public function getParts(): array { + return array( $this->part ); + } + }; + + return new class( $message ) { + private object $message; + + public function __construct( object $message ) { + $this->message = $message; + } + + public function getMessage(): object { + return $this->message; + } + }; +}; + +$multi_candidate_result = new class( $candidate, $function_call_part ) { + /** @var callable */ + private $candidate; + /** @var callable */ + private $function_call_part; + + public function __construct( callable $candidate, callable $function_call_part ) { + $this->candidate = $candidate; + $this->function_call_part = $function_call_part; + } + + public function getCandidates(): array { + $candidate = $this->candidate; + $function_call_part = $this->function_call_part; + + return array( + $candidate( $function_call_part( 'call_one', 'client/work', array( 'path' => 'index.html' ) ) ), + $candidate( $function_call_part( 'call_two', 'client/finish', array( 'path' => 'styles.css' ) ) ), + ); + } +}; + +$extracted_calls = AgentsAPI\AI\WP_Agent_Provider_Turn_Result::extract_tool_calls( $multi_candidate_result ); +agents_api_smoke_assert_equals( 2, count( $extracted_calls ), 'structured extraction scans all candidates', $failures, $passes ); +agents_api_smoke_assert_equals( 'client/work', $extracted_calls[0]['name'] ?? '', 'first candidate tool call is preserved', $failures, $passes ); +agents_api_smoke_assert_equals( 'client/finish', $extracted_calls[1]['name'] ?? '', 'second candidate tool call is preserved', $failures, $passes ); +agents_api_smoke_assert_equals( 'call_two', $extracted_calls[1]['id'] ?? '', 'second candidate tool call id is preserved', $failures, $passes ); + echo "\n[3] Non-empty incomplete decisions append continuation messages and events:\n"; $continue_events = array(); @@ -320,4 +418,69 @@ static function ( array $messages, array $context ) use ( &$caller_managed_turns agents_api_smoke_assert_equals( 1, $caller_managed_turns, 'caller-managed loop stopped after one turn via completion policy', $failures, $passes ); +echo "\n[6] Completion policy does not truncate same-turn tool results for Responses API pairing:\n"; +$policy_log = array(); +$same_turn_executor_calls = 0; +$same_turn_executor = new class( $same_turn_executor_calls ) implements AgentsAPI\AI\Tools\WP_Agent_Tool_Executor { + private int $calls; + + public function __construct( int &$calls ) { + $this->calls = &$calls; + } + + public function executeWP_Agent_Tool_Call( array $tool_call, array $tool_definition, array $context = array() ): array { + ++$this->calls; + return array( + 'success' => true, + 'tool_name' => $tool_call['tool_name'], + 'result' => array( 'path' => $tool_call['parameters']['path'] ?? '' ), + ); + } +}; + +$same_turn_result = AgentsAPI\AI\WP_Agent_Conversation_Loop::run( + array( array( 'role' => 'user', 'content' => 'write files' ) ), + static function ( array $messages ): array { + return array( + 'messages' => $messages, + 'tool_calls' => array( + array( 'id' => 'call_one', 'name' => 'client/work', 'parameters' => array( 'path' => 'one.html' ) ), + array( 'id' => 'call_two', 'name' => 'client/work', 'parameters' => array( 'path' => 'two.html' ) ), + ), + ); + }, + array( + 'max_turns' => 5, + 'tool_executor' => $same_turn_executor, + 'tool_declarations' => $tools, + 'completion_policy' => $always_complete_policy, + 'should_continue' => static function (): bool { + return true; + }, + ) +); + +$same_turn_tool_results = array_values( + array_filter( + $same_turn_result['messages'], + static function ( array $message ): bool { + return ( $message['type'] ?? '' ) === AgentsAPI\AI\WP_Agent_Message::TYPE_TOOL_RESULT; + } + ) +); +$same_turn_stop_events = array_values( + array_filter( + $same_turn_result['events'], + static function ( array $event ): bool { + return 'completion_policy_stop' === ( $event['type'] ?? '' ); + } + ) +); + +agents_api_smoke_assert_equals( 2, $same_turn_executor_calls, 'completion policy still executes every same-turn tool call', $failures, $passes ); +agents_api_smoke_assert_equals( 2, count( $same_turn_result['tool_execution_results'] ), 'completion policy preserves every same-turn tool execution result', $failures, $passes ); +agents_api_smoke_assert_equals( 2, count( $same_turn_tool_results ), 'transcript contains every same-turn tool result message', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'call_one', 'call_two' ), array_column( array_column( $same_turn_tool_results, 'metadata' ), 'tool_call_id' ), 'tool result messages keep every provider call id', $failures, $passes ); +agents_api_smoke_assert_equals( 1, count( $same_turn_stop_events ), 'complete decision records one stop event while draining same-turn calls', $failures, $passes ); + agents_api_smoke_finish( 'Agents API conversation loop completion policy', $failures, $passes ); diff --git a/tests/runtime-tool-policy-smoke.php b/tests/runtime-tool-policy-smoke.php index c3538e0..9067a3d 100644 --- a/tests/runtime-tool-policy-smoke.php +++ b/tests/runtime-tool-policy-smoke.php @@ -58,7 +58,20 @@ ) ); +$provider_safe_runtime_tool = WP_Agent_Tool_Declaration::normalize( + array( + 'name' => 'filesystem_write', + 'source' => WP_Agent_Tool_Declaration::SOURCE_CLIENT, + 'description' => 'Write one generated website artifact file.', + 'parameters' => array( 'type' => 'object' ), + 'executor' => WP_Agent_Tool_Declaration::EXECUTOR_CLIENT, + 'scope' => WP_Agent_Tool_Declaration::SCOPE_RUN, + ) +); + agents_api_smoke_assert_equals( WP_Agent_Runtime_Tool_Policy::SCHEMA, $policy['schema'] ?? '', 'policy exposes canonical schema', $failures, $passes ); +agents_api_smoke_assert_equals( 'filesystem_write', $provider_safe_runtime_tool['name'] ?? '', 'provider-safe runtime tool name is accepted', $failures, $passes ); +agents_api_smoke_assert_equals( WP_Agent_Tool_Declaration::SOURCE_CLIENT, $provider_safe_runtime_tool['source'] ?? '', 'provider-safe runtime tool keeps client source', $failures, $passes ); agents_api_smoke_assert_equals( WP_Agent_Runtime_Tool_Policy::VERSION, $policy['version'] ?? 0, 'policy exposes canonical version', $failures, $passes ); agents_api_smoke_assert_equals( 3, count( $policy['tools'] ?? array() ), 'policy includes all declared tools', $failures, $passes ); agents_api_smoke_assert_equals( array( 'runtime_type' => 'wordpress-playground', 'session_id' => 'abc123' ), $policy['context'] ?? array(), 'policy context keeps only scalar metadata', $failures, $passes );