__( 'Search products', 'agent-adapt' ), 'description' => __( 'Search the store\'s catalogue by keywords, category, price and stock. Returns what a signed-out shopper would see.', 'agent-adapt' ), 'method' => 'GET', 'path' => '/catalog/search', 'params' => array( 'q' => array( 'type' => 'string', 'maxLength' => 200, 'description' => __( 'Keywords.', 'agent-adapt' ) ), 'category' => array( 'type' => 'string', 'maxLength' => 200, 'description' => __( 'Category slug or ID.', 'agent-adapt' ) ), 'min_price' => array( 'type' => 'string', 'maxLength' => 20, 'description' => __( 'Minimum price, as a decimal in the store currency.', 'agent-adapt' ) ), 'max_price' => array( 'type' => 'string', 'maxLength' => 20, 'description' => __( 'Maximum price, as a decimal in the store currency.', 'agent-adapt' ) ), 'in_stock' => array( 'type' => 'boolean', 'description' => __( 'Only products that can be bought now.', 'agent-adapt' ) ), 'on_sale' => array( 'type' => 'boolean', 'description' => __( 'Only products on sale.', 'agent-adapt' ) ), 'orderby' => array( 'type' => 'string', 'enum' => array( 'relevance', 'price', 'date', 'popularity', 'rating' ), 'default' => 'relevance' ), 'order' => array( 'type' => 'string', 'enum' => array( 'asc', 'desc' ), 'default' => 'desc' ), 'page' => array( 'type' => 'integer', 'minimum' => 1, 'default' => 1 ), 'per_page' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 50, 'default' => 10 ), ), 'response' => array( 'type' => 'object', 'properties' => array( 'products' => array( 'type' => 'array', 'items' => self::product_schema( false ) ), 'page' => array( 'type' => 'integer' ), 'per_page' => array( 'type' => 'integer' ), 'total' => array( 'type' => 'integer' ), 'total_pages' => array( 'type' => 'integer' ), ), ), 'handler' => array( __CLASS__, 'search' ), ) ); AgentAdapt_Capabilities::register( 'catalog.product', array( 'summary' => __( 'Get a product', 'agent-adapt' ), 'description' => __( 'One product in full: description, price, stock, attributes and variations, plus the store\'s own up-sells and cross-sells.', 'agent-adapt' ), 'method' => 'GET', 'path' => '/catalog/products/{id}', 'params' => array( 'id' => array( 'type' => 'integer', 'minimum' => 1, 'required' => true, 'in' => 'path' ), ), 'response' => self::product_schema( true ), 'handler' => array( __CLASS__, 'product' ), ) ); } /** * catalog.search * * @param AgentAdapt_Context $context Who is asking. * @param array $p Validated params. * @return array|WP_Error */ public static function search( AgentAdapt_Context $context, array $p ) { $page = (int) ( $p['page'] ?? 1 ); $per_page = (int) ( $p['per_page'] ?? 10 ); $q = trim( (string) ( $p['q'] ?? '' ) ); // ALWAYS name a visibility. The Store API has no default here, and // without one it returns catalogue-hidden products in search results // (found by the protocol suite, 11 Oct 2026, on WooCommerce 11.2.1). $query = array( 'page' => $page, 'per_page' => $per_page, 'catalog_visibility' => '' !== $q ? 'search' : 'catalog', ); if ( '' !== $q ) { $ids = self::keyword_ids( $q ); if ( ! empty( $p['on_sale'] ) ) { // Intersect here: the Store API merges on_sale into `include` with // PHP's array `+` (a union by key), which would ADD sale products // that matched none of the keywords. $ids = array_values( array_intersect( $ids, array_map( 'intval', wc_get_product_ids_on_sale() ) ) ); unset( $p['on_sale'] ); } if ( ! $ids ) { // Never send an empty include: the Store API reads it as "no filter". AgentAdapt_Events::record( 'catalog.searched', $context, array( 'value' => 0, 'meta' => array( 'terms' => self::normalise_terms( $q ) ) ) ); return self::empty_page( $page, $per_page ); } $query['include'] = $ids; $query['orderby'] = 'include'; } if ( ! empty( $p['category'] ) ) { $cat = self::category_id( (string) $p['category'] ); if ( null === $cat ) { return self::empty_page( $page, $per_page ); } $query['category'] = (string) $cat; } foreach ( array( 'min_price', 'max_price' ) as $k ) { if ( isset( $p[ $k ] ) && '' !== $p[ $k ] ) { if ( ! is_numeric( $p[ $k ] ) || (float) $p[ $k ] < 0 ) { return new WP_Error( 'invalid_request', "$k must be a non-negative decimal", array( 'status' => 400 ) ); } // The Store API takes prices in the currency's smallest unit. $query[ $k ] = (string) (int) round( (float) $p[ $k ] * ( 10 ** wc_get_price_decimals() ) ); } } if ( ! empty( $p['in_stock'] ) ) { $query['stock_status'] = array( 'instock', 'onbackorder' ); } if ( ! empty( $p['on_sale'] ) ) { $query['on_sale'] = true; } $orderby = (string) ( $p['orderby'] ?? 'relevance' ); if ( 'relevance' !== $orderby || '' === $q ) { $query['orderby'] = 'relevance' === $orderby ? 'date' : $orderby; $query['order'] = (string) ( $p['order'] ?? 'desc' ); } $res = self::store_api( '/wc/store/v1/products', $query ); if ( is_wp_error( $res ) ) { return $res; } $products = array_map( static fn( $item ) => self::shape( (array) $item, false ), (array) $res['data'] ); $out = array( 'products' => $products, 'page' => $page, 'per_page' => $per_page, 'total' => (int) ( $res['headers']['X-WP-Total'] ?? count( $products ) ), 'total_pages' => (int) ( $res['headers']['X-WP-TotalPages'] ?? 1 ), ); AgentAdapt_Events::record( 'catalog.searched', $context, array( 'value' => $out['total'], 'meta' => array( 'terms' => self::normalise_terms( (string) ( $p['q'] ?? '' ) ) ) ) ); return $out; } /** * catalog.product * * @param AgentAdapt_Context $context Who is asking. * @param array $p Validated params. * @return array|WP_Error */ public static function product( AgentAdapt_Context $context, array $p ) { $id = (int) $p['id']; if ( ! self::resolvable( $id ) ) { return new WP_Error( 'not_found', 'no such product', array( 'status' => 404 ) ); } $res = self::store_api( '/wc/store/v1/products/' . $id, array() ); if ( is_wp_error( $res ) ) { // Hidden, private, draft or nonexistent: one answer for all. return new WP_Error( 'not_found', 'no such product', array( 'status' => 404 ) ); } $out = self::shape( (array) $res['data'], true ); AgentAdapt_Events::record( 'product.viewed', $context, array( 'object_id' => $id ) ); return $out; } /** * Candidate product IDs for a keyword query, best match first. * * The Store API's own search is ONE `post_title LIKE '%whole phrase%'`, so * "stormline jacket" misses "Stormline Insulated Jacket". Agents send * natural queries, so here EVERY term must match the title or the SKU, in * any order. Only published products are candidates; visibility, stock, * price and category are then applied by the Store API to this set, so * the storefront's rules still decide what is shown. * * @param string $q Query. * @return int[] */ private static function keyword_ids( string $q ): array { global $wpdb; $terms = array_slice( array_values( array_unique( array_filter( preg_split( '/\s+/u', mb_strtolower( $q ) ), static fn( $t ) => mb_strlen( $t ) >= 2 ) ) ), 0, 8 ); if ( ! $terms ) { return array(); } $where = array(); $args = array(); foreach ( $terms as $term ) { $like = '%' . $wpdb->esc_like( $term ) . '%'; $where[] = '( p.post_title LIKE %s OR l.sku LIKE %s )'; $args[] = $like; $args[] = $like; } // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where holds only fixed fragments with placeholders; every value, and both table names (%i), are in the args. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT p.ID, p.post_title FROM %i p LEFT JOIN %i l ON l.product_id = p.ID WHERE p.post_type = 'product' AND p.post_status = 'publish' AND " . implode( ' AND ', $where ) . ' ORDER BY p.ID DESC LIMIT 500', array_merge( array( $wpdb->posts, $wpdb->prefix . 'wc_product_meta_lookup' ), $args ) ), ARRAY_A ); // phpcs:enable $phrase = mb_strtolower( trim( (string) preg_replace( '/\s+/u', ' ', $q ) ) ); usort( $rows, static function ( $a, $b ) use ( $phrase, $terms ) { $score = static function ( $r ) use ( $phrase, $terms ) { $title = mb_strtolower( (string) $r['post_title'] ); return ( str_contains( $title, $phrase ) ? 2 : 0 ) + ( str_starts_with( $title, $terms[0] ) ? 1 : 0 ); }; return array( $score( $b ), (int) $b['ID'] ) <=> array( $score( $a ), (int) $a['ID'] ); } ); return array_map( static fn( $r ) => (int) $r['ID'], $rows ); } /** * Whether a product may be fetched by ID through the agent API. * * Stricter than the storefront ON PURPOSE. WooCommerce's "hidden" catalogue * visibility removes a product from the shop and search, but its page still * answers anyone holding the link, and the Store API serves it by ID. * Merchants use "hidden" for wholesale lines, private stock and gift items; * with sequential IDs, "fetch by ID" would let an agent enumerate exactly * what the merchant chose to keep out of sight (FACTORY.md §9.5: the * existence of the thing can be the secret). An agent whose user has the * link can still open the page through the web channel. * * @param int $id Product ID. */ public static function resolvable( int $id ): bool { $product = wc_get_product( $id ); if ( ! $product || 'publish' !== $product->get_status() || post_password_required( $id ) ) { return false; } if ( $product->get_parent_id() ) { $parent = wc_get_product( $product->get_parent_id() ); if ( ! $parent || 'publish' !== $parent->get_status() ) { return false; } $product = $parent; // A variation is as visible as its parent. } /** * Whether catalogue-hidden products may be fetched by ID by agents. * * @param bool $expose Default false. * @param WC_Product $product The product. */ return 'hidden' !== $product->get_catalog_visibility() || (bool) apply_filters( 'agentadapt_expose_hidden_products', false, $product ); } /** * Internal Store API GET. * * @param string $route Store API route. * @param array $query Query params. * @return array{data: mixed, headers: array}|WP_Error */ private static function store_api( string $route, array $query ) { $req = new WP_REST_Request( 'GET', $route ); $req->set_query_params( $query ); $res = rest_do_request( $req ); if ( $res->is_error() || $res->get_status() >= 400 ) { return new WP_Error( 'store_api', 'catalogue unavailable', array( 'status' => $res->get_status() >= 500 ? 502 : 404 ) ); } return array( 'data' => $res->get_data(), 'headers' => $res->get_headers() ); } /** * Store API product → agent product. * * @param array $item Store API product. * @param bool $full Include description, attributes, variations, related. * @return array */ public static function shape( array $item, bool $full ): array { $item = json_decode( (string) wp_json_encode( $item ), true ); $prices = (array) ( $item['prices'] ?? array() ); $unit = (int) ( $prices['currency_minor_unit'] ?? wc_get_price_decimals() ); $cur = (string) ( $prices['currency_code'] ?? get_woocommerce_currency() ); $money = static fn( $v ) => AgentAdapt_Money::from_minor( $v, $unit, $cur ); $on_sale = ! empty( $item['on_sale'] ); $out = array( 'id' => (int) $item['id'], 'name' => self::text( (string) ( $item['name'] ?? '' ) ), 'type' => (string) ( $item['type'] ?? '' ), 'url' => (string) ( $item['permalink'] ?? '' ), 'sku' => (string) ( $item['sku'] ?? '' ), 'summary' => self::text( (string) ( $item['short_description'] ?? '' ), 500 ), 'price' => $money( $prices['price'] ?? null ), 'regular_price' => $money( $prices['regular_price'] ?? null ), 'sale_price' => $on_sale ? $money( $prices['sale_price'] ?? null ) : null, 'price_range' => ! empty( $prices['price_range'] ) ? array( 'min' => $money( $prices['price_range']['min_amount'] ?? null ), 'max' => $money( $prices['price_range']['max_amount'] ?? null ), ) : null, 'on_sale' => $on_sale, 'in_stock' => ! empty( $item['is_in_stock'] ), 'purchasable' => ! empty( $item['is_purchasable'] ), 'has_options' => ! empty( $item['has_options'] ), // Only what the store already shows shoppers (Store API respects the // store's stock display setting); never invented scarcity (§12.4). 'low_stock_remaining' => isset( $item['low_stock_remaining'] ) ? (int) $item['low_stock_remaining'] : null, 'image' => (string) ( $item['images'][0]['src'] ?? '' ), 'categories' => array_map( static fn( $c ) => array( 'id' => (int) $c['id'], 'name' => self::text( (string) $c['name'] ), 'slug' => (string) $c['slug'] ), (array) ( $item['categories'] ?? array() ) ), ); if ( $full ) { $out['description'] = self::text( (string) ( $item['description'] ?? '' ), 5000 ); $out['attributes'] = array_map( static fn( $a ) => array( 'name' => self::text( (string) $a['name'] ), 'has_variations' => ! empty( $a['has_variations'] ), 'values' => array_map( static fn( $t ) => self::text( (string) $t['name'] ), (array) ( $a['terms'] ?? array() ) ), ), (array) ( $item['attributes'] ?? array() ) ); $out['variations'] = array_map( static fn( $v ) => array( 'id' => (int) $v['id'], 'attributes' => array_map( static fn( $a ) => array( 'name' => self::text( (string) $a['name'] ), 'value' => (string) $a['value'] ), (array) ( $v['attributes'] ?? array() ) ), ), (array) ( $item['variations'] ?? array() ) ); $out['related'] = self::related( (int) $item['id'] ); } return $out; } /** * The store's own up-sells and cross-sells, visible products only. * * @param int $id Product ID. * @return array{upsells: int[], cross_sells: int[]} */ private static function related( int $id ): array { $product = wc_get_product( $id ); $visible = static function ( array $ids ): array { return array_values( array_filter( array_map( 'intval', $ids ), static function ( $pid ) { $p = wc_get_product( $pid ); return $p && 'publish' === $p->get_status() && $p->is_visible(); } ) ); }; return array( 'upsells' => $product ? $visible( $product->get_upsell_ids() ) : array(), 'cross_sells' => $product ? $visible( $product->get_cross_sell_ids() ) : array(), ); } /** * Category ID from an ID or slug, or null when unknown. * * @param string $cat ID or slug. */ private static function category_id( string $cat ): ?int { if ( ctype_digit( $cat ) ) { return term_exists( (int) $cat, 'product_cat' ) ? (int) $cat : null; } $term = get_term_by( 'slug', sanitize_title( $cat ), 'product_cat' ); return $term ? (int) $term->term_id : null; } /** * Empty result page. * * @param int $page Page. * @param int $per_page Per page. * @return array */ private static function empty_page( int $page, int $per_page ): array { return array( 'products' => array(), 'page' => $page, 'per_page' => $per_page, 'total' => 0, 'total_pages' => 0 ); } /** * HTML → plain text, entities decoded, whitespace collapsed, optionally cut. * * @param string $html Markup. * @param int $max Maximum characters (0 = no limit). */ public static function text( string $html, int $max = 0 ): string { $t = trim( (string) preg_replace( '/\s+/u', ' ', html_entity_decode( wp_strip_all_tags( $html ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ) ); return ( $max > 0 && mb_strlen( $t ) > $max ) ? rtrim( mb_substr( $t, 0, $max - 1 ) ) . '…' : $t; } /** * Search terms for the event stream: lower-case, at most 100 characters. * * @param string $q Query. */ private static function normalise_terms( string $q ): string { return mb_substr( trim( (string) preg_replace( '/\s+/u', ' ', mb_strtolower( $q ) ) ), 0, 100 ); } /** * JSON Schema of an agent product. * * @param bool $full Detail shape. * @return array */ private static function product_schema( bool $full ): array { $m = AgentAdapt_Money::schema(); $props = array( 'id' => array( 'type' => 'integer' ), 'name' => array( 'type' => 'string' ), 'type' => array( 'type' => 'string' ), 'url' => array( 'type' => 'string', 'format' => 'uri' ), 'sku' => array( 'type' => 'string' ), 'summary' => array( 'type' => 'string' ), 'price' => $m, 'regular_price' => $m, 'sale_price' => $m, 'price_range' => array( 'type' => array( 'object', 'null' ), 'properties' => array( 'min' => $m, 'max' => $m ) ), 'on_sale' => array( 'type' => 'boolean' ), 'in_stock' => array( 'type' => 'boolean' ), 'purchasable' => array( 'type' => 'boolean' ), 'has_options' => array( 'type' => 'boolean' ), 'low_stock_remaining' => array( 'type' => array( 'integer', 'null' ) ), 'image' => array( 'type' => 'string' ), 'categories' => array( 'type' => 'array', 'items' => array( 'type' => 'object' ) ), ); if ( $full ) { $props += array( 'description' => array( 'type' => 'string' ), 'attributes' => array( 'type' => 'array', 'items' => array( 'type' => 'object' ) ), 'variations' => array( 'type' => 'array', 'items' => array( 'type' => 'object' ) ), 'related' => array( 'type' => 'object', 'description' => 'The store\'s own up-sells and cross-sells (product IDs), as shown on the product page.', 'properties' => array( 'upsells' => array( 'type' => 'array', 'items' => array( 'type' => 'integer' ) ), 'cross_sells' => array( 'type' => 'array', 'items' => array( 'type' => 'integer' ) ) ), ), ); } return array( 'type' => 'object', 'properties' => $props ); } }