Changeset 61795
Legend:
- Unmodified
- Added
- Removed
-
trunk/src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php
r61700 r61795 17 17 * Resolves and executes WordPress Abilities API function calls from AI models. 18 18 * 19 * This class must be instantiated with the specific abilities that the AI model 20 * is allowed to execute, ensuring that only explicitly specified abilities can 21 * be called. This prevents the model from executing arbitrary abilities. 22 * 19 23 * @since 7.0.0 20 24 */ … … 30 34 31 35 /** 36 * Map of allowed ability names for this instance. 37 * 38 * Keys are ability name strings, values are `true` for O(1) lookup. 39 * 40 * @since 7.0.0 41 * @var array<string, true> 42 */ 43 private array $allowed_abilities; 44 45 /** 46 * Constructor. 47 * 48 * @since 7.0.0 49 * 50 * @param WP_Ability|string ...$abilities The abilities that this resolver is allowed to execute. 51 */ 52 public function __construct( ...$abilities ) { 53 $this->allowed_abilities = array(); 54 55 foreach ( $abilities as $ability ) { 56 if ( $ability instanceof WP_Ability ) { 57 $this->allowed_abilities[ $ability->get_name() ] = true; 58 } elseif ( is_string( $ability ) ) { 59 $this->allowed_abilities[ $ability ] = true; 60 } 61 } 62 } 63 64 /** 32 65 * Checks if a function call is an ability call. 33 66 * … … 37 70 * @return bool True if the function call is an ability call, false otherwise. 38 71 */ 39 public staticfunction is_ability_call( FunctionCall $call ): bool {72 public function is_ability_call( FunctionCall $call ): bool { 40 73 $name = $call->getName(); 41 74 if ( null === $name ) { … … 49 82 * Executes a WordPress ability from a function call. 50 83 * 84 * Only abilities that were specified in the constructor are allowed to be 85 * executed. If the ability is not in the allowed list, an error response 86 * with code `ability_not_allowed` is returned. 87 * 51 88 * @since 7.0.0 52 89 * … … 54 91 * @return FunctionResponse The response from executing the ability. 55 92 */ 56 public staticfunction execute_ability( FunctionCall $call ): FunctionResponse {93 public function execute_ability( FunctionCall $call ): FunctionResponse { 57 94 $function_name = $call->getName() ?? 'unknown'; 58 95 $function_id = $call->getId() ?? 'unknown'; 59 96 60 if ( ! self::is_ability_call( $call ) ) {97 if ( ! $this->is_ability_call( $call ) ) { 61 98 return new FunctionResponse( 62 99 $function_id, … … 70 107 71 108 $ability_name = self::function_name_to_ability_name( $function_name ); 72 $ability = wp_get_ability( $ability_name ); 109 110 if ( ! isset( $this->allowed_abilities[ $ability_name ] ) ) { 111 return new FunctionResponse( 112 $function_id, 113 $function_name, 114 array( 115 /* translators: %s: ability name */ 116 'error' => sprintf( __( 'Ability "%s" was not specified in the allowed abilities list.' ), $ability_name ), 117 'code' => 'ability_not_allowed', 118 ) 119 ); 120 } 121 122 $ability = wp_get_ability( $ability_name ); 73 123 74 124 if ( ! $ability instanceof WP_Ability ) { … … 114 164 * @return bool True if the message contains ability calls, false otherwise. 115 165 */ 116 public staticfunction has_ability_calls( Message $message ): bool {117 return null !== array_find(118 $message->getParts(),119 static function ( MessagePart $part ): bool {120 return $part->getType()->isFunctionCall()121 && $part->getFunctionCall() instanceof FunctionCall122 && self::is_ability_call( $part->getFunctionCall() );166 public function has_ability_calls( Message $message ): bool { 167 foreach ( $message->getParts() as $part ) { 168 if ( $part->getType()->isFunctionCall() ) { 169 $function_call = $part->getFunctionCall(); 170 if ( $function_call instanceof FunctionCall && $this->is_ability_call( $function_call ) ) { 171 return true; 172 } 123 173 } 124 ); 174 } 175 176 return false; 125 177 } 126 178 … … 133 185 * @return Message A new message with function responses. 134 186 */ 135 public staticfunction execute_abilities( Message $message ): Message {187 public function execute_abilities( Message $message ): Message { 136 188 $response_parts = array(); 137 189 … … 140 192 $function_call = $part->getFunctionCall(); 141 193 if ( $function_call instanceof FunctionCall ) { 142 $function_response = self::execute_ability( $function_call );194 $function_response = $this->execute_ability( $function_call ); 143 195 $response_parts[] = new MessagePart( $function_response ); 144 196 } -
trunk/tests/phpunit/tests/ai-client/wpAiClientAbilityFunctionResolver.php
r61777 r61795 42 42 */ 43 43 public function test_is_ability_call_returns_true_for_valid_ability() { 44 $call = new FunctionCall( 44 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'tec/create_event' ); 45 $call = new FunctionCall( 45 46 'test-id', 46 47 'wpab__tec__create_event', … … 48 49 ); 49 50 50 $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call );51 $result = $resolver->is_ability_call( $call ); 51 52 52 53 $this->assertTrue( $result ); … … 59 60 */ 60 61 public function test_is_ability_call_returns_true_for_nested_namespace() { 61 $call = new FunctionCall( 62 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'tec/v1/create_event' ); 63 $call = new FunctionCall( 62 64 'test-id', 63 65 'wpab__tec__v1__create_event', … … 65 67 ); 66 68 67 $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call );69 $result = $resolver->is_ability_call( $call ); 68 70 69 71 $this->assertTrue( $result ); … … 76 78 */ 77 79 public function test_is_ability_call_returns_false_for_non_ability() { 78 $call = new FunctionCall( 80 $resolver = new WP_AI_Client_Ability_Function_Resolver(); 81 $call = new FunctionCall( 79 82 'test-id', 80 83 'regular_function', … … 82 85 ); 83 86 84 $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call );87 $result = $resolver->is_ability_call( $call ); 85 88 86 89 $this->assertFalse( $result ); … … 93 96 */ 94 97 public function test_is_ability_call_returns_false_when_name_is_null() { 95 $call = new FunctionCall( 98 $resolver = new WP_AI_Client_Ability_Function_Resolver(); 99 $call = new FunctionCall( 96 100 'test-id', 97 101 null, … … 99 103 ); 100 104 101 $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call );105 $result = $resolver->is_ability_call( $call ); 102 106 103 107 $this->assertFalse( $result ); … … 110 114 */ 111 115 public function test_is_ability_call_returns_false_for_partial_prefix() { 112 $call = new FunctionCall( 116 $resolver = new WP_AI_Client_Ability_Function_Resolver(); 117 $call = new FunctionCall( 113 118 'test-id', 114 119 'wpab_single_underscore', … … 116 121 ); 117 122 118 $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call );123 $result = $resolver->is_ability_call( $call ); 119 124 120 125 $this->assertFalse( $result ); … … 127 132 */ 128 133 public function test_execute_ability_returns_error_for_non_ability_call() { 129 $call = new FunctionCall( 134 $resolver = new WP_AI_Client_Ability_Function_Resolver(); 135 $call = new FunctionCall( 130 136 'test-id', 131 137 'regular_function', … … 133 139 ); 134 140 135 $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call );141 $response = $resolver->execute_ability( $call ); 136 142 137 143 $this->assertInstanceOf( FunctionResponse::class, $response ); … … 154 160 $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); 155 161 156 $call = new FunctionCall( 162 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'nonexistent/ability' ); 163 $call = new FunctionCall( 157 164 'test-id', 158 165 'wpab__nonexistent__ability', … … 160 167 ); 161 168 162 $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call );169 $response = $resolver->execute_ability( $call ); 163 170 164 171 $this->assertInstanceOf( FunctionResponse::class, $response ); … … 181 188 $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); 182 189 183 $call = new FunctionCall( 190 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'nonexistent/ability' ); 191 $call = new FunctionCall( 184 192 null, 185 193 'wpab__nonexistent__ability', … … 187 195 ); 188 196 189 $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call );197 $response = $resolver->execute_ability( $call ); 190 198 191 199 $this->assertInstanceOf( FunctionResponse::class, $response ); … … 199 207 */ 200 208 public function test_has_ability_calls_returns_true_when_present() { 201 $call = new FunctionCall( 209 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'tec/create_event' ); 210 $call = new FunctionCall( 202 211 'test-id', 203 212 'wpab__tec__create_event', … … 212 221 ); 213 222 214 $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message );223 $result = $resolver->has_ability_calls( $message ); 215 224 216 225 $this->assertTrue( $result ); … … 223 232 */ 224 233 public function test_has_ability_calls_returns_false_when_not_present() { 225 $call = new FunctionCall( 234 $resolver = new WP_AI_Client_Ability_Function_Resolver(); 235 $call = new FunctionCall( 226 236 'test-id', 227 237 'regular_function', … … 236 246 ); 237 247 238 $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message );248 $result = $resolver->has_ability_calls( $message ); 239 249 240 250 $this->assertFalse( $result ); … … 247 257 */ 248 258 public function test_has_ability_calls_returns_false_for_text_only() { 249 $message = new UserMessage( 259 $resolver = new WP_AI_Client_Ability_Function_Resolver(); 260 $message = new UserMessage( 250 261 array( 251 262 new MessagePart( 'Just some text' ), … … 253 264 ); 254 265 255 $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message );266 $result = $resolver->has_ability_calls( $message ); 256 267 257 268 $this->assertFalse( $result ); … … 264 275 */ 265 276 public function test_has_ability_calls_returns_true_with_mixed_content() { 277 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'tec/create_event' ); 278 266 279 $regular_call = new FunctionCall( 267 280 'regular-id', … … 284 297 ); 285 298 286 $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message );299 $result = $resolver->has_ability_calls( $message ); 287 300 288 301 $this->assertTrue( $result ); … … 295 308 */ 296 309 public function test_has_ability_calls_with_empty_message() { 297 $message = new ModelMessage( array() ); 298 299 $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message ); 310 $resolver = new WP_AI_Client_Ability_Function_Resolver(); 311 $message = new ModelMessage( array() ); 312 313 $result = $resolver->has_ability_calls( $message ); 300 314 301 315 $this->assertFalse( $result ); … … 308 322 */ 309 323 public function test_execute_abilities_with_empty_message() { 310 $message = new ModelMessage( array() ); 311 312 $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); 324 $resolver = new WP_AI_Client_Ability_Function_Resolver(); 325 $message = new ModelMessage( array() ); 326 327 $result = $resolver->execute_abilities( $message ); 313 328 314 329 $this->assertInstanceOf( UserMessage::class, $result ); … … 324 339 $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); 325 340 326 $call = new FunctionCall( 341 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'nonexistent/ability' ); 342 $call = new FunctionCall( 327 343 'test-id', 328 344 'wpab__nonexistent__ability', … … 336 352 ); 337 353 338 $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );354 $result = $resolver->execute_abilities( $message ); 339 355 340 356 $this->assertInstanceOf( UserMessage::class, $result ); … … 356 372 $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); 357 373 358 $call = new FunctionCall( 374 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'nonexistent/ability' ); 375 $call = new FunctionCall( 359 376 'test-id', 360 377 'wpab__nonexistent__ability', … … 368 385 ); 369 386 370 $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );387 $result = $resolver->execute_abilities( $message ); 371 388 372 389 $this->assertInstanceOf( UserMessage::class, $result ); … … 380 397 public function test_execute_abilities_processes_multiple_calls() { 381 398 $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); 399 400 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'nonexistent/ability1', 'nonexistent/ability2' ); 382 401 383 402 $call1 = new FunctionCall( … … 400 419 ); 401 420 402 $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );421 $result = $resolver->execute_abilities( $message ); 403 422 404 423 $this->assertInstanceOf( UserMessage::class, $result ); … … 415 434 $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); 416 435 417 $call = new FunctionCall( 436 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'nonexistent/ability' ); 437 $call = new FunctionCall( 418 438 'test-id', 419 439 'wpab__nonexistent__ability', … … 429 449 ); 430 450 431 $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );451 $result = $resolver->execute_abilities( $message ); 432 452 433 453 $this->assertInstanceOf( UserMessage::class, $result ); … … 465 485 */ 466 486 public function test_execute_ability_success() { 467 $call = new FunctionCall( 487 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/simple' ); 488 $call = new FunctionCall( 468 489 'test-id', 469 490 'wpab__wpaiclienttests__simple', … … 471 492 ); 472 493 473 $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call );494 $response = $resolver->execute_ability( $call ); 474 495 475 496 $this->assertInstanceOf( FunctionResponse::class, $response ); … … 488 509 */ 489 510 public function test_execute_ability_with_parameters() { 490 $call = new FunctionCall( 511 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/with-params' ); 512 $call = new FunctionCall( 491 513 'test-id', 492 514 'wpab__wpaiclienttests__with-params', … … 494 516 ); 495 517 496 $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call );518 $response = $resolver->execute_ability( $call ); 497 519 498 520 $this->assertInstanceOf( FunctionResponse::class, $response ); … … 513 535 */ 514 536 public function test_execute_ability_handles_wp_error() { 515 $call = new FunctionCall( 537 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/returns-error' ); 538 $call = new FunctionCall( 516 539 'test-id', 517 540 'wpab__wpaiclienttests__returns-error', … … 519 542 ); 520 543 521 $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call );544 $response = $resolver->execute_ability( $call ); 522 545 523 546 $this->assertInstanceOf( FunctionResponse::class, $response ); … … 538 561 */ 539 562 public function test_execute_abilities_success() { 540 $call = new FunctionCall( 563 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/simple' ); 564 $call = new FunctionCall( 541 565 'test-id', 542 566 'wpab__wpaiclienttests__simple', … … 550 574 ); 551 575 552 $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );576 $result = $resolver->execute_abilities( $message ); 553 577 554 578 $this->assertInstanceOf( UserMessage::class, $result ); … … 569 593 */ 570 594 public function test_execute_abilities_multiple_success() { 595 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/simple', 'wpaiclienttests/hyphen-test' ); 596 571 597 $call1 = new FunctionCall( 572 598 'call-1', … … 588 614 ); 589 615 590 $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );616 $result = $resolver->execute_abilities( $message ); 591 617 592 618 $this->assertInstanceOf( UserMessage::class, $result ); … … 615 641 */ 616 642 public function test_execute_abilities_with_mixed_content() { 617 $call = new FunctionCall( 643 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/simple' ); 644 $call = new FunctionCall( 618 645 'test-id', 619 646 'wpab__wpaiclienttests__simple', … … 629 656 ); 630 657 631 $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );658 $result = $resolver->execute_abilities( $message ); 632 659 633 660 $this->assertInstanceOf( UserMessage::class, $result ); … … 646 673 */ 647 674 public function test_execute_abilities_with_parameters() { 648 $call = new FunctionCall( 675 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/with-params' ); 676 $call = new FunctionCall( 649 677 'test-id', 650 678 'wpab__wpaiclienttests__with-params', … … 658 686 ); 659 687 660 $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );688 $result = $resolver->execute_abilities( $message ); 661 689 662 690 $this->assertInstanceOf( UserMessage::class, $result ); … … 672 700 $this->assertSame( 'Integration Test', $data['title'] ); 673 701 } 702 703 /** 704 * Test execute_ability rejects ability not in allowed list. 705 * 706 * @ticket 64769 707 */ 708 public function test_execute_ability_rejects_ability_not_in_allowed_list() { 709 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/simple' ); 710 $call = new FunctionCall( 711 'test-id', 712 'wpab__wpaiclienttests__with-params', 713 array( 'title' => 'Test' ) 714 ); 715 716 $response = $resolver->execute_ability( $call ); 717 718 $this->assertInstanceOf( FunctionResponse::class, $response ); 719 $data = $response->getResponse(); 720 $this->assertIsArray( $data ); 721 $this->assertArrayHasKey( 'error', $data ); 722 $this->assertStringContainsString( 'not specified in the allowed abilities list', $data['error'] ); 723 $this->assertArrayHasKey( 'code', $data ); 724 $this->assertSame( 'ability_not_allowed', $data['code'] ); 725 } 726 727 /** 728 * Test execute_ability rejects all abilities when constructed with no abilities. 729 * 730 * @ticket 64769 731 */ 732 public function test_execute_ability_rejects_all_when_no_abilities_specified() { 733 $resolver = new WP_AI_Client_Ability_Function_Resolver(); 734 $call = new FunctionCall( 735 'test-id', 736 'wpab__wpaiclienttests__simple', 737 array() 738 ); 739 740 $response = $resolver->execute_ability( $call ); 741 742 $this->assertInstanceOf( FunctionResponse::class, $response ); 743 $data = $response->getResponse(); 744 $this->assertIsArray( $data ); 745 $this->assertArrayHasKey( 'code', $data ); 746 $this->assertSame( 'ability_not_allowed', $data['code'] ); 747 } 748 749 /** 750 * Test execute_abilities filters by allowed list. 751 * 752 * @ticket 64769 753 */ 754 public function test_execute_abilities_filters_by_allowed_list() { 755 $resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/simple' ); 756 757 $call1 = new FunctionCall( 758 'call-1', 759 'wpab__wpaiclienttests__simple', 760 array() 761 ); 762 763 $call2 = new FunctionCall( 764 'call-2', 765 'wpab__wpaiclienttests__with-params', 766 array( 'title' => 'Test' ) 767 ); 768 769 $message = new ModelMessage( 770 array( 771 new MessagePart( $call1 ), 772 new MessagePart( $call2 ), 773 ) 774 ); 775 776 $result = $resolver->execute_abilities( $message ); 777 778 $parts = $result->getParts(); 779 $this->assertCount( 2, $parts ); 780 781 $response1_data = $parts[0]->getFunctionResponse()->getResponse(); 782 $this->assertArrayHasKey( 'success', $response1_data ); 783 $this->assertTrue( $response1_data['success'] ); 784 785 $response2_data = $parts[1]->getFunctionResponse()->getResponse(); 786 $this->assertSame( 'ability_not_allowed', $response2_data['code'] ); 787 } 788 789 /** 790 * Test constructor accepts WP_Ability objects. 791 * 792 * @ticket 64769 793 */ 794 public function test_constructor_accepts_wp_ability_objects() { 795 $ability = wp_get_ability( 'wpaiclienttests/simple' ); 796 $resolver = new WP_AI_Client_Ability_Function_Resolver( $ability ); 797 $call = new FunctionCall( 798 'test-id', 799 'wpab__wpaiclienttests__simple', 800 array() 801 ); 802 803 $response = $resolver->execute_ability( $call ); 804 805 $data = $response->getResponse(); 806 $this->assertArrayHasKey( 'success', $data ); 807 $this->assertTrue( $data['success'] ); 808 } 809 810 /** 811 * Test constructor accepts mixed WP_Ability objects and strings. 812 * 813 * @ticket 64769 814 */ 815 public function test_constructor_accepts_mixed_ability_types() { 816 $ability = wp_get_ability( 'wpaiclienttests/simple' ); 817 $resolver = new WP_AI_Client_Ability_Function_Resolver( $ability, 'wpaiclienttests/with-params' ); 818 819 $call1 = new FunctionCall( 820 'call-1', 821 'wpab__wpaiclienttests__simple', 822 array() 823 ); 824 $response1 = $resolver->execute_ability( $call1 ); 825 $this->assertArrayHasKey( 'success', $response1->getResponse() ); 826 827 $call2 = new FunctionCall( 828 'call-2', 829 'wpab__wpaiclienttests__with-params', 830 array( 'title' => 'Test' ) 831 ); 832 $response2 = $resolver->execute_ability( $call2 ); 833 $this->assertArrayHasKey( 'success', $response2->getResponse() ); 834 } 674 835 }
Note: See TracChangeset
for help on using the changeset viewer.