PluginProbe
AI Builder – Generate pages, blocks, images & translate with AI / 2.7.8
AI Builder – Generate pages, blocks, images & translate with AI v2.7.8
2.7.10 2.7.9 2.7.8 2.0.8 2.0.9 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.1.7 2.1.8 2.1.9 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.3.0 2.3.10 All 122 releases
ai-builder / includes / class-agent-discovery-service.php

class-agent-discovery-service.php in AI Builder – Generate pages, blocks, images & translate with AI 2.7.8, at includes/class-agent-discovery-service.php

646 lines 19.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Agent Discovery Service
4 *
5 * Discovers all WordPress REST API routes and converts them to JSON Schema
6 * format compatible with Anthropic/OpenAI function calling.
7 *
8 * @package AI_Builder
9 */
10
11 if (!defined('ABSPATH')) {
12 exit;
13 }
14
15 class AIBUI_Agent_Discovery_Service
16 {
17 /**
18 * Routes that should NEVER be exposed (security blacklist)
19 * These cannot be enabled even by admins, regardless of HTTP method.
20 */
21 const FORBIDDEN_ROUTES = array(
22 // '/wp/v2/users', // User management - security risk
23 // '/wp/v2/users/(?P<id>[\d]+)', // Individual user access
24 '/wp/v2/users/me', // Current user info
25 // '/wp/v2/settings', // Site settings - dangerous
26 '/wp/v2/application-passwords', // Application passwords
27 '/jwt-auth', // JWT authentication routes
28 '/oembed', // oEmbed routes
29 );
30
31 /**
32 * Route patterns to exclude (regex patterns), all methods.
33 */
34 const FORBIDDEN_PATTERNS = array(
35 '#^/wp-site-health#', // Site health
36 '#^/wp/v2/users/(?P<user_id>(?:[\d]+|me))/#', // User sub-routes
37 // '#^/wp/v2/settings#', // All settings routes (now controlled per method)
38 '#application-passwords#', // Any route touching application passwords
39 '#/batch$#', // Batch endpoints
40 '#/autosaves#', // Autosave routes
41 '#/revisions#', // Revision routes
42 );
43
44 /**
45 * Route patterns that are forbidden only for specific HTTP methods.
46 * This allows, for example, GET on plugins but blocks POST/DELETE.
47 */
48 const FORBIDDEN_METHOD_PATTERNS = array(
49 'POST' => array(
50 '#^/wp/v2/plugins#',
51 '#^/wp/v2/themes#',
52 // Ban POST on single user route: /wp/v2/users/(?P<id>[\d]+)
53 // Match the literal "(?P<id>...)" part from the route pattern by escaping parentheses.
54 // '#^/wp/v2/users/\(\?P<id>.+$#',
55 ),
56 'PUT' => array(
57 '#^/wp/v2/plugins#',
58 '#^/wp/v2/themes#',
59 ),
60 'PATCH' => array(
61 '#^/wp/v2/plugins#',
62 '#^/wp/v2/themes#',
63 ),
64 'DELETE' => array(
65 '#^/wp/v2/plugins#',
66 '#^/wp/v2/themes#',
67 '#^/wp/v2/users#',
68 '#^/wp/v2/posts#',
69 '#/wp/v2/pages/#',
70 '#/wp/v2/media/#',
71 '#/wp/v2/menu-items/#',
72 '#/wp/v2/blocks/#',
73 '#/wp/v2/templates/#',
74 '#/wp/v2/template-parts/(?P<id>([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/#',
75 '#/wp/v2/navigation/#',
76 '#/wp/v2/menus/#',
77 '#/wp/v2/wp_pattern_category/#',
78 '#/wp/v2/widgets/#'
79
80 ),
81 );
82
83 /**
84 * Routes enabled by default when first discovered (method-agnostic).
85 * For fine-grained control per HTTP method, see DEFAULT_ENABLED_METHOD_ROUTES.
86 */
87 const DEFAULT_ENABLED_ROUTES = array(
88 '/wp/v2/posts',
89 '/wp/v2/posts/(?P<id>[\d]+)',
90 '/wp/v2/pages',
91 '/wp/v2/pages/(?P<id>[\d]+)',
92 '/wp/v2/categories',
93 '/wp/v2/tags',
94 '/wp/v2/media',
95 '/wp/v2/comments',
96 '/wp/v2/search',
97 '/wp/v2/media/(?P<id>[\d]+)',
98 '/wp/v2/menu-items',
99 '/wp/v2/blocks',
100 '/wp/v2/blocks/(?P<id>[\d]+)',
101 '/wp/v2/templates',
102 '/wp/v2/template-parts',
103 '/wp/v2/global-styles/',
104 '/wp/v2/navigation/',
105 '/wp/v2/font-families/',
106 '/wp/v2/statuses',
107 '/wp/v2/categories/',
108 '/wp/v2/navigation',
109 '/wp/v2/font-families',
110 '/wp/v2/menus'
111 );
112
113 /**
114 * Routes enabled by default for specific HTTP methods.
115 * Example: allow GET /wp/v2/settings by default, but not other methods.
116 */
117 const DEFAULT_ENABLED_METHOD_ROUTES = array(
118 'GET' => array(
119 '/wp/v2/settings',
120 '/wp/v2/users',
121 '/wp/v2/block-directory/search',
122 '/wp/v2/pattern-directory/patterns',
123 '/wp/v2/block-patterns/patterns',
124 '/wp/v2/block-patterns/categories',
125 '/wp/v2/menu-locations',
126 '/wp/v2/menu-locations/(?P<location>[\w-]+)',
127 '/wp/v2/font-collections',
128 '/wp/v2/font-collections/(?P<slug>[\/\w-]+)',
129 ),
130 'POST' => array(
131 '/wp/v2/users'
132 ),
133 );
134
135 /**
136 * Namespaces to include in discovery
137 */
138 const ALLOWED_NAMESPACES = array(
139 'wp/v2',
140 'wc/v3',
141 'wc/v2',
142 'wc-analytics',
143 'wp-block-editor/v1',
144 'ai-builder/v1',
145 );
146
147 /**
148 * Get all discoverable routes
149 *
150 * @return array
151 */
152 public function get_all_routes()
153 {
154 $server = rest_get_server();
155 $routes = $server->get_routes();
156 $discovered = array();
157
158 foreach ($routes as $route => $handlers) {
159 // Get route namespace
160 $namespace = $this->extract_namespace($route);
161
162 // Skip if namespace not in allowed list (but allow custom namespaces)
163 if (empty($namespace)) {
164 continue;
165 }
166
167 // Process each handler (method) for this route
168 foreach ($handlers as $handler) {
169 if (!isset($handler['methods']) || !isset($handler['callback'])) {
170 continue;
171 }
172
173 $methods = array_keys($handler['methods']);
174
175 foreach ($methods as $method) {
176 // Skip forbidden route/method combinations
177 if ($this->is_forbidden_route($route, $method)) {
178 continue;
179 }
180
181 $route_info = $this->build_route_info($route, $method, $handler, $namespace);
182 if ($route_info) {
183 $discovered[] = $route_info;
184 }
185 }
186 }
187 }
188
189 return $discovered;
190 }
191
192 /**
193 * Get routes grouped by namespace
194 *
195 * @return array
196 */
197 public function get_routes_by_namespace()
198 {
199 $routes = $this->get_all_routes();
200 $grouped = array();
201
202 foreach ($routes as $route) {
203 $namespace = $route['namespace'];
204 if (!isset($grouped[$namespace])) {
205 $grouped[$namespace] = array();
206 }
207 $grouped[$namespace][] = $route;
208 }
209
210 ksort($grouped);
211 return $grouped;
212 }
213
214 /**
215 * Convert routes to OpenAI/Anthropic tools format
216 *
217 * @param array $routes Array of route info
218 * @return array Tools in JSON Schema format
219 */
220 public function convert_to_tools_format(array $routes)
221 {
222 $tools = array();
223
224 foreach ($routes as $route) {
225 $tool = array(
226 'name' => $this->generate_tool_name($route['route'], $route['method']),
227 'description' => $this->generate_tool_description($route),
228 'input_schema' => array(
229 'type' => 'object',
230 'properties' => $this->convert_args_to_schema($route['args']),
231 'required' => $this->get_required_args($route['args']),
232 ),
233 );
234
235 // Add route metadata for execution
236 $tool['_meta'] = array(
237 'route' => $route['route'],
238 'method' => $route['method'],
239 'namespace' => $route['namespace'],
240 );
241
242 $tools[] = $tool;
243 }
244
245 return $tools;
246 }
247
248 /**
249 * Check if a route is forbidden
250 *
251 * @param string $route
252 * @param string|null $method Optional HTTP method (GET, POST, etc.)
253 * @return bool
254 */
255 public function is_forbidden_route($route, $method = null)
256 {
257 // Check exact matches
258 foreach (self::FORBIDDEN_ROUTES as $forbidden) {
259 if ($route === $forbidden) {
260 return true;
261 }
262 }
263
264 // Check global patterns (all methods)
265 foreach (self::FORBIDDEN_PATTERNS as $pattern) {
266 if (preg_match($pattern, $route)) {
267 return true;
268 }
269 }
270
271 // Check method-specific patterns if method provided
272 if (!empty($method)) {
273 $method = strtoupper($method);
274 if (isset(self::FORBIDDEN_METHOD_PATTERNS[$method])) {
275 foreach (self::FORBIDDEN_METHOD_PATTERNS[$method] as $pattern) {
276 if (preg_match($pattern, $route)) {
277 return true;
278 }
279 }
280 }
281 }
282
283 return false;
284 }
285
286 /**
287 * Check if a route should be enabled by default
288 *
289 * @param string $route
290 * @param string|null $method Optional HTTP method
291 * @return bool
292 */
293 public function is_default_enabled($route, $method = null)
294 {
295 // 1) Method-specific defaults
296 if (!empty($method)) {
297 $method = strtoupper($method);
298 if (isset(self::DEFAULT_ENABLED_METHOD_ROUTES[$method])) {
299 foreach (self::DEFAULT_ENABLED_METHOD_ROUTES[$method] as $default) {
300 if ($route === $default) {
301 return true;
302 }
303 // Also check if route matches the pattern
304 $pattern = '#^' . preg_replace('/\(\?P<[^>]+>[^)]+\)/', '[^/]+', $default) . '$#';
305 if (preg_match($pattern, $route)) {
306 return true;
307 }
308 }
309 }
310 }
311
312 // 2) Legacy method-agnostic defaults
313 foreach (self::DEFAULT_ENABLED_ROUTES as $default) {
314 if ($route === $default) {
315 return true;
316 }
317 // Also check if route matches the pattern
318 $pattern = '#^' . preg_replace('/\(\?P<[^>]+>[^)]+\)/', '[^/]+', $default) . '$#';
319 if (preg_match($pattern, $route)) {
320 return true;
321 }
322 }
323 return false;
324 }
325
326 /**
327 * Extract namespace from route
328 *
329 * @param string $route
330 * @return string
331 */
332 private function extract_namespace($route)
333 {
334 // Remove leading slash
335 $route = ltrim($route, '/');
336
337 // Common patterns: wp/v2/posts, wc/v3/products
338 if (preg_match('#^([a-zA-Z0-9_-]+/v\d+)#', $route, $matches)) {
339 return $matches[1];
340 }
341
342 // Single segment namespace
343 if (preg_match('#^([a-zA-Z0-9_-]+)/#', $route, $matches)) {
344 return $matches[1];
345 }
346
347 return '';
348 }
349
350 /**
351 * Build route info array
352 *
353 * @param string $route
354 * @param string $method
355 * @param array $handler
356 * @param string $namespace
357 * @return array|null
358 */
359 private function build_route_info($route, $method, $handler, $namespace)
360 {
361 $args = isset($handler['args']) ? $handler['args'] : array();
362
363 // Clean up args, remove internal WordPress args
364 $cleaned_args = $this->clean_args($args);
365
366 $upper_method = strtoupper($method);
367
368 $route_info = array(
369 'route' => $route,
370 'method' => $upper_method,
371 'namespace' => $namespace,
372 'args' => $cleaned_args,
373 'permission_callback' => isset($handler['permission_callback']) ? true : false,
374 );
375
376 // Always generate a short, human description for the UI and tools
377 $route_info['description'] = $this->generate_tool_description($route_info);
378
379 // Pass method so we can have method-specific defaults (e.g. GET /wp/v2/settings)
380 $route_info['is_default_enabled'] = $this->is_default_enabled($route, $upper_method);
381 $route_info['unique_id'] = $this->generate_route_id($route, $upper_method);
382
383 return $route_info;
384 }
385
386 /**
387 * Try to extract a human-readable description for a route from its handler.
388 *
389 * @param array $handler Route handler configuration.
390 * @param string $route Route path.
391 * @param string $method HTTP method.
392 * @return string
393 */
394 private function extract_route_description($handler, $route, $method)
395 {
396 // Some routes may expose a direct description field
397 if (isset($handler['description']) && is_string($handler['description']) && $handler['description'] !== '') {
398 return $handler['description'];
399 }
400
401 // Try schema description if schema is already resolved as an array
402 if (isset($handler['schema']) && is_array($handler['schema']) && isset($handler['schema']['description'])) {
403 $desc = $handler['schema']['description'];
404 if (is_string($desc) && $desc !== '') {
405 return $desc;
406 }
407 }
408
409 // Fallback: empty string, UI will show a "Docs" button instead
410 return '';
411 }
412
413 /**
414 * Clean arguments, remove internal WP args
415 *
416 * @param array $args
417 * @return array
418 */
419 private function clean_args($args)
420 {
421 $internal_args = array('context', '_fields', '_embed', '_envelope');
422 $cleaned = array();
423
424 foreach ($args as $key => $config) {
425 if (in_array($key, $internal_args)) {
426 continue;
427 }
428 $cleaned[$key] = $config;
429 }
430
431 return $cleaned;
432 }
433
434 /**
435 * Convert WordPress REST args to JSON Schema properties
436 *
437 * @param array $args
438 * @return array
439 */
440 private function convert_args_to_schema($args)
441 {
442 $properties = array();
443
444 foreach ($args as $name => $config) {
445 $property = array();
446
447 // Map WordPress types to JSON Schema types
448 $wp_type = isset($config['type']) ? $config['type'] : 'string';
449 $property['type'] = $this->map_wp_type_to_json_schema($wp_type);
450
451 // Add description
452 if (isset($config['description'])) {
453 $property['description'] = $config['description'];
454 }
455
456 // Handle enum
457 if (isset($config['enum'])) {
458 $property['enum'] = $config['enum'];
459 }
460
461 // Handle default
462 if (isset($config['default'])) {
463 $property['default'] = $config['default'];
464 }
465
466 // Handle array items
467 if ($property['type'] === 'array' && isset($config['items'])) {
468 $property['items'] = array(
469 'type' => $this->map_wp_type_to_json_schema($config['items']['type'] ?? 'string'),
470 );
471 }
472
473 // Handle minimum/maximum for integers
474 if ($property['type'] === 'integer' || $property['type'] === 'number') {
475 if (isset($config['minimum'])) {
476 $property['minimum'] = $config['minimum'];
477 }
478 if (isset($config['maximum'])) {
479 $property['maximum'] = $config['maximum'];
480 }
481 }
482
483 // Handle pattern for strings
484 if (isset($config['pattern'])) {
485 $property['pattern'] = $config['pattern'];
486 }
487
488 $properties[$name] = $property;
489 }
490
491 return $properties;
492 }
493
494 /**
495 * Map WordPress type to JSON Schema type
496 *
497 * @param string|array $wp_type
498 * @return string
499 */
500 private function map_wp_type_to_json_schema($wp_type)
501 {
502 // Handle array of types (e.g., ['string', 'null'])
503 if (is_array($wp_type)) {
504 // Return the first non-null type
505 foreach ($wp_type as $type) {
506 if ($type !== 'null') {
507 return $this->map_wp_type_to_json_schema($type);
508 }
509 }
510 return 'string';
511 }
512
513 $type_map = array(
514 'string' => 'string',
515 'integer' => 'integer',
516 'int' => 'integer',
517 'number' => 'number',
518 'float' => 'number',
519 'boolean' => 'boolean',
520 'bool' => 'boolean',
521 'array' => 'array',
522 'object' => 'object',
523 );
524
525 return isset($type_map[$wp_type]) ? $type_map[$wp_type] : 'string';
526 }
527
528 /**
529 * Get required arguments
530 *
531 * @param array $args
532 * @return array
533 */
534 private function get_required_args($args)
535 {
536 $required = array();
537
538 foreach ($args as $name => $config) {
539 if (isset($config['required']) && $config['required']) {
540 $required[] = $name;
541 }
542 }
543
544 return $required;
545 }
546
547 /**
548 * Generate a unique tool name from route and method
549 *
550 * @param string $route
551 * @param string $method
552 * @return string
553 */
554 private function generate_tool_name($route, $method)
555 {
556 // Convert route to snake_case function name
557 $name = $route;
558
559 // Remove leading slash
560 $name = ltrim($name, '/');
561
562 // Replace parameter patterns with generic names
563 $name = preg_replace('/\(\?P<([^>]+)>[^)]+\)/', '$1', $name);
564
565 // Replace slashes and special chars with underscores
566 $name = preg_replace('/[^a-zA-Z0-9_]/', '_', $name);
567
568 // Remove consecutive underscores
569 $name = preg_replace('/_+/', '_', $name);
570
571 // Trim underscores
572 $name = trim($name, '_');
573
574 // Add method prefix
575 $method_prefix = strtolower($method);
576
577 return $method_prefix . '_' . $name;
578 }
579
580 /**
581 * Generate a unique route ID
582 *
583 * @param string $route
584 * @param string $method
585 * @return string
586 */
587 public function generate_route_id($route, $method)
588 {
589 return md5($method . ':' . $route);
590 }
591
592 /**
593 * Generate tool description
594 *
595 * @param array $route
596 * @return string
597 */
598 private function generate_tool_description($route)
599 {
600 $method = $route['method'];
601 $path = $route['route'];
602
603 // Parse route to create human-readable description
604 $resource = $this->extract_resource_name($path);
605
606 $descriptions = array(
607 'GET' => "Retrieve {$resource} from WordPress",
608 'POST' => "Create a new {$resource} in WordPress",
609 'PUT' => "Update an existing {$resource} in WordPress",
610 'PATCH' => "Partially update a {$resource} in WordPress",
611 'DELETE' => "Delete a {$resource} from WordPress",
612 );
613
614 $base_description = isset($descriptions[$method])
615 ? $descriptions[$method]
616 : "Perform {$method} operation on {$resource}";
617
618 return "{$base_description}";
619 }
620
621 /**
622 * Extract resource name from route path
623 *
624 * @param string $path
625 * @return string
626 */
627 private function extract_resource_name($path)
628 {
629 // Remove namespace and version
630 $path = preg_replace('#^/[a-zA-Z0-9_-]+/v\d+/#', '', $path);
631
632 // Remove parameter patterns
633 $path = preg_replace('/\(\?P<[^>]+>[^)]+\)/', '', $path);
634
635 // Get first segment as resource name
636 $parts = explode('/', trim($path, '/'));
637 $resource = isset($parts[0]) ? $parts[0] : 'resource';
638
639 // Make it singular and readable
640 $resource = str_replace(array('-', '_'), ' ', $resource);
641
642 return $resource;
643 }
644 }
645
646