=1 enabled tool; an empty list is off (the safe default that * preserves "nothing happens until you add a tool"). The OPT_ENABLED option * is still written by persist() for back-compat, but presence is the source * of truth — there is no longer a separate global on/off toggle to drift. */ public static function is_enabled() { return self::count_enabled(self::enabled_map()) > 0; } /** * Saved per-tool settings map: [ callback_fn => entry ]. * * An entry is EITHER the legacy bare-bool shape (`true`/`false`) OR the * richer object shape `[ 'enabled' => bool, 'usage_hint' => string ]` * introduced by plan 27d3e6. Both shapes are accepted on read (see * resolve_tool_setting()) so existing installs are never migrated * destructively — a row only takes the object shape the next time the * owner saves the AI Tools section. */ public static function enabled_map() { $map = get_option(self::OPT_TOOLS, array()); return is_array($map) ? $map : array(); } /** Max stored length of a per-tool "when to use" hint. */ const HINT_MAX = 500; /** * Normalize a stored OPT_TOOLS entry into [ enabled(bool), usage_hint(string) ]. * Accepts BOTH the legacy bare-bool shape AND the new * { enabled, usage_hint } object shape (back-compat, HARD — plan 27d3e6). * * A tool may ship a catalog `default_hint` (plan caee10). It applies ONLY * when the stored entry carries no `usage_hint` KEY AT ALL — an absent key * means the owner has never been shown a hint for this tool, so seeding one * is new guidance rather than an override. A key that is PRESENT BUT EMPTY * is an owner who deliberately cleared the box, and it stays empty. That * distinction is the whole correctness of the default and it mirrors the * `array_key_exists` idiom this function already uses for `enabled`. * * @param mixed $saved The stored entry, or null when the tool is unset. * @param bool $is_cautious Whether the tool defaults off (sensitive). * @param string $default_hint Catalog-shipped hint, used only on an ABSENT key. * @return array{0:bool,1:string} */ public static function resolve_tool_setting($saved, $is_cautious, $default_hint = '') { $default_hint = self::clip_hint($default_hint); if (is_array($saved)) { // Object shape. An absent 'enabled' key means the owner never turned // this tool on → OFF. AI Tools default to ZERO active on a fresh // install (plan d450a7: presence = active; nothing fires until the // owner adds a tool). $is_cautious no longer affects the default — // EVERY tool is off until explicitly enabled. (Regression fix: 3.2.10 // shipped with this defaulting to !$is_cautious, which showed ~10 // tools "active" on a fresh install and was a footgun for turning FC // on by an accidental save.) $enabled = array_key_exists('enabled', $saved) ? (bool) $saved['enabled'] : false; // NOTE: array_key_exists, NOT isset — isset() is false for a stored // null and would silently re-seed the default over a cleared hint. $hint = array_key_exists('usage_hint', $saved) ? (string) $saved['usage_hint'] : $default_hint; return array($enabled, $hint); } if ($saved === null) { // Unset → OFF (no tool active until added), but the catalog default // hint applies: nothing has been stored for this tool yet. return array(false, $default_hint); } // Legacy bare-bool: a plain true/false enables/disables. Written before // hints existed (pre-27d3e6), so it carries no usage_hint key either — // it gets the catalog default too, or a site that enabled the tool back // then would stay silently hint-less forever. return array((bool) $saved, $default_hint); } /** Trim + length-bound a usage hint before it is stored or sent to the model. */ private static function clip_hint($s) { $s = trim((string) $s); return function_exists('mb_substr') ? mb_substr($s, 0, self::HINT_MAX) : substr($s, 0, self::HINT_MAX); } /** Bounded multi-step depth (mirrors AI Engine's mwai_function_call_max_depth). */ public static function max_depth() { $depth = (int) apply_filters('mxchat_function_call_max_depth', 5); return max(1, min(10, $depth)); } /** Hard ceiling on tool executions in a single user turn (loop-safety). */ public static function max_tool_calls_per_turn() { $n = (int) apply_filters('mxchat_function_call_max_per_turn', 8); return max(1, min(20, $n)); } /* ------------------------------------------------------------------ * * Curation policy * ------------------------------------------------------------------ */ /** * Callbacks that default to OFF (opt-in) even when function calling is on: * anything that spends money, mutates a cart, exposes customer PII, hands the * conversation to a human, or starts a data-collection flow. An admin can * still enable them deliberately on the AI Tools checklist. */ public static function cautious_callbacks() { return apply_filters('mxchat_function_calling_cautious_callbacks', array( // money / cart mutation 'mxchat_handle_add_to_cart_intent', 'mxchat_add_to_cart', 'mxchat_handle_checkout_intent', 'mxchat_checkout_redirect', // customer PII 'mxchat_handle_order_history', // disruptive handoff / data-collection flows 'mxchat_live_agent_handover', 'mxchat_telegram_live_agent_handover', 'mxchat_webhook_live_agent_handover', 'mxchat_handle_email_capture', 'mxchat_handle_form_collection', )); } /** Callbacks that are never offered as model tools (not meaningful as a tool). */ public static function excluded_callbacks() { return apply_filters('mxchat_function_calling_excluded_callbacks', array( 'mxchat_handle_switch_to_chatbot_intent', // chatbot/agent mode toggle )); } /** * Curated core callbacks eligible as model tools, with MODEL-FACING * descriptions (written for the LLM to reason over, distinct from the * admin-facing labels in the Actions registry). * * Each value: [ 'label' => admin label, 'description' => model-facing ]. */ public static function core_tool_catalog() { return apply_filters('mxchat_function_calling_core_tools', array( 'mxchat_handle_search_request' => array( 'label' => __('Web Search', 'mxchat'), 'description' => __('Search the public web (via Brave Search) for current, real-time information to answer the user. Use when the answer may be recent or is not in the site content.', 'mxchat'), // Admin-facing setup requirement (plan 183856): this tool runs on the // Brave Search API and silently no-ops without a key. requires_key // drives the "Brave key not set" notice; setup_note is shown in the // tool's detail panel so the admin knows where to add the key. 'requires_key' => 'brave_api_key', 'setup_note' => __('Powered by Brave Search — requires a Brave Search API key. Add it under Settings → Brave Search.', 'mxchat'), ), 'mxchat_handle_image_search_request' => array( 'label' => __('Image Search', 'mxchat'), 'description' => __('Search the web (via Brave Search) for images relevant to the user request and show them in the chat.', 'mxchat'), // Output is a rendered image gallery (html), not text — the FC loop // must SURFACE it to the frontend, not strip it (plan 48a57a). This // callback self-saves its own bot messages (text + html). 'emits_ui' => true, 'ui_self_saves' => true, // Brave-powered like Web Search (plan 183856). 'requires_key' => 'brave_api_key', 'setup_note' => __('Powered by Brave Search — requires a Brave Search API key. Add it under Settings → Brave Search.', 'mxchat'), ), 'mxchat_handle_pdf_discussion' => array( 'label' => __('Ask the Uploaded PDF', 'mxchat'), 'description' => __('Answer a question using the PDF document the visitor uploaded earlier in this conversation.', 'mxchat'), ), 'mxchat_generate_image' => array( 'label' => __('Generate Image (OpenAI)', 'mxchat'), 'description' => __('Create an image from a text description using OpenAI image generation. The query should describe the image to make.', 'mxchat'), // Output is a rendered (html) — surface it, don't strip it // (plan 48a57a). Self-saves its own bot messages (text + html). 'emits_ui' => true, 'ui_self_saves' => true, ), 'mxchat_generate_gemini_image' => array( 'label' => __('Generate Image (Gemini)', 'mxchat'), 'description' => __('Create an image from a text description using Google Imagen. The query should describe the image to make.', 'mxchat'), // Output is a rendered (html) — surface it, don't strip it // (plan 48a57a). Self-saves its own bot messages (text + html). 'emits_ui' => true, 'ui_self_saves' => true, ), // Cautious (default-off) core callbacks — selectable but opt-in: 'mxchat_handle_email_capture' => array( 'label' => __('Collect Email', 'mxchat'), 'description' => __('Begin collecting the visitor\'s email address for the mailing list.', 'mxchat'), ), // The two handoff tools ship a default usage hint (plan caee10). // "Escalate when you don't know" is the most-requested handoff // behavior, and the base description says only WHAT the tool does, // never WHEN — so before this, the behavior worked only for an owner // who independently guessed they had to type the condition into the // hint box. The second clause is deliberate: a bare "when you don't // know" invites the model to escalate on small talk. 'mxchat_live_agent_handover' => array( 'label' => __('Hand Off to Live Agent (Slack)', 'mxchat'), 'description' => __('Transfer the conversation to a human support agent on Slack.', 'mxchat'), 'default_hint' => __('Use this when the visitor has asked something you cannot answer from the knowledge base, or has asked twice about the same unresolved problem.', 'mxchat'), ), 'mxchat_telegram_live_agent_handover' => array( 'label' => __('Hand Off to Live Agent (Telegram)', 'mxchat'), 'description' => __('Transfer the conversation to a human support agent on Telegram.', 'mxchat'), 'default_hint' => __('Use this when the visitor has asked something you cannot answer from the knowledge base, or has asked twice about the same unresolved problem.', 'mxchat'), ), // Webhook destination (plan d88e22): outbound-only — the handoff is // POSTed to the owner's configured URL and the conversation stays in // AI mode; the receiving system follows up out-of-band. 'mxchat_webhook_live_agent_handover' => array( 'label' => __('Hand Off to Live Agent (Webhook)', 'mxchat'), 'description' => __('Notify the site\'s human support team about this conversation via the configured webhook. The team follows up separately; you keep assisting meanwhile.', 'mxchat'), 'default_hint' => __('Use this when the visitor has asked something you cannot answer from the knowledge base, or has asked twice about the same unresolved problem.', 'mxchat'), ), )); } /* ------------------------------------------------------------------ * * Tool list construction (the single source for both surfaces) * ------------------------------------------------------------------ */ /** * Build the full provider-agnostic tool list, each annotated with its * current enabled state. Used by the admin checklist AND the chat loop. * * @param array|null $enabled_map Override the saved enable map (for previews). * @return array[] each: [ * 'name' => API tool name (sanitized callback fn), * 'callback' => the callback function name to invoke, * 'label' => admin label, * 'description' => model-facing description, * 'group' => grouping label, * 'is_addon' => bool (true → invoke via apply_filters), * 'addon' => add-on slug or '', * 'cautious' => bool (defaults off), * 'enabled' => bool (effective enabled state), * 'parameters' => JSON-Schema object for the tool arguments, * ] */ public static function available_tools($enabled_map = null) { if ($enabled_map === null) { $enabled_map = self::enabled_map(); } $excluded = self::excluded_callbacks(); $cautious = self::cautious_callbacks(); $entries = array(); // 1) Core tools. foreach (self::core_tool_catalog() as $fn => $meta) { if (in_array($fn, $excluded, true)) { continue; } $core_desc = (isset($meta['fc_description']) && $meta['fc_description'] !== '') ? $meta['fc_description'] : (isset($meta['description']) ? $meta['description'] : ''); $entries[$fn] = array( 'callback' => $fn, 'label' => isset($meta['label']) ? $meta['label'] : $fn, 'description' => $core_desc, 'group' => __('Core', 'mxchat'), 'is_addon' => false, 'addon' => '', 'fc_parameters' => (isset($meta['fc_parameters']) && is_array($meta['fc_parameters'])) ? $meta['fc_parameters'] : null, // plan 48a57a — UI-bearing tool metadata. emits_ui marks a tool // whose output is a rendered element (image/card/gallery) that the // FC loop must surface to the frontend; ui_self_saves notes the // callback persists its own bot messages (core image/search do). 'emits_ui' => !empty($meta['emits_ui']), 'ui_self_saves' => array_key_exists('ui_self_saves', $meta) ? !empty($meta['ui_self_saves']) : true, // Admin-facing setup metadata (plan 183856) — core tools only. 'requires_key' => isset($meta['requires_key']) ? $meta['requires_key'] : '', 'setup_note' => isset($meta['setup_note']) ? $meta['setup_note'] : '', // Catalog-shipped "when to use" default (plan caee10). Applied // only when the stored entry has no usage_hint key — see // resolve_tool_setting(). Core tools only in this build. 'default_hint' => isset($meta['default_hint']) ? $meta['default_hint'] : '', ); } // 2) Active add-on callbacks (canonical: the live filter). $addon_callbacks = apply_filters('mxchat_available_callbacks', array()); if (is_array($addon_callbacks)) { foreach ($addon_callbacks as $fn => $data) { if (in_array($fn, $excluded, true)) { continue; } if (!is_array($data)) { $data = array(); } $label = isset($data['label']) ? $data['label'] : self::humanize($fn); // Prefer an explicit model-facing description (fc_description) so the // admin-facing `description` shown in the legacy Actions registry can // stay short while the model gets richer when-to-call guidance. $desc = (isset($data['fc_description']) && $data['fc_description'] !== '') ? $data['fc_description'] : (isset($data['description']) && $data['description'] !== '' ? $data['description'] : sprintf(__('Use the %s capability.', 'mxchat'), $label)); $entries[$fn] = array( 'callback' => $fn, 'label' => $label, 'description' => $desc, 'group' => isset($data['group']) ? $data['group'] : __('Add-ons', 'mxchat'), 'is_addon' => true, 'addon' => isset($data['addon']) ? $data['addon'] : '', 'fc_parameters' => (isset($data['fc_parameters']) && is_array($data['fc_parameters'])) ? $data['fc_parameters'] : null, // plan 48a57a — an add-on declares emits_ui when its callback // returns a rendered element (e.g. woo product cards). Add-on // callbacks default to NOT self-saving (the integrator persists // their html), so ui_self_saves defaults false. 'emits_ui' => !empty($data['emits_ui']), 'ui_self_saves' => !empty($data['ui_self_saves']), ); } } // 2b) Per-INSTANCE tools injected directly by an add-on (plan 60bccc). // Kept SEPARATE from `mxchat_available_callbacks` on purpose: an add-on // that exposes one tool per stored record (e.g. mxchat-forms surfaces // each opted-in form as its own "Collect:
" tool) must not push N // entries into the intent/Actions admin registry, which also reads the // callbacks filter. Each entry mirrors the add-on entry shape; is_addon // is forced true so the chat loop invokes it via apply_filters($fn,...), // exactly like a normal add-on callback. Additive: a no-op until an // add-on hooks the filter, so existing installs see no change. $extra_tools = apply_filters('mxchat_function_calling_extra_tools', array()); if (is_array($extra_tools)) { foreach ($extra_tools as $fn => $data) { if (in_array($fn, $excluded, true) || isset($entries[$fn]) || !is_array($data)) { continue; } $label = isset($data['label']) ? $data['label'] : self::humanize($fn); $desc = (isset($data['fc_description']) && $data['fc_description'] !== '') ? $data['fc_description'] : (isset($data['description']) && $data['description'] !== '' ? $data['description'] : sprintf(__('Use the %s capability.', 'mxchat'), $label)); $entries[$fn] = array( 'callback' => $fn, 'label' => $label, 'description' => $desc, 'group' => isset($data['group']) ? $data['group'] : __('Add-ons', 'mxchat'), 'is_addon' => true, 'addon' => isset($data['addon']) ? $data['addon'] : '', 'fc_parameters' => (isset($data['fc_parameters']) && is_array($data['fc_parameters'])) ? $data['fc_parameters'] : null, 'emits_ui' => !empty($data['emits_ui']), 'ui_self_saves' => !empty($data['ui_self_saves']), ); } } // 3) Finalize: name, parameters, cautious flag, effective enabled state, // plus the owner's optional per-tool "when to use" hint (plan 27d3e6). $tools = array(); foreach ($entries as $fn => $e) { $is_cautious = in_array($fn, $cautious, true); $saved = array_key_exists($fn, $enabled_map) ? $enabled_map[$fn] : null; list($enabled, $usage_hint) = self::resolve_tool_setting( $saved, $is_cautious, isset($e['default_hint']) ? $e['default_hint'] : '' ); $tools[] = array( 'name' => self::tool_name($fn), 'callback' => $fn, 'label' => $e['label'], 'description' => $e['description'], 'usage_hint' => $usage_hint, 'group' => $e['group'], 'is_addon' => $e['is_addon'], 'addon' => $e['addon'], 'cautious' => $is_cautious, 'enabled' => $enabled, 'parameters' => self::parameters_for($fn, $e), // plan 48a57a — carried through so the chat loop can detect // UI-bearing tool output and decide whether to persist its html. 'emits_ui' => !empty($e['emits_ui']), 'ui_self_saves' => !empty($e['ui_self_saves']), // plan 183856 — admin-facing setup hint surfaced on the AI Tools // page; requires_key lets the page flag a missing dependency key. 'requires_key' => isset($e['requires_key']) ? $e['requires_key'] : '', 'setup_note' => isset($e['setup_note']) ? $e['setup_note'] : '', ); } return $tools; } /** Just the enabled tools — what the model actually sees. */ public static function enabled_tools($enabled_map = null) { $tools = array(); foreach (self::available_tools($enabled_map) as $t) { if (!empty($t['enabled'])) { $tools[] = $t; } } return self::apply_live_agent_schedule($tools); } /** * Withhold each live-agent handover tool outside ITS channel's availability * schedule (plans 8ccaa2 + 99d7a4; webhook added by d88e22). Slack, * Telegram, and the webhook destination carry independent schedules: if * Slack is off-hours but Telegram is staffed, the Telegram handoff stays * offered and only the Slack one is withheld — and likewise for each other * pair. Off-hours the bot must not DANGLE a human it cannot reach, so the * tool never reaches the model in the first place. * * Applied HERE and deliberately NOT in available_tools(): that function * feeds the admin AI Tools checklist, which must keep showing the owner's * real configuration. A tool that vanished from the admin list every evening * would read as a bug, not a schedule. * * tool_by_name() resolves through this same list, so an off-hours model that * emits the tool name anyway (e.g. echoing conversation history) cannot get * it executed either. * * All call sites tolerate an empty result — mxchat_fc_should_run() and * mxchat_fc_attempt() each fall back to the normal path — so this is safe * even on a site whose ONLY enabled tool is a handover. */ private static function apply_live_agent_schedule($tools) { if (!class_exists('MxChat_Live_Agent_Schedule')) { return $tools; } $withhold = array(); if (!MxChat_Live_Agent_Schedule::is_within_hours('slack')) { $withhold[] = 'mxchat_live_agent_handover'; } if (!MxChat_Live_Agent_Schedule::is_within_hours('telegram')) { $withhold[] = 'mxchat_telegram_live_agent_handover'; } if (!MxChat_Live_Agent_Schedule::is_within_hours('webhook')) { $withhold[] = 'mxchat_webhook_live_agent_handover'; } if (empty($withhold)) { return $tools; } $out = array(); foreach ($tools as $t) { if (in_array($t['callback'], $withhold, true)) { continue; } $out[] = $t; } return $out; } /** Resolve a model-emitted tool name back to its tool entry. */ public static function tool_by_name($name, $enabled_only = true) { $list = $enabled_only ? self::enabled_tools() : self::available_tools(); foreach ($list as $t) { if ($t['name'] === $name || $t['callback'] === $name) { return $t; } } return null; } /* ------------------------------------------------------------------ * * Schema helpers * ------------------------------------------------------------------ */ /** Sanitize a callback fn into a valid API tool name (^[A-Za-z0-9_-]{1,64}$). */ public static function tool_name($fn) { $name = preg_replace('/[^A-Za-z0-9_-]/', '_', (string) $fn); return substr($name, 0, 64); } /** Turn a function name into a readable label as a fallback. */ private static function humanize($fn) { $s = preg_replace('/^mxchat_(handle_)?/', '', (string) $fn); $s = str_replace('_', ' ', $s); return ucwords(trim($s)); } /** * JSON-Schema for a tool's arguments. MxChat callbacks are message-driven, so * the default is a single natural-language `query` string (handed to the * callback as the user message). A registry entry may declare richer params * via an additive `fc_parameters` key (used by the woo tools plan c2c9b5). */ public static function parameters_for($fn, $entry = array()) { if (is_array($entry) && isset($entry['fc_parameters']) && is_array($entry['fc_parameters'])) { return $entry['fc_parameters']; } return array( 'type' => 'object', 'properties' => array( 'query' => array( 'type' => 'string', 'description' => __('The natural-language request, question, or search query to act on.', 'mxchat'), ), ), 'required' => array('query'), ); } /* ------------------------------------------------------------------ * * Provider-native tool-schema transforms (pure) * ------------------------------------------------------------------ */ /** OpenAI / xAI / DeepSeek / OpenRouter / Custom (OpenAI-compatible). */ public static function to_openai_tools($tools) { $out = array(); foreach ($tools as $t) { $out[] = array( 'type' => 'function', 'function' => array( 'name' => $t['name'], 'description' => self::clip(self::model_description($t)), 'parameters' => $t['parameters'], ), ); } return $out; } /** Anthropic Claude. */ public static function to_anthropic_tools($tools) { $out = array(); foreach ($tools as $t) { $out[] = array( 'name' => $t['name'], 'description' => self::clip(self::model_description($t)), 'input_schema' => $t['parameters'], ); } return $out; } /** Google Gemini (functionDeclarations under a single tools entry). */ public static function to_gemini_tools($tools) { $decls = array(); foreach ($tools as $t) { $decls[] = array( 'name' => $t['name'], 'description' => self::clip(self::model_description($t)), 'parameters' => $t['parameters'], ); } return $decls ? array(array('functionDeclarations' => $decls)) : array(); } private static function clip($s, $max = 1024) { $s = (string) $s; return strlen($s) > $max ? substr($s, 0, $max) : $s; } /** * The description actually sent to the model (plan 27d3e6): the fixed * dev-authored base description PLUS the site owner's optional * "when to use" hint. The hint STEERS the model's decision to call the * tool; the base description is additive-only and never replaced. When the * hint is empty the description is unchanged, so existing installs send the * exact same schema as before. */ private static function model_description($t) { $base = isset($t['description']) ? (string) $t['description'] : ''; $hint = isset($t['usage_hint']) ? trim((string) $t['usage_hint']) : ''; if ($hint === '') { return $base; } return ($base !== '' ? $base . "\n\n" : '') . 'When to use: ' . $hint; } /* ------------------------------------------------------------------ * * AJAX autosave for the AI Tools section * ------------------------------------------------------------------ */ /** * Persist the master toggle + the per-tool settings map. $all is every tool * shown; $checked is the subset that is on; $hints is [ fn => string ] of * the owner's "when to use" notes. We store an explicit object entry for * EVERY shown tool so unchecking one persists as enabled:false (not * "absent → default") and so the hint survives an enable/disable toggle. * Returns the stored map. */ private static function persist($enabled, $all, $checked, $hints = array()) { update_option(self::OPT_ENABLED, $enabled ? 1 : 0); $checked_lookup = array_fill_keys($checked, true); $map = array(); foreach ($all as $fn) { $map[$fn] = array( 'enabled' => isset($checked_lookup[$fn]), 'usage_hint' => isset($hints[$fn]) ? self::clip_hint($hints[$fn]) : '', ); } update_option(self::OPT_TOOLS, $map); return $map; } /** Count tools that are effectively enabled across both stored shapes. */ private static function count_enabled($map) { $n = 0; foreach ($map as $v) { if (is_array($v) ? !empty($v['enabled']) : (bool) $v) { $n++; } } return $n; } /** AJAX: autosave on every toggle/checkbox change in the AI Tools section. */ public static function handle_ajax_save() { if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => 'forbidden'), 403); } check_ajax_referer('mxchat_fc_autosave', 'nonce'); // Presence = active (plan d450a7): the global on/off toggle is gone, so // the gate is derived server-side from whether any tool is checked — not // a client-sent flag. Empty list = off (safe default). $all = isset($_POST['all_tools']) && is_array($_POST['all_tools']) ? array_map('sanitize_text_field', wp_unslash($_POST['all_tools'])) : array(); $checked = isset($_POST['tools']) && is_array($_POST['tools']) ? array_map('sanitize_text_field', wp_unslash($_POST['tools'])) : array(); // Per-tool "when to use" hints: [ callback_fn => free text ]. Keyed by // callback, so sanitize the key as a text field and the value as a // textarea (preserves newlines, strips tags). $hints = array(); if (isset($_POST['hints']) && is_array($_POST['hints'])) { foreach (wp_unslash($_POST['hints']) as $fn => $hint) { $hints[sanitize_text_field($fn)] = sanitize_textarea_field($hint); } } $enabled = !empty($checked); $map = self::persist($enabled, $all, $checked, $hints); wp_send_json_success(array('enabled' => $enabled, 'enabled_count' => self::count_enabled($map))); } }