diff --git a/agents-api.php b/agents-api.php index c2909ef..a88d175 100644 --- a/agents-api.php +++ b/agents-api.php @@ -167,6 +167,8 @@ require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-option-run-control-store.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-run-control.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-run-result-envelope.php'; +require_once AGENTS_API_PATH . 'src/Runtime/interface-wp-agent-run-control-adapter.php'; +require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-filter-run-control-adapter.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-run-outcome.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-runtime-package-run-request.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-runtime-package-run-result.php'; diff --git a/composer.json b/composer.json index 753af5c..e103b52 100644 --- a/composer.json +++ b/composer.json @@ -88,6 +88,7 @@ "php tests/iteration-budget-smoke.php", "php tests/conversation-loop-budgets-smoke.php", "php tests/runtime-package-run-contract-smoke.php", + "php tests/run-control-normalization-smoke.php", "php tests/channels-smoke.php", "php tests/chat-run-control-smoke.php", "php tests/task-execution-smoke.php", diff --git a/src/Channels/register-agents-chat-run-control-abilities.php b/src/Channels/register-agents-chat-run-control-abilities.php index e1ed631..95a878a 100644 --- a/src/Channels/register-agents-chat-run-control-abilities.php +++ b/src/Channels/register-agents-chat-run-control-abilities.php @@ -8,6 +8,8 @@ namespace AgentsAPI\AI\Channels; use AgentsAPI\AI\WP_Agent_Chat_Run_Control; +use AgentsAPI\AI\WP_Agent_Filter_Run_Control_Adapter; +use AgentsAPI\AI\WP_Agent_Run_Control; defined( 'ABSPATH' ) || exit; @@ -94,9 +96,9 @@ static function (): void { * @return array|\WP_Error */ function agents_get_chat_run( array $input ) { - $handler = apply_filters( 'wp_agent_chat_run_status_handler', null, $input ); - if ( is_callable( $handler ) ) { - return agents_chat_run_control_normalize_result( call_user_func( $handler, $input ), 'agents_chat_run_invalid_status' ); + $result = agents_chat_run_control_adapter()->get_run( $input ); + if ( null !== $result ) { + return is_wp_error( $result ) ? $result : agents_chat_run_control_normalize_result( $result, 'agents_chat_run_invalid_status' ); } $run = WP_Agent_Chat_Run_Control::get_run( agents_chat_run_control_string( $input['run_id'] ?? '' ) ); @@ -116,13 +118,7 @@ function agents_get_chat_run( array $input ) { * @return array|\WP_Error */ function agents_list_chat_run_events( array $input ) { - $handler = apply_filters( 'wp_agent_chat_run_events_handler', null, $input ); - if ( is_callable( $handler ) ) { - $result = call_user_func( $handler, $input ); - return agents_chat_run_events_normalize_result( $result ); - } - - return agents_chat_run_control_no_handler( 'agents_chat_run_events_no_handler', 'No chat run events handler is registered.' ); + return agents_chat_run_events_normalize_result( agents_chat_run_control_adapter()->list_events( $input ) ); } /** @@ -130,9 +126,9 @@ function agents_list_chat_run_events( array $input ) { * @return array|\WP_Error */ function agents_cancel_chat_run( array $input ) { - $handler = apply_filters( 'wp_agent_chat_run_cancel_handler', null, $input ); - if ( is_callable( $handler ) ) { - $result = agents_chat_run_control_normalize_result( call_user_func( $handler, $input ), 'agents_chat_run_invalid_cancel_result' ); + $result = agents_chat_run_control_adapter()->cancel_run( $input ); + if ( null !== $result ) { + $result = is_wp_error( $result ) ? $result : agents_chat_run_control_normalize_result( $result, 'agents_chat_run_invalid_cancel_result' ); } else { $run = WP_Agent_Chat_Run_Control::get_run( agents_chat_run_control_string( $input['run_id'] ?? '' ) ); $requested_session_id = agents_chat_run_control_string( $input['session_id'] ?? '' ); @@ -284,8 +280,9 @@ function agents_chat_run_control_normalize_result( $result, string $error_code ) return $result; } - if ( ! is_array( $result ) ) { - return new \WP_Error( $error_code, 'Chat run-control handlers must return an array or WP_Error.' ); + $result = WP_Agent_Run_Control::normalize_run_result( $result, $error_code ); + if ( is_wp_error( $result ) ) { + return $result; } try { @@ -304,8 +301,9 @@ function agents_chat_run_events_normalize_result( $result ) { return $result; } - if ( ! is_array( $result ) ) { - return new \WP_Error( 'agents_chat_run_invalid_events_result', 'Chat run event handlers must return an array or WP_Error.' ); + $result = WP_Agent_Run_Control::normalize_events_result( $result, 'agents_chat_run_invalid_events_result' ); + if ( is_wp_error( $result ) ) { + return $result; } $result = agents_chat_run_control_string_keyed_array( $result ); @@ -319,6 +317,24 @@ function agents_chat_run_events_normalize_result( $result ) { return $result; } +function agents_chat_run_control_adapter(): WP_Agent_Filter_Run_Control_Adapter { + static $adapter = null; + if ( ! $adapter instanceof WP_Agent_Filter_Run_Control_Adapter ) { + $adapter = new WP_Agent_Filter_Run_Control_Adapter( + 'wp_agent_chat_run_status_handler', + 'wp_agent_chat_run_events_handler', + 'wp_agent_chat_run_cancel_handler', + 'agents_chat_run_invalid_status', + 'agents_chat_run_invalid_events_result', + 'agents_chat_run_invalid_cancel_result', + 'agents_chat_run_events_no_handler', + 'No chat run events handler is registered.' + ); + } + + return $adapter; +} + function agents_chat_run_control_string( mixed $value ): string { if ( is_scalar( $value ) ) { return (string) $value; diff --git a/src/Runtime/class-wp-agent-filter-run-control-adapter.php b/src/Runtime/class-wp-agent-filter-run-control-adapter.php new file mode 100644 index 0000000..1dde358 --- /dev/null +++ b/src/Runtime/class-wp-agent-filter-run-control-adapter.php @@ -0,0 +1,73 @@ + $input Run-control request input. + * @return array|\WP_Error|null + */ + public function get_run( array $input ) { + $handler = apply_filters( $this->status_filter, null, $input ); + if ( ! is_callable( $handler ) ) { + return null; + } + + return WP_Agent_Run_Control::normalize_run_result( call_user_func( $handler, $input ), $this->invalid_status_code ); + } + + /** + * @param array $input Run-control request input. + * @return array|\WP_Error + */ + public function list_events( array $input ) { + $handler = apply_filters( $this->events_filter, null, $input ); + if ( ! is_callable( $handler ) ) { + return new \WP_Error( $this->events_no_handler_code, $this->events_no_handler_message ); + } + + return WP_Agent_Run_Control::normalize_events_result( call_user_func( $handler, $input ), $this->invalid_events_code ); + } + + /** + * @param array $input Run-control request input. + * @return array|\WP_Error|null + */ + public function cancel_run( array $input ) { + $handler = apply_filters( $this->cancel_filter, null, $input ); + if ( ! is_callable( $handler ) ) { + return null; + } + + return WP_Agent_Run_Control::normalize_cancel_result( call_user_func( $handler, $input ), $this->invalid_cancel_code ); + } +} diff --git a/src/Runtime/class-wp-agent-run-control.php b/src/Runtime/class-wp-agent-run-control.php index 2af4180..feb0dab 100644 --- a/src/Runtime/class-wp-agent-run-control.php +++ b/src/Runtime/class-wp-agent-run-control.php @@ -110,6 +110,111 @@ public static function normalize_run( array $run ): array { return $normalized; } + /** + * Normalize a handler result into the generic run envelope. + * + * @param mixed $result Handler result. + * @param string $error_code Error code for invalid results. + * @return array|\WP_Error + */ + public static function normalize_run_result( mixed $result, string $error_code ) { + if ( is_wp_error( $result ) ) { + return $result; + } + + if ( ! is_array( $result ) ) { + return new \WP_Error( $error_code, 'Run-control handlers must return an array or WP_Error.' ); + } + + try { + return self::normalize_run( self::string_keyed_array( $result ) ); + } catch ( \InvalidArgumentException $error ) { + return new \WP_Error( $error_code, $error->getMessage() ); + } + } + + /** + * Normalize a cancellation result and infer whether the request was accepted. + * + * @param mixed $result Handler result. + * @param string $error_code Error code for invalid results. + * @return array|\WP_Error + */ + public static function normalize_cancel_result( mixed $result, string $error_code ) { + $result = self::normalize_run_result( $result, $error_code ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $status = self::normalize_status( $result['status'] ?? self::STATUS_RUNNING ); + $result['status'] = $status; + $result['cancelled'] = (bool) ( $result['cancelled'] ?? in_array( + $status, + array( + self::STATUS_CANCELLING, + self::STATUS_CANCELLED, + ), + true + ) ); + + return $result; + } + + /** + * Normalize an event page for an addressable run. + * + * @param mixed $result Handler result. + * @param string $error_code Error code for invalid results. + * @return array|\WP_Error + */ + public static function normalize_events_result( mixed $result, string $error_code ) { + if ( is_wp_error( $result ) ) { + return $result; + } + + if ( ! is_array( $result ) ) { + return new \WP_Error( $error_code, 'Run event handlers must return an array or WP_Error.' ); + } + + try { + $run = self::normalize_run( self::string_keyed_array( $result ) ); + } catch ( \InvalidArgumentException $error ) { + return new \WP_Error( $error_code, $error->getMessage() ); + } + + $events = array(); + foreach ( is_array( $result['events'] ?? null ) ? array_values( $result['events'] ) : array() as $event ) { + if ( is_array( $event ) ) { + $events[] = self::normalize_event( self::string_keyed_array( $event ) ); + } + } + + $run['events'] = $events; + $run['cursor'] = self::string_value( $result['cursor'] ?? '' ); + $run['has_more'] = (bool) ( $result['has_more'] ?? false ); + + return $run; + } + + /** + * @param array $event Raw event. + * @return array + */ + public static function normalize_event( array $event ): array { + $normalized = array( + 'id' => self::string_value( $event['id'] ?? '' ), + 'type' => self::string_value( $event['type'] ?? '' ), + 'created_at' => self::string_value( $event['created_at'] ?? '' ), + 'metadata' => isset( $event['metadata'] ) && is_array( $event['metadata'] ) ? self::string_keyed_array( $event['metadata'] ) : array(), + ); + + if ( isset( $event['message'] ) ) { + $normalized['message'] = self::string_value( $event['message'] ); + } + + return $normalized; + } + /** * Start or update an addressable run in the selected store. * @@ -255,10 +360,25 @@ public static function now(): string { return gmdate( 'c' ); } - private static function string_value( mixed $value ): string { + public static function string_value( mixed $value ): string { return is_int( $value ) || is_float( $value ) || is_string( $value ) || is_bool( $value ) ? (string) $value : ''; } + /** + * @param array $value Raw array. + * @return array + */ + public static function string_keyed_array( array $value ): array { + $result = array(); + foreach ( $value as $key => $item ) { + if ( is_string( $key ) ) { + $result[ $key ] = $item; + } + } + + return $result; + } + private static function int_value( mixed $value ): int { return is_int( $value ) || is_float( $value ) || is_string( $value ) || is_bool( $value ) ? (int) $value : 0; } diff --git a/src/Runtime/interface-wp-agent-run-control-adapter.php b/src/Runtime/interface-wp-agent-run-control-adapter.php new file mode 100644 index 0000000..85dfa12 --- /dev/null +++ b/src/Runtime/interface-wp-agent-run-control-adapter.php @@ -0,0 +1,31 @@ + $input Run-control request input. + * @return array|\WP_Error|null + */ + public function get_run( array $input ); + + /** + * @param array $input Run-control request input. + * @return array|\WP_Error + */ + public function list_events( array $input ); + + /** + * @param array $input Run-control request input. + * @return array|\WP_Error|null + */ + public function cancel_run( array $input ); +} diff --git a/tests/run-control-normalization-smoke.php b/tests/run-control-normalization-smoke.php new file mode 100644 index 0000000..780ba28 --- /dev/null +++ b/tests/run-control-normalization-smoke.php @@ -0,0 +1,95 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data(): array { return $this->data; } + } +} + +agents_api_smoke_require_module(); + +$status = AgentsAPI\AI\WP_Agent_Run_Control::normalize_run_result( + array( + 'run_id' => 'run-1', + 'status' => 'APPROVAL_REQUIRED', + 'metadata' => array( 'provider' => 'test' ), + ), + 'agents_run_invalid_status' +); +agents_api_smoke_assert_equals( 'approval_required', $status['status'] ?? null, 'status normalization lowercases known statuses', $failures, $passes ); +agents_api_smoke_assert_equals( 'test', $status['metadata']['provider'] ?? null, 'status normalization preserves metadata', $failures, $passes ); + +$invalid_status = AgentsAPI\AI\WP_Agent_Run_Control::normalize_run_result( array( 'status' => 'running' ), 'agents_run_invalid_status' ); +agents_api_smoke_assert_equals( true, $invalid_status instanceof WP_Error, 'status normalization rejects missing run_id', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_run_invalid_status', $invalid_status->get_error_code(), 'status normalization returns configured error code', $failures, $passes ); + +$cancel = AgentsAPI\AI\WP_Agent_Run_Control::normalize_cancel_result( + array( + 'run_id' => 'run-2', + 'status' => 'cancelling', + ), + 'agents_run_invalid_cancel' +); +agents_api_smoke_assert_equals( true, $cancel['cancelled'] ?? null, 'cancel normalization infers accepted cancellation', $failures, $passes ); + +$completed_cancel = AgentsAPI\AI\WP_Agent_Run_Control::normalize_cancel_result( + array( + 'run_id' => 'run-3', + 'status' => 'completed', + 'cancelled' => false, + ), + 'agents_run_invalid_cancel' +); +agents_api_smoke_assert_equals( false, $completed_cancel['cancelled'] ?? null, 'cancel normalization preserves explicit terminal cancellation state', $failures, $passes ); + +$events = AgentsAPI\AI\WP_Agent_Run_Control::normalize_events_result( + array( + 'run_id' => 'run-4', + 'status' => 'running', + 'events' => array( + array( + 'id' => 123, + 'type' => 'tool_call', + 'message' => true, + 'created_at' => '2026-01-01T00:00:00Z', + 'metadata' => array( + 'tool_name' => 'client/tool', + 0 => 'ignored', + ), + ), + 'ignored', + ), + 'cursor' => 456, + 'has_more' => 1, + ), + 'agents_run_invalid_events' +); +agents_api_smoke_assert_equals( '123', $events['events'][0]['id'] ?? null, 'event normalization stringifies scalar event ids', $failures, $passes ); +agents_api_smoke_assert_equals( '1', $events['events'][0]['message'] ?? null, 'event normalization stringifies scalar messages', $failures, $passes ); +agents_api_smoke_assert_equals( 'client/tool', $events['events'][0]['metadata']['tool_name'] ?? null, 'event normalization preserves string-keyed metadata', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 0, $events['events'][0]['metadata'] ?? array() ), 'event normalization drops non-string metadata keys', $failures, $passes ); +agents_api_smoke_assert_equals( '456', $events['cursor'] ?? null, 'event normalization stringifies cursors', $failures, $passes ); +agents_api_smoke_assert_equals( true, $events['has_more'] ?? null, 'event normalization coerces has_more', $failures, $passes ); + +agents_api_smoke_finish( 'run-control normalization', $failures, $passes );