ability name => label */ public static function names(): array { return [ self::SEARCH => __('Search the site', 'aipcf-ai-provider-for-cloudflare'), self::ANSWER => __('Answer from the site', 'aipcf-ai-provider-for-cloudflare'), self::RELATED => __('Related posts', 'aipcf-ai-provider-for-cloudflare'), ]; } public static function registerCategory(): void { if (!self::enabled() || !function_exists('wp_register_ability_category')) { return; } wp_register_ability_category(self::CATEGORY, [ 'label' => 'AIPCF', 'description' => __('Search and answers from this site\'s published content.', 'aipcf-ai-provider-for-cloudflare'), ]); } public static function register(): void { if (!self::enabled() || !self::available()) { return; } $meta = [ 'annotations' => ['readonly' => true, 'destructive' => false, 'idempotent' => true], 'public' => true, 'show_in_rest' => true, 'mcp' => ['public' => true, 'type' => 'tool'], ]; $question = ['type' => 'string', 'minLength' => 2, 'maxLength' => self::MAX_QUESTION, 'description' => 'The question or search, in plain words.']; $source = [ 'type' => 'object', 'properties' => [ 'id' => ['type' => 'integer'], 'title' => ['type' => 'string'], 'url' => ['type' => 'string'], ], ]; wp_register_ability(self::SEARCH, [ 'label' => self::names()[self::SEARCH], 'description' => 'Finds the published posts and pages of this site that best match a question, by meaning and by the words used. Returns each match with its title, link, the matching text and how close it is. Use it to look things up on this site before answering.', 'category' => self::CATEGORY, 'input_schema' => [ 'type' => 'object', 'properties' => [ 'question' => $question, 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 10, 'default' => 5, 'description' => 'How many matches to return.'], ], 'required' => ['question'], 'additionalProperties' => false, ], 'output_schema' => [ 'type' => 'object', 'properties' => [ 'results' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'id' => ['type' => 'integer'], 'title' => ['type' => 'string'], 'url' => ['type' => 'string'], 'text' => ['type' => 'string'], 'match' => ['type' => 'string'], ], ], ], ], ], 'execute_callback' => [self::class, 'search'], 'permission_callback' => [self::class, 'permission'], 'meta' => $meta, ]); wp_register_ability(self::ANSWER, [ 'label' => self::names()[self::ANSWER], 'description' => 'Answers a question in a few sentences from this site\'s published content only, citing the posts it used as [#id] (listed in sources). When the site does not cover the question, found is false and the answer says so. Follows the site owner\'s topic rules.', 'category' => self::CATEGORY, 'input_schema' => [ 'type' => 'object', 'properties' => ['question' => $question], 'required' => ['question'], 'additionalProperties' => false, ], 'output_schema' => [ 'type' => 'object', 'properties' => [ 'answer' => ['type' => 'string'], 'found' => ['type' => 'boolean'], 'sources' => ['type' => 'array', 'items' => $source], ], ], 'execute_callback' => [self::class, 'answer'], 'permission_callback' => [self::class, 'permission'], 'meta' => $meta, ]); wp_register_ability(self::RELATED, [ 'label' => self::names()[self::RELATED], 'description' => 'Lists the published posts closest in meaning to a post of this site, best first. Give the post\'s ID or its link.', 'category' => self::CATEGORY, 'input_schema' => [ 'type' => 'object', 'properties' => [ 'post' => ['type' => ['integer', 'string'], 'description' => 'The post ID, or the post\'s link on this site.'], 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 10, 'default' => 5], ], 'required' => ['post'], 'additionalProperties' => false, ], 'output_schema' => [ 'type' => 'object', 'properties' => [ 'post' => $source, 'related' => ['type' => 'array', 'items' => $source], ], ], 'execute_callback' => [self::class, 'related'], 'permission_callback' => [self::class, 'permission'], 'meta' => $meta, ]); } /** * Who may call: logged-in users with the chosen capability, while the feature is on. * * @param mixed $input */ public static function permission($input = null): bool { return self::enabled() && is_user_logged_in() && current_user_can((string) Settings::get('agents_capability')); } /** * @param mixed $input * @return array{results: list}|\WP_Error */ public static function search($input) { $before = Recorder::taskNeurons(self::TASK); $q = self::question($input); $out = self::checked($q, true); if (!is_wp_error($out)) { $limit = max(1, min(10, (int) (is_array($input) ? ($input['limit'] ?? 5) : 5))); $found = Retriever::find($q, $limit * 3, self::TASK, Retriever::MODE_RERANK); if (is_wp_error($found)) { $out = $found; } else { $results = []; $seen = []; $min = (float) Settings::get('search_min_score'); foreach ($found as $m) { $id = (int) $m['id']; $match = $m['rerank'] !== null ? $m['rerank'] >= Retriever::RERANK_MIN : ($m['vector'] >= $min || $m['words'] > 0); if (isset($seen[$id]) || !$match || !self::readable($id)) { continue; } $text = self::text($id, (string) $m['text']); if ($text === '') { continue; } $seen[$id] = true; $results[] = [ 'id' => $id, 'title' => html_entity_decode(get_the_title($id), ENT_QUOTES, 'UTF-8'), 'url' => (string) get_permalink($id), 'text' => $text, 'match' => Search::strength(['score' => (float) ($m['rerank'] ?? $m['vector']), 'reranked' => $m['rerank'] !== null, 'words' => $m['words'] > 0]), ]; if (count($results) >= $limit) { break; } } $out = ['results' => $results]; } } self::log(self::SEARCH, $out, $before, static fn(array $o): string => sprintf( /* translators: %d: number of results */ _n('%d result', '%d results', count($o['results']), 'aipcf-ai-provider-for-cloudflare'), count($o['results']) )); return $out; } /** * @param mixed $input * @return array{answer: string, found: bool, sources: list}|\WP_Error */ public static function answer($input) { $before = Recorder::taskNeurons(self::TASK); $q = self::question($input); $out = self::checked($q, true); if (!is_wp_error($out)) { $r = Chat::answerAs($q, self::TASK); $out = is_wp_error($r) ? $r : ['answer' => $r['reply'], 'found' => $r['found'] && !$r['blocked'], 'sources' => $r['sources']]; } self::log(self::ANSWER, $out, $before, static fn(array $o): string => $o['found'] /* translators: %d: number of posts cited */ ? sprintf(_n('Answered from %d post', 'Answered from %d posts', count($o['sources']), 'aipcf-ai-provider-for-cloudflare'), count($o['sources'])) : __('Not found on the site', 'aipcf-ai-provider-for-cloudflare')); return $out; } /** * Free: the lists Related posts worked out already. * * @param mixed $input * @return array{post: array{id: int, title: string, url: string}, related: list}|\WP_Error */ public static function related($input) { $before = Recorder::taskNeurons(self::TASK); $raw = is_array($input) ? ($input['post'] ?? 0) : 0; $id = is_numeric($raw) ? (int) $raw : url_to_postid((string) $raw); $out = self::checked('', false); if (!is_wp_error($out)) { $post = $id > 0 ? get_post($id) : null; if (!$post instanceof \WP_Post || !Related::showable($post) || !self::readable($id)) { $out = new \WP_Error('aipcf_agents_post', __('No published post with that ID or link.', 'aipcf-ai-provider-for-cloudflare'), ['status' => 404]); } else { $limit = max(1, min(10, (int) ($input['limit'] ?? 5))); $related = []; foreach (Related::posts($id, $limit) as $p) { if (self::readable($p->ID)) { $related[] = self::source($p->ID); } } $out = ['post' => self::source($id), 'related' => $related]; } } self::log(self::RELATED, $out, $before, static fn(array $o): string => sprintf( /* translators: %d: number of related posts */ _n('%d related post', '%d related posts', count($o['related']), 'aipcf-ai-provider-for-cloudflare'), count($o['related']) )); return $out; } /** * @param mixed $input */ private static function question($input): string { $q = is_array($input) ? (string) ($input['question'] ?? '') : ''; $q = trim((string) preg_replace('/\s+/u', ' ', wp_strip_all_tags($q))); return mb_substr($q, 0, self::MAX_QUESTION); } /** * The rate limit, and for calls that spend neurons, the daily limits. * * @return true|\WP_Error */ private static function checked(string $q, bool $spends) { if ($spends && mb_strlen($q) < 2) { return new \WP_Error('aipcf_agents_question', __('Ask a question.', 'aipcf-ai-provider-for-cloudflare'), ['status' => 400]); } $key = 'aipcf_agents_rate_' . get_current_user_id(); $count = (int) get_transient($key); if ($count >= self::PER_MINUTE) { return new \WP_Error('aipcf_agents_rate', __('Too many calls in a minute. Wait a moment and try again.', 'aipcf-ai-provider-for-cloudflare'), ['status' => 429]); } set_transient($key, $count + 1, MINUTE_IN_SECONDS); if ($spends) { $allowed = Budget::allows(self::TASK); if (is_wp_error($allowed)) { $allowed->add_data(['status' => 429]); return $allowed; } } return true; } private static function readable(int $id): bool { $p = get_post($id); return $p instanceof \WP_Post && $p->post_status === 'publish' && $p->post_password === '' && current_user_can('read_post', $id); } /** * The matching text as the Site assistant would see it: without the title header, and through * the same filter, so text a site keeps for some members stays out. */ private static function text(int $id, string $text): string { $text = trim((string) preg_replace('/^Title:.*?\n(Section:.*?\n)?\n/s', '', $text)); /** This filter is documented in src/Features/Chat.php */ $text = trim((string) apply_filters('aipcf_chat_passage', $text, $id, get_current_user_id())); return mb_substr($text, 0, self::MAX_TEXT) . (mb_strlen($text) > self::MAX_TEXT ? '...' : ''); } /** * @return array{id: int, title: string, url: string} */ private static function source(int $id): array { return ['id' => $id, 'title' => html_entity_decode(get_the_title($id), ENT_QUOTES, 'UTF-8'), 'url' => (string) get_permalink($id)]; } /** * Every call in Activity: who, which ability, what came of it and the neurons. The question * itself is not kept. * * @param array|\WP_Error $out * @param callable(array): string $summary */ private static function log(string $ability, $out, float $before, callable $summary): void { $failed = is_wp_error($out); ActivityLog::add([ 'tool' => 'agents', 'action' => 'agent_' . str_replace(['aipcf/', '-'], ['', '_'], $ability), 'status' => $failed ? ($out->get_error_code() === 'aipcf_agents_post' || $out->get_error_code() === 'aipcf_agents_question' ? 'info' : 'warning') : 'ok', 'message' => $failed ? $out->get_error_message() : $summary($out), 'neurons' => Recorder::taskNeurons(self::TASK) - $before, ]); } }