=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: