# ai-builder/2.7.8/includes/class-agent-discovery-service.php

AI Builder – Generate pages, blocks, images &amp; translate with AI, version 2.7.8. 646 lines.

- Page: https://pluginprobe.com/plugins/ai-builder/2.7.8/code/includes/class-agent-discovery-service.php
- Raw: https://pluginprobe.com/plugins/ai-builder/2.7.8/raw/includes/class-agent-discovery-service.php
- Modified: 2025-12-03T17:16:46+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/ai-builder/2.7.8/code/includes/class-agent-discovery-service.php#L10-L20`.

```php
<?php
/**
 * Agent Discovery Service
 * 
 * Discovers all WordPress REST API routes and converts them to JSON Schema
 * format compatible with Anthropic/OpenAI function calling.
 *
 * @package AI_Builder
 */

if (!defined('ABSPATH')) {
    exit;
}

class AIBUI_Agent_Discovery_Service
{
    /**
     * Routes that should NEVER be exposed (security blacklist)
     * These cannot be enabled even by admins, regardless of HTTP method.
     */
    const FORBIDDEN_ROUTES = array(
        // '/wp/v2/users',                 // User management - security risk
        // '/wp/v2/users/(?P<id>[\d]+)',   // Individual user access
        '/wp/v2/users/me',              // Current user info
        // '/wp/v2/settings',              // Site settings - dangerous
        '/wp/v2/application-passwords', // Application passwords
        '/jwt-auth',                    // JWT authentication routes
        '/oembed',                      // oEmbed routes
    );

    /**
     * Route patterns to exclude (regex patterns), all methods.
     */
    const FORBIDDEN_PATTERNS = array(
        '#^/wp-site-health#',                           // Site health
        '#^/wp/v2/users/(?P<user_id>(?:[\d]+|me))/#',   // User sub-routes
        // '#^/wp/v2/settings#',                       // All settings routes (now controlled per method)
        '#application-passwords#',                     // Any route touching application passwords
        '#/batch$#',                                   // Batch endpoints
        '#/autosaves#',                                // Autosave routes
        '#/revisions#',                                // Revision routes
    );

    /**
     * Route patterns that are forbidden only for specific HTTP methods.
     * This allows, for example, GET on plugins but blocks POST/DELETE.
     */
    const FORBIDDEN_METHOD_PATTERNS = array(
        'POST' => array(
            '#^/wp/v2/plugins#',
            '#^/wp/v2/themes#',
            // Ban POST on single user route: /wp/v2/users/(?P<id>[\d]+)
            // Match the literal "(?P<id>...)" part from the route pattern by escaping parentheses.
            // '#^/wp/v2/users/\(\?P<id>.+$#',
        ),
        'PUT' => array(
            '#^/wp/v2/plugins#',
            '#^/wp/v2/themes#',
        ),
        'PATCH' => array(
            '#^/wp/v2/plugins#',
            '#^/wp/v2/themes#',
        ),
        'DELETE' => array(
            '#^/wp/v2/plugins#',
            '#^/wp/v2/themes#',
            '#^/wp/v2/users#',
            '#^/wp/v2/posts#',
            '#/wp/v2/pages/#',
            '#/wp/v2/media/#',
            '#/wp/v2/menu-items/#',
            '#/wp/v2/blocks/#',
            '#/wp/v2/templates/#',
            '#/wp/v2/template-parts/(?P<id>([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/#',
            '#/wp/v2/navigation/#',
            '#/wp/v2/menus/#',
            '#/wp/v2/wp_pattern_category/#',
            '#/wp/v2/widgets/#'

        ),
    );

    /**
     * Routes enabled by default when first discovered (method-agnostic).
     * For fine-grained control per HTTP method, see DEFAULT_ENABLED_METHOD_ROUTES.
     */
    const DEFAULT_ENABLED_ROUTES = array(
        '/wp/v2/posts',
        '/wp/v2/posts/(?P<id>[\d]+)',
        '/wp/v2/pages',
        '/wp/v2/pages/(?P<id>[\d]+)',
        '/wp/v2/categories',
        '/wp/v2/tags',
        '/wp/v2/media',
        '/wp/v2/comments',
        '/wp/v2/search',
        '/wp/v2/media/(?P<id>[\d]+)',
        '/wp/v2/menu-items',
        '/wp/v2/blocks',
        '/wp/v2/blocks/(?P<id>[\d]+)',
        '/wp/v2/templates',
        '/wp/v2/template-parts',
        '/wp/v2/global-styles/',
        '/wp/v2/navigation/',
        '/wp/v2/font-families/',
        '/wp/v2/statuses',
        '/wp/v2/categories/',
        '/wp/v2/navigation',
        '/wp/v2/font-families',
        '/wp/v2/menus'
    );

    /**
     * Routes enabled by default for specific HTTP methods.
     * Example: allow GET /wp/v2/settings by default, but not other methods.
     */
    const DEFAULT_ENABLED_METHOD_ROUTES = array(
        'GET' => array(
            '/wp/v2/settings',
            '/wp/v2/users',
            '/wp/v2/block-directory/search',
            '/wp/v2/pattern-directory/patterns',
            '/wp/v2/block-patterns/patterns',
            '/wp/v2/block-patterns/categories',
            '/wp/v2/menu-locations',
            '/wp/v2/menu-locations/(?P<location>[\w-]+)',
            '/wp/v2/font-collections',
            '/wp/v2/font-collections/(?P<slug>[\/\w-]+)',
        ),
        'POST' => array(
            '/wp/v2/users'
        ),
    );

    /**
     * Namespaces to include in discovery
     */
    const ALLOWED_NAMESPACES = array(
        'wp/v2',
        'wc/v3',
        'wc/v2',
        'wc-analytics',
        'wp-block-editor/v1',
        'ai-builder/v1',
    );

    /**
     * Get all discoverable routes
     *
     * @return array
     */
    public function get_all_routes()
    {
        $server = rest_get_server();
        $routes = $server->get_routes();
        $discovered = array();

        foreach ($routes as $route => $handlers) {
            // Get route namespace
            $namespace = $this->extract_namespace($route);
            
            // Skip if namespace not in allowed list (but allow custom namespaces)
            if (empty($namespace)) {
                continue;
            }

            // Process each handler (method) for this route
            foreach ($handlers as $handler) {
                if (!isset($handler['methods']) || !isset($handler['callback'])) {
                    continue;
                }

                $methods = array_keys($handler['methods']);
                
                foreach ($methods as $method) {
                    // Skip forbidden route/method combinations
                    if ($this->is_forbidden_route($route, $method)) {
                        continue;
                    }

                    $route_info = $this->build_route_info($route, $method, $handler, $namespace);
                    if ($route_info) {
                        $discovered[] = $route_info;
                    }
                }
            }
        }

        return $discovered;
    }

    /**
     * Get routes grouped by namespace
     *
     * @return array
     */
    public function get_routes_by_namespace()
    {
        $routes = $this->get_all_routes();
        $grouped = array();

        foreach ($routes as $route) {
            $namespace = $route['namespace'];
            if (!isset($grouped[$namespace])) {
                $grouped[$namespace] = array();
            }
            $grouped[$namespace][] = $route;
        }

        ksort($grouped);
        return $grouped;
    }

    /**
     * Convert routes to OpenAI/Anthropic tools format
     *
     * @param array $routes Array of route info
     * @return array Tools in JSON Schema format
     */
    public function convert_to_tools_format(array $routes)
    {
        $tools = array();

        foreach ($routes as $route) {
            $tool = array(
                'name' => $this->generate_tool_name($route['route'], $route['method']),
                'description' => $this->generate_tool_description($route),
                'input_schema' => array(
                    'type' => 'object',
                    'properties' => $this->convert_args_to_schema($route['args']),
                    'required' => $this->get_required_args($route['args']),
                ),
            );

            // Add route metadata for execution
            $tool['_meta'] = array(
                'route' => $route['route'],
                'method' => $route['method'],
                'namespace' => $route['namespace'],
            );

            $tools[] = $tool;
        }

        return $tools;
    }

    /**
     * Check if a route is forbidden
     *
     * @param string      $route
     * @param string|null $method Optional HTTP method (GET, POST, etc.)
     * @return bool
     */
    public function is_forbidden_route($route, $method = null)
    {
        // Check exact matches
        foreach (self::FORBIDDEN_ROUTES as $forbidden) {
            if ($route === $forbidden) {
                return true;
            }
        }

        // Check global patterns (all methods)
        foreach (self::FORBIDDEN_PATTERNS as $pattern) {
            if (preg_match($pattern, $route)) {
                return true;
            }
        }

        // Check method-specific patterns if method provided
        if (!empty($method)) {
            $method = strtoupper($method);
            if (isset(self::FORBIDDEN_METHOD_PATTERNS[$method])) {
                foreach (self::FORBIDDEN_METHOD_PATTERNS[$method] as $pattern) {
                    if (preg_match($pattern, $route)) {
                        return true;
                    }
                }
            }
        }

        return false;
    }

    /**
     * Check if a route should be enabled by default
     *
     * @param string      $route
     * @param string|null $method Optional HTTP method
     * @return bool
     */
    public function is_default_enabled($route, $method = null)
    {
        // 1) Method-specific defaults
        if (!empty($method)) {
            $method = strtoupper($method);
            if (isset(self::DEFAULT_ENABLED_METHOD_ROUTES[$method])) {
                foreach (self::DEFAULT_ENABLED_METHOD_ROUTES[$method] as $default) {
                    if ($route === $default) {
                        return true;
                    }
                    // Also check if route matches the pattern
                    $pattern = '#^' . preg_replace('/\(\?P<[^>]+>[^)]+\)/', '[^/]+', $default) . '$#';
                    if (preg_match($pattern, $route)) {
                        return true;
                    }
                }
            }
        }

        // 2) Legacy method-agnostic defaults
        foreach (self::DEFAULT_ENABLED_ROUTES as $default) {
            if ($route === $default) {
                return true;
            }
            // Also check if route matches the pattern
            $pattern = '#^' . preg_replace('/\(\?P<[^>]+>[^)]+\)/', '[^/]+', $default) . '$#';
            if (preg_match($pattern, $route)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Extract namespace from route
     *
     * @param string $route
     * @return string
     */
    private function extract_namespace($route)
    {
        // Remove leading slash
        $route = ltrim($route, '/');
        
        // Common patterns: wp/v2/posts, wc/v3/products
        if (preg_match('#^([a-zA-Z0-9_-]+/v\d+)#', $route, $matches)) {
            return $matches[1];
        }

        // Single segment namespace
        if (preg_match('#^([a-zA-Z0-9_-]+)/#', $route, $matches)) {
            return $matches[1];
        }

        return '';
    }

    /**
     * Build route info array
     *
     * @param string $route
     * @param string $method
     * @param array $handler
     * @param string $namespace
     * @return array|null
     */
    private function build_route_info($route, $method, $handler, $namespace)
    {
        $args = isset($handler['args']) ? $handler['args'] : array();
        
        // Clean up args, remove internal WordPress args
        $cleaned_args = $this->clean_args($args);

        $upper_method = strtoupper($method);

        $route_info = array(
            'route' => $route,
            'method' => $upper_method,
            'namespace' => $namespace,
            'args' => $cleaned_args,
            'permission_callback' => isset($handler['permission_callback']) ? true : false,
        );

        // Always generate a short, human description for the UI and tools
        $route_info['description'] = $this->generate_tool_description($route_info);

        // Pass method so we can have method-specific defaults (e.g. GET /wp/v2/settings)
        $route_info['is_default_enabled'] = $this->is_default_enabled($route, $upper_method);
        $route_info['unique_id']        = $this->generate_route_id($route, $upper_method);

        return $route_info;
    }

    /**
     * Try to extract a human-readable description for a route from its handler.
     *
     * @param array  $handler Route handler configuration.
     * @param string $route   Route path.
     * @param string $method  HTTP method.
     * @return string
     */
    private function extract_route_description($handler, $route, $method)
    {
        // Some routes may expose a direct description field
        if (isset($handler['description']) && is_string($handler['description']) && $handler['description'] !== '') {
            return $handler['description'];
        }

        // Try schema description if schema is already resolved as an array
        if (isset($handler['schema']) && is_array($handler['schema']) && isset($handler['schema']['description'])) {
            $desc = $handler['schema']['description'];
            if (is_string($desc) && $desc !== '') {
                return $desc;
            }
        }

        // Fallback: empty string, UI will show a "Docs" button instead
        return '';
    }

    /**
     * Clean arguments, remove internal WP args
     *
     * @param array $args
     * @return array
     */
    private function clean_args($args)
    {
        $internal_args = array('context', '_fields', '_embed', '_envelope');
        $cleaned = array();

        foreach ($args as $key => $config) {
            if (in_array($key, $internal_args)) {
                continue;
            }
            $cleaned[$key] = $config;
        }

        return $cleaned;
    }

    /**
     * Convert WordPress REST args to JSON Schema properties
     *
     * @param array $args
     * @return array
     */
    private function convert_args_to_schema($args)
    {
        $properties = array();

        foreach ($args as $name => $config) {
            $property = array();

            // Map WordPress types to JSON Schema types
            $wp_type = isset($config['type']) ? $config['type'] : 'string';
            $property['type'] = $this->map_wp_type_to_json_schema($wp_type);

            // Add description
            if (isset($config['description'])) {
                $property['description'] = $config['description'];
            }

            // Handle enum
            if (isset($config['enum'])) {
                $property['enum'] = $config['enum'];
            }

            // Handle default
            if (isset($config['default'])) {
                $property['default'] = $config['default'];
            }

            // Handle array items
            if ($property['type'] === 'array' && isset($config['items'])) {
                $property['items'] = array(
                    'type' => $this->map_wp_type_to_json_schema($config['items']['type'] ?? 'string'),
                );
            }

            // Handle minimum/maximum for integers
            if ($property['type'] === 'integer' || $property['type'] === 'number') {
                if (isset($config['minimum'])) {
                    $property['minimum'] = $config['minimum'];
                }
                if (isset($config['maximum'])) {
                    $property['maximum'] = $config['maximum'];
                }
            }

            // Handle pattern for strings
            if (isset($config['pattern'])) {
                $property['pattern'] = $config['pattern'];
            }

            $properties[$name] = $property;
        }

        return $properties;
    }

    /**
     * Map WordPress type to JSON Schema type
     *
     * @param string|array $wp_type
     * @return string
     */
    private function map_wp_type_to_json_schema($wp_type)
    {
        // Handle array of types (e.g., ['string', 'null'])
        if (is_array($wp_type)) {
            // Return the first non-null type
            foreach ($wp_type as $type) {
                if ($type !== 'null') {
                    return $this->map_wp_type_to_json_schema($type);
                }
            }
            return 'string';
        }

        $type_map = array(
            'string' => 'string',
            'integer' => 'integer',
            'int' => 'integer',
            'number' => 'number',
            'float' => 'number',
            'boolean' => 'boolean',
            'bool' => 'boolean',
            'array' => 'array',
            'object' => 'object',
        );

        return isset($type_map[$wp_type]) ? $type_map[$wp_type] : 'string';
    }

    /**
     * Get required arguments
     *
     * @param array $args
     * @return array
     */
    private function get_required_args($args)
    {
        $required = array();

        foreach ($args as $name => $config) {
            if (isset($config['required']) && $config['required']) {
                $required[] = $name;
            }
        }

        return $required;
    }

    /**
     * Generate a unique tool name from route and method
     *
     * @param string $route
     * @param string $method
     * @return string
     */
    private function generate_tool_name($route, $method)
    {
        // Convert route to snake_case function name
        $name = $route;
        
        // Remove leading slash
        $name = ltrim($name, '/');
        
        // Replace parameter patterns with generic names
        $name = preg_replace('/\(\?P<([^>]+)>[^)]+\)/', '$1', $name);
        
        // Replace slashes and special chars with underscores
        $name = preg_replace('/[^a-zA-Z0-9_]/', '_', $name);
        
        // Remove consecutive underscores
        $name = preg_replace('/_+/', '_', $name);
        
        // Trim underscores
        $name = trim($name, '_');
        
        // Add method prefix
        $method_prefix = strtolower($method);
        
        return $method_prefix . '_' . $name;
    }

    /**
     * Generate a unique route ID
     *
     * @param string $route
     * @param string $method
     * @return string
     */
    public function generate_route_id($route, $method)
    {
        return md5($method . ':' . $route);
    }

    /**
     * Generate tool description
     *
     * @param array $route
     * @return string
     */
    private function generate_tool_description($route)
    {
        $method = $route['method'];
        $path = $route['route'];
        
        // Parse route to create human-readable description
        $resource = $this->extract_resource_name($path);
        
        $descriptions = array(
            'GET' => "Retrieve {$resource} from WordPress",
            'POST' => "Create a new {$resource} in WordPress",
            'PUT' => "Update an existing {$resource} in WordPress",
            'PATCH' => "Partially update a {$resource} in WordPress",
            'DELETE' => "Delete a {$resource} from WordPress",
        );

        $base_description = isset($descriptions[$method]) 
            ? $descriptions[$method] 
            : "Perform {$method} operation on {$resource}";

        return "{$base_description}";
    }

    /**
     * Extract resource name from route path
     *
     * @param string $path
     * @return string
     */
    private function extract_resource_name($path)
    {
        // Remove namespace and version
        $path = preg_replace('#^/[a-zA-Z0-9_-]+/v\d+/#', '', $path);
        
        // Remove parameter patterns
        $path = preg_replace('/\(\?P<[^>]+>[^)]+\)/', '', $path);
        
        // Get first segment as resource name
        $parts = explode('/', trim($path, '/'));
        $resource = isset($parts[0]) ? $parts[0] : 'resource';
        
        // Make it singular and readable
        $resource = str_replace(array('-', '_'), ' ', $resource);
        
        return $resource;
    }
}


```
