| 1 |
<?php |
| 2 |
/** |
| 3 |
* MxChat Tool Registry — adapter from the callback registry to model-callable tools. |
| 4 |
* |
| 5 |
* Plan: plan-mxchat-20260617-a41dee (native function-calling loop). |
| 6 |
* |
| 7 |
* This is the SINGLE SOURCE that turns MxChat's registered callbacks into |
| 8 |
* provider-agnostic tool schemas. BOTH surfaces read from it so they can never |
| 9 |
* drift apart (the "synced in several places, broken in one" trap from |
| 10 |
* CLAUDE.md's model-registry checklist): |
| 11 |
* - the admin "AI Tools" per-tool checklist (class-mxchat-admin.php Actions page) |
| 12 |
* - the chat-time function-calling loop (class-mxchat-integrator.php) |
| 13 |
* |
| 14 |
* Tool sources (both used by available_tools()): |
| 15 |
* - CORE: a curated, model-facing catalog of the core mxchat-basic callbacks |
| 16 |
* that make sensible model tools (core_tool_catalog()). Curated rather than |
| 17 |
* reusing every intent callback because some core callbacks (e.g. the |
| 18 |
* chatbot-mode toggle) are not meaningful as model tools. |
| 19 |
* - ADD-ONS: whatever active+licensed add-ons register via the existing |
| 20 |
* `mxchat_available_callbacks` filter (woo / perplexity / forms). Reading the |
| 21 |
* live filter means the tool list tracks the active add-ons with zero |
| 22 |
* duplication. |
| 23 |
* |
| 24 |
* The registry is intentionally self-contained (no dependency on MxChat_Admin, |
| 25 |
* which is admin-only) so the front-end integrator can build the same tool list |
| 26 |
* the admin checklist shows. |
| 27 |
* |
| 28 |
* @package MxChat |
| 29 |
* @since 3.2.10 |
| 30 |
*/ |
| 31 |
|
| 32 |
if (!defined('ABSPATH')) { |
| 33 |
exit; |
| 34 |
} |
| 35 |
|
| 36 |
class MxChat_Tool_Registry { |
| 37 |
|
| 38 |
/** Standalone options (bypass the mxchat_options sanitize traps). */ |
| 39 |
const OPT_ENABLED = 'mxchat_function_calling_enabled'; |
| 40 |
const OPT_TOOLS = 'mxchat_function_calling_tools'; |
| 41 |
|
| 42 |
/** |
| 43 |
* Register the AJAX autosave handler for the AI Tools section. |
| 44 |
* Called once from the plugin bootstrap. The section saves on every toggle/ |
| 45 |
* checkbox change (matching MxChat's autosave UX) — no Save button. |
| 46 |
*/ |
| 47 |
public static function init() { |
| 48 |
add_action('wp_ajax_mxchat_fc_autosave', array(__CLASS__, 'handle_ajax_save')); |
| 49 |
} |
| 50 |
|
| 51 |
/* ------------------------------------------------------------------ * |
| 52 |
* Settings accessors |
| 53 |
* ------------------------------------------------------------------ */ |
| 54 |
|
| 55 |
/** |
| 56 |
* Master gate — a tool in the list = active (plan d450a7). Derived purely |
| 57 |
* from tool-presence so the function-calling loop runs exactly when the |
| 58 |
* owner has >=1 enabled tool; an empty list is off (the safe default that |
| 59 |
* preserves "nothing happens until you add a tool"). The OPT_ENABLED option |
| 60 |
* is still written by persist() for back-compat, but presence is the source |
| 61 |
* of truth — there is no longer a separate global on/off toggle to drift. |
| 62 |
*/ |
| 63 |
public static function is_enabled() { |
| 64 |
return self::count_enabled(self::enabled_map()) > 0; |
| 65 |
} |
| 66 |
|
| 67 |
/** |
| 68 |
* Saved per-tool settings map: [ callback_fn => entry ]. |
| 69 |
* |
| 70 |
* An entry is EITHER the legacy bare-bool shape (`true`/`false`) OR the |
| 71 |
* richer object shape `[ 'enabled' => bool, 'usage_hint' => string ]` |
| 72 |
* introduced by plan 27d3e6. Both shapes are accepted on read (see |
| 73 |
* resolve_tool_setting()) so existing installs are never migrated |
| 74 |
* destructively — a row only takes the object shape the next time the |
| 75 |
* owner saves the AI Tools section. |
| 76 |
*/ |
| 77 |
public static function enabled_map() { |
| 78 |
$map = get_option(self::OPT_TOOLS, array()); |
| 79 |
return is_array($map) ? $map : array(); |
| 80 |
} |
| 81 |
|
| 82 |
/** Max stored length of a per-tool "when to use" hint. */ |
| 83 |
const HINT_MAX = 500; |
| 84 |
|
| 85 |
/** |
| 86 |
* Normalize a stored OPT_TOOLS entry into [ enabled(bool), usage_hint(string) ]. |
| 87 |
* Accepts BOTH the legacy bare-bool shape AND the new |
| 88 |
* { enabled, usage_hint } object shape (back-compat, HARD — plan 27d3e6). |
| 89 |
* |
| 90 |
* A tool may ship a catalog `default_hint` (plan caee10). It applies ONLY |
| 91 |
* when the stored entry carries no `usage_hint` KEY AT ALL — an absent key |
| 92 |
* means the owner has never been shown a hint for this tool, so seeding one |
| 93 |
* is new guidance rather than an override. A key that is PRESENT BUT EMPTY |
| 94 |
* is an owner who deliberately cleared the box, and it stays empty. That |
| 95 |
* distinction is the whole correctness of the default and it mirrors the |
| 96 |
* `array_key_exists` idiom this function already uses for `enabled`. |
| 97 |
* |
| 98 |
* @param mixed $saved The stored entry, or null when the tool is unset. |
| 99 |
* @param bool $is_cautious Whether the tool defaults off (sensitive). |
| 100 |
* @param string $default_hint Catalog-shipped hint, used only on an ABSENT key. |
| 101 |
* @return array{0:bool,1:string} |
| 102 |
*/ |
| 103 |
public static function resolve_tool_setting($saved, $is_cautious, $default_hint = '') { |
| 104 |
$default_hint = self::clip_hint($default_hint); |
| 105 |
if (is_array($saved)) { |
| 106 |
// Object shape. An absent 'enabled' key means the owner never turned |
| 107 |
// this tool on → OFF. AI Tools default to ZERO active on a fresh |
| 108 |
// install (plan d450a7: presence = active; nothing fires until the |
| 109 |
// owner adds a tool). $is_cautious no longer affects the default — |
| 110 |
// EVERY tool is off until explicitly enabled. (Regression fix: 3.2.10 |
| 111 |
// shipped with this defaulting to !$is_cautious, which showed ~10 |
| 112 |
// tools "active" on a fresh install and was a footgun for turning FC |
| 113 |
// on by an accidental save.) |
| 114 |
$enabled = array_key_exists('enabled', $saved) |
| 115 |
? (bool) $saved['enabled'] |
| 116 |
: false; |
| 117 |
// NOTE: array_key_exists, NOT isset — isset() is false for a stored |
| 118 |
// null and would silently re-seed the default over a cleared hint. |
| 119 |
$hint = array_key_exists('usage_hint', $saved) |
| 120 |
? (string) $saved['usage_hint'] |
| 121 |
: $default_hint; |
| 122 |
return array($enabled, $hint); |
| 123 |
} |
| 124 |
if ($saved === null) { |
| 125 |
// Unset → OFF (no tool active until added), but the catalog default |
| 126 |
// hint applies: nothing has been stored for this tool yet. |
| 127 |
return array(false, $default_hint); |
| 128 |
} |
| 129 |
// Legacy bare-bool: a plain true/false enables/disables. Written before |
| 130 |
// hints existed (pre-27d3e6), so it carries no usage_hint key either — |
| 131 |
// it gets the catalog default too, or a site that enabled the tool back |
| 132 |
// then would stay silently hint-less forever. |
| 133 |
return array((bool) $saved, $default_hint); |
| 134 |
} |
| 135 |
|
| 136 |
/** Trim + length-bound a usage hint before it is stored or sent to the model. */ |
| 137 |
private static function clip_hint($s) { |
| 138 |
$s = trim((string) $s); |
| 139 |
return function_exists('mb_substr') |
| 140 |
? mb_substr($s, 0, self::HINT_MAX) |
| 141 |
: substr($s, 0, self::HINT_MAX); |
| 142 |
} |
| 143 |
|
| 144 |
/** Bounded multi-step depth (mirrors AI Engine's mwai_function_call_max_depth). */ |
| 145 |
public static function max_depth() { |
| 146 |
$depth = (int) apply_filters('mxchat_function_call_max_depth', 5); |
| 147 |
return max(1, min(10, $depth)); |
| 148 |
} |
| 149 |
|
| 150 |
/** Hard ceiling on tool executions in a single user turn (loop-safety). */ |
| 151 |
public static function max_tool_calls_per_turn() { |
| 152 |
$n = (int) apply_filters('mxchat_function_call_max_per_turn', 8); |
| 153 |
return max(1, min(20, $n)); |
| 154 |
} |
| 155 |
|
| 156 |
/* ------------------------------------------------------------------ * |
| 157 |
* Curation policy |
| 158 |
* ------------------------------------------------------------------ */ |
| 159 |
|
| 160 |
/** |
| 161 |
* Callbacks that default to OFF (opt-in) even when function calling is on: |
| 162 |
* anything that spends money, mutates a cart, exposes customer PII, hands the |
| 163 |
* conversation to a human, or starts a data-collection flow. An admin can |
| 164 |
* still enable them deliberately on the AI Tools checklist. |
| 165 |
*/ |
| 166 |
public static function cautious_callbacks() { |
| 167 |
return apply_filters('mxchat_function_calling_cautious_callbacks', array( |
| 168 |
// money / cart mutation |
| 169 |
'mxchat_handle_add_to_cart_intent', 'mxchat_add_to_cart', |
| 170 |
'mxchat_handle_checkout_intent', 'mxchat_checkout_redirect', |
| 171 |
// customer PII |
| 172 |
'mxchat_handle_order_history', |
| 173 |
// disruptive handoff / data-collection flows |
| 174 |
'mxchat_live_agent_handover', 'mxchat_telegram_live_agent_handover', |
| 175 |
'mxchat_webhook_live_agent_handover', |
| 176 |
'mxchat_handle_email_capture', 'mxchat_handle_form_collection', |
| 177 |
)); |
| 178 |
} |
| 179 |
|
| 180 |
/** Callbacks that are never offered as model tools (not meaningful as a tool). */ |
| 181 |
public static function excluded_callbacks() { |
| 182 |
return apply_filters('mxchat_function_calling_excluded_callbacks', array( |
| 183 |
'mxchat_handle_switch_to_chatbot_intent', // chatbot/agent mode toggle |
| 184 |
)); |
| 185 |
} |
| 186 |
|
| 187 |
/** |
| 188 |
* Curated core callbacks eligible as model tools, with MODEL-FACING |
| 189 |
* descriptions (written for the LLM to reason over, distinct from the |
| 190 |
* admin-facing labels in the Actions registry). |
| 191 |
* |
| 192 |
* Each value: [ 'label' => admin label, 'description' => model-facing ]. |
| 193 |
*/ |
| 194 |
public static function core_tool_catalog() { |
| 195 |
return apply_filters('mxchat_function_calling_core_tools', array( |
| 196 |
'mxchat_handle_search_request' => array( |
| 197 |
'label' => __('Web Search', 'mxchat'), |
| 198 |
'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'), |
| 199 |
// Admin-facing setup requirement (plan 183856): this tool runs on the |
| 200 |
// Brave Search API and silently no-ops without a key. requires_key |
| 201 |
// drives the "Brave key not set" notice; setup_note is shown in the |
| 202 |
// tool's detail panel so the admin knows where to add the key. |
| 203 |
'requires_key' => 'brave_api_key', |
| 204 |
'setup_note' => __('Powered by Brave Search — requires a Brave Search API key. Add it under Settings → Brave Search.', 'mxchat'), |
| 205 |
), |
| 206 |
'mxchat_handle_image_search_request' => array( |
| 207 |
'label' => __('Image Search', 'mxchat'), |
| 208 |
'description' => __('Search the web (via Brave Search) for images relevant to the user request and show them in the chat.', 'mxchat'), |
| 209 |
// Output is a rendered image gallery (html), not text — the FC loop |
| 210 |
// must SURFACE it to the frontend, not strip it (plan 48a57a). This |
| 211 |
// callback self-saves its own bot messages (text + html). |
| 212 |
'emits_ui' => true, |
| 213 |
'ui_self_saves' => true, |
| 214 |
// Brave-powered like Web Search (plan 183856). |
| 215 |
'requires_key' => 'brave_api_key', |
| 216 |
'setup_note' => __('Powered by Brave Search — requires a Brave Search API key. Add it under Settings → Brave Search.', 'mxchat'), |
| 217 |
), |
| 218 |
'mxchat_handle_pdf_discussion' => array( |
| 219 |
'label' => __('Ask the Uploaded PDF', 'mxchat'), |
| 220 |
'description' => __('Answer a question using the PDF document the visitor uploaded earlier in this conversation.', 'mxchat'), |
| 221 |
), |
| 222 |
'mxchat_generate_image' => array( |
| 223 |
'label' => __('Generate Image (OpenAI)', 'mxchat'), |
| 224 |
'description' => __('Create an image from a text description using OpenAI image generation. The query should describe the image to make.', 'mxchat'), |
| 225 |
// Output is a rendered <img> (html) — surface it, don't strip it |
| 226 |
// (plan 48a57a). Self-saves its own bot messages (text + html). |
| 227 |
'emits_ui' => true, |
| 228 |
'ui_self_saves' => true, |
| 229 |
), |
| 230 |
'mxchat_generate_gemini_image' => array( |
| 231 |
'label' => __('Generate Image (Gemini)', 'mxchat'), |
| 232 |
'description' => __('Create an image from a text description using Google Imagen. The query should describe the image to make.', 'mxchat'), |
| 233 |
// Output is a rendered <img> (html) — surface it, don't strip it |
| 234 |
// (plan 48a57a). Self-saves its own bot messages (text + html). |
| 235 |
'emits_ui' => true, |
| 236 |
'ui_self_saves' => true, |
| 237 |
), |
| 238 |
// Cautious (default-off) core callbacks — selectable but opt-in: |
| 239 |
'mxchat_handle_email_capture' => array( |
| 240 |
'label' => __('Collect Email', 'mxchat'), |
| 241 |
'description' => __('Begin collecting the visitor\'s email address for the mailing list.', 'mxchat'), |
| 242 |
), |
| 243 |
// The two handoff tools ship a default usage hint (plan caee10). |
| 244 |
// "Escalate when you don't know" is the most-requested handoff |
| 245 |
// behavior, and the base description says only WHAT the tool does, |
| 246 |
// never WHEN — so before this, the behavior worked only for an owner |
| 247 |
// who independently guessed they had to type the condition into the |
| 248 |
// hint box. The second clause is deliberate: a bare "when you don't |
| 249 |
// know" invites the model to escalate on small talk. |
| 250 |
'mxchat_live_agent_handover' => array( |
| 251 |
'label' => __('Hand Off to Live Agent (Slack)', 'mxchat'), |
| 252 |
'description' => __('Transfer the conversation to a human support agent on Slack.', 'mxchat'), |
| 253 |
'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'), |
| 254 |
), |
| 255 |
'mxchat_telegram_live_agent_handover' => array( |
| 256 |
'label' => __('Hand Off to Live Agent (Telegram)', 'mxchat'), |
| 257 |
'description' => __('Transfer the conversation to a human support agent on Telegram.', 'mxchat'), |
| 258 |
'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'), |
| 259 |
), |
| 260 |
// Webhook destination (plan d88e22): outbound-only — the handoff is |
| 261 |
// POSTed to the owner's configured URL and the conversation stays in |
| 262 |
// AI mode; the receiving system follows up out-of-band. |
| 263 |
'mxchat_webhook_live_agent_handover' => array( |
| 264 |
'label' => __('Hand Off to Live Agent (Webhook)', 'mxchat'), |
| 265 |
'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'), |
| 266 |
'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'), |
| 267 |
), |
| 268 |
)); |
| 269 |
} |
| 270 |
|
| 271 |
/* ------------------------------------------------------------------ * |
| 272 |
* Tool list construction (the single source for both surfaces) |
| 273 |
* ------------------------------------------------------------------ */ |
| 274 |
|
| 275 |
/** |
| 276 |
* Build the full provider-agnostic tool list, each annotated with its |
| 277 |
* current enabled state. Used by the admin checklist AND the chat loop. |
| 278 |
* |
| 279 |
* @param array|null $enabled_map Override the saved enable map (for previews). |
| 280 |
* @return array[] each: [ |
| 281 |
* 'name' => API tool name (sanitized callback fn), |
| 282 |
* 'callback' => the callback function name to invoke, |
| 283 |
* 'label' => admin label, |
| 284 |
* 'description' => model-facing description, |
| 285 |
* 'group' => grouping label, |
| 286 |
* 'is_addon' => bool (true → invoke via apply_filters), |
| 287 |
* 'addon' => add-on slug or '', |
| 288 |
* 'cautious' => bool (defaults off), |
| 289 |
* 'enabled' => bool (effective enabled state), |
| 290 |
* 'parameters' => JSON-Schema object for the tool arguments, |
| 291 |
* ] |
| 292 |
*/ |
| 293 |
public static function available_tools($enabled_map = null) { |
| 294 |
if ($enabled_map === null) { |
| 295 |
$enabled_map = self::enabled_map(); |
| 296 |
} |
| 297 |
$excluded = self::excluded_callbacks(); |
| 298 |
$cautious = self::cautious_callbacks(); |
| 299 |
|
| 300 |
$entries = array(); |
| 301 |
|
| 302 |
// 1) Core tools. |
| 303 |
foreach (self::core_tool_catalog() as $fn => $meta) { |
| 304 |
if (in_array($fn, $excluded, true)) { |
| 305 |
continue; |
| 306 |
} |
| 307 |
$core_desc = (isset($meta['fc_description']) && $meta['fc_description'] !== '') |
| 308 |
? $meta['fc_description'] |
| 309 |
: (isset($meta['description']) ? $meta['description'] : ''); |
| 310 |
$entries[$fn] = array( |
| 311 |
'callback' => $fn, |
| 312 |
'label' => isset($meta['label']) ? $meta['label'] : $fn, |
| 313 |
'description' => $core_desc, |
| 314 |
'group' => __('Core', 'mxchat'), |
| 315 |
'is_addon' => false, |
| 316 |
'addon' => '', |
| 317 |
'fc_parameters' => (isset($meta['fc_parameters']) && is_array($meta['fc_parameters'])) ? $meta['fc_parameters'] : null, |
| 318 |
// plan 48a57a — UI-bearing tool metadata. emits_ui marks a tool |
| 319 |
// whose output is a rendered element (image/card/gallery) that the |
| 320 |
// FC loop must surface to the frontend; ui_self_saves notes the |
| 321 |
// callback persists its own bot messages (core image/search do). |
| 322 |
'emits_ui' => !empty($meta['emits_ui']), |
| 323 |
'ui_self_saves' => array_key_exists('ui_self_saves', $meta) ? !empty($meta['ui_self_saves']) : true, |
| 324 |
// Admin-facing setup metadata (plan 183856) — core tools only. |
| 325 |
'requires_key' => isset($meta['requires_key']) ? $meta['requires_key'] : '', |
| 326 |
'setup_note' => isset($meta['setup_note']) ? $meta['setup_note'] : '', |
| 327 |
// Catalog-shipped "when to use" default (plan caee10). Applied |
| 328 |
// only when the stored entry has no usage_hint key — see |
| 329 |
// resolve_tool_setting(). Core tools only in this build. |
| 330 |
'default_hint' => isset($meta['default_hint']) ? $meta['default_hint'] : '', |
| 331 |
); |
| 332 |
} |
| 333 |
|
| 334 |
// 2) Active add-on callbacks (canonical: the live filter). |
| 335 |
$addon_callbacks = apply_filters('mxchat_available_callbacks', array()); |
| 336 |
if (is_array($addon_callbacks)) { |
| 337 |
foreach ($addon_callbacks as $fn => $data) { |
| 338 |
if (in_array($fn, $excluded, true)) { |
| 339 |
continue; |
| 340 |
} |
| 341 |
if (!is_array($data)) { |
| 342 |
$data = array(); |
| 343 |
} |
| 344 |
$label = isset($data['label']) ? $data['label'] : self::humanize($fn); |
| 345 |
// Prefer an explicit model-facing description (fc_description) so the |
| 346 |
// admin-facing `description` shown in the legacy Actions registry can |
| 347 |
// stay short while the model gets richer when-to-call guidance. |
| 348 |
$desc = (isset($data['fc_description']) && $data['fc_description'] !== '') |
| 349 |
? $data['fc_description'] |
| 350 |
: (isset($data['description']) && $data['description'] !== '' |
| 351 |
? $data['description'] |
| 352 |
: sprintf(__('Use the %s capability.', 'mxchat'), $label)); |
| 353 |
$entries[$fn] = array( |
| 354 |
'callback' => $fn, |
| 355 |
'label' => $label, |
| 356 |
'description' => $desc, |
| 357 |
'group' => isset($data['group']) ? $data['group'] : __('Add-ons', 'mxchat'), |
| 358 |
'is_addon' => true, |
| 359 |
'addon' => isset($data['addon']) ? $data['addon'] : '', |
| 360 |
'fc_parameters' => (isset($data['fc_parameters']) && is_array($data['fc_parameters'])) ? $data['fc_parameters'] : null, |
| 361 |
// plan 48a57a — an add-on declares emits_ui when its callback |
| 362 |
// returns a rendered element (e.g. woo product cards). Add-on |
| 363 |
// callbacks default to NOT self-saving (the integrator persists |
| 364 |
// their html), so ui_self_saves defaults false. |
| 365 |
'emits_ui' => !empty($data['emits_ui']), |
| 366 |
'ui_self_saves' => !empty($data['ui_self_saves']), |
| 367 |
); |
| 368 |
} |
| 369 |
} |
| 370 |
|
| 371 |
// 2b) Per-INSTANCE tools injected directly by an add-on (plan 60bccc). |
| 372 |
// Kept SEPARATE from `mxchat_available_callbacks` on purpose: an add-on |
| 373 |
// that exposes one tool per stored record (e.g. mxchat-forms surfaces |
| 374 |
// each opted-in form as its own "Collect: <Form>" tool) must not push N |
| 375 |
// entries into the intent/Actions admin registry, which also reads the |
| 376 |
// callbacks filter. Each entry mirrors the add-on entry shape; is_addon |
| 377 |
// is forced true so the chat loop invokes it via apply_filters($fn,...), |
| 378 |
// exactly like a normal add-on callback. Additive: a no-op until an |
| 379 |
// add-on hooks the filter, so existing installs see no change. |
| 380 |
$extra_tools = apply_filters('mxchat_function_calling_extra_tools', array()); |
| 381 |
if (is_array($extra_tools)) { |
| 382 |
foreach ($extra_tools as $fn => $data) { |
| 383 |
if (in_array($fn, $excluded, true) || isset($entries[$fn]) || !is_array($data)) { |
| 384 |
continue; |
| 385 |
} |
| 386 |
$label = isset($data['label']) ? $data['label'] : self::humanize($fn); |
| 387 |
$desc = (isset($data['fc_description']) && $data['fc_description'] !== '') |
| 388 |
? $data['fc_description'] |
| 389 |
: (isset($data['description']) && $data['description'] !== '' |
| 390 |
? $data['description'] |
| 391 |
: sprintf(__('Use the %s capability.', 'mxchat'), $label)); |
| 392 |
$entries[$fn] = array( |
| 393 |
'callback' => $fn, |
| 394 |
'label' => $label, |
| 395 |
'description' => $desc, |
| 396 |
'group' => isset($data['group']) ? $data['group'] : __('Add-ons', 'mxchat'), |
| 397 |
'is_addon' => true, |
| 398 |
'addon' => isset($data['addon']) ? $data['addon'] : '', |
| 399 |
'fc_parameters' => (isset($data['fc_parameters']) && is_array($data['fc_parameters'])) ? $data['fc_parameters'] : null, |
| 400 |
'emits_ui' => !empty($data['emits_ui']), |
| 401 |
'ui_self_saves' => !empty($data['ui_self_saves']), |
| 402 |
); |
| 403 |
} |
| 404 |
} |
| 405 |
|
| 406 |
// 3) Finalize: name, parameters, cautious flag, effective enabled state, |
| 407 |
// plus the owner's optional per-tool "when to use" hint (plan 27d3e6). |
| 408 |
$tools = array(); |
| 409 |
foreach ($entries as $fn => $e) { |
| 410 |
$is_cautious = in_array($fn, $cautious, true); |
| 411 |
$saved = array_key_exists($fn, $enabled_map) ? $enabled_map[$fn] : null; |
| 412 |
list($enabled, $usage_hint) = self::resolve_tool_setting( |
| 413 |
$saved, |
| 414 |
$is_cautious, |
| 415 |
isset($e['default_hint']) ? $e['default_hint'] : '' |
| 416 |
); |
| 417 |
$tools[] = array( |
| 418 |
'name' => self::tool_name($fn), |
| 419 |
'callback' => $fn, |
| 420 |
'label' => $e['label'], |
| 421 |
'description' => $e['description'], |
| 422 |
'usage_hint' => $usage_hint, |
| 423 |
'group' => $e['group'], |
| 424 |
'is_addon' => $e['is_addon'], |
| 425 |
'addon' => $e['addon'], |
| 426 |
'cautious' => $is_cautious, |
| 427 |
'enabled' => $enabled, |
| 428 |
'parameters' => self::parameters_for($fn, $e), |
| 429 |
// plan 48a57a — carried through so the chat loop can detect |
| 430 |
// UI-bearing tool output and decide whether to persist its html. |
| 431 |
'emits_ui' => !empty($e['emits_ui']), |
| 432 |
'ui_self_saves' => !empty($e['ui_self_saves']), |
| 433 |
// plan 183856 — admin-facing setup hint surfaced on the AI Tools |
| 434 |
// page; requires_key lets the page flag a missing dependency key. |
| 435 |
'requires_key' => isset($e['requires_key']) ? $e['requires_key'] : '', |
| 436 |
'setup_note' => isset($e['setup_note']) ? $e['setup_note'] : '', |
| 437 |
); |
| 438 |
} |
| 439 |
return $tools; |
| 440 |
} |
| 441 |
|
| 442 |
/** Just the enabled tools — what the model actually sees. */ |
| 443 |
public static function enabled_tools($enabled_map = null) { |
| 444 |
$tools = array(); |
| 445 |
foreach (self::available_tools($enabled_map) as $t) { |
| 446 |
if (!empty($t['enabled'])) { |
| 447 |
$tools[] = $t; |
| 448 |
} |
| 449 |
} |
| 450 |
return self::apply_live_agent_schedule($tools); |
| 451 |
} |
| 452 |
|
| 453 |
/** |
| 454 |
* Withhold each live-agent handover tool outside ITS channel's availability |
| 455 |
* schedule (plans 8ccaa2 + 99d7a4; webhook added by d88e22). Slack, |
| 456 |
* Telegram, and the webhook destination carry independent schedules: if |
| 457 |
* Slack is off-hours but Telegram is staffed, the Telegram handoff stays |
| 458 |
* offered and only the Slack one is withheld — and likewise for each other |
| 459 |
* pair. Off-hours the bot must not DANGLE a human it cannot reach, so the |
| 460 |
* tool never reaches the model in the first place. |
| 461 |
* |
| 462 |
* Applied HERE and deliberately NOT in available_tools(): that function |
| 463 |
* feeds the admin AI Tools checklist, which must keep showing the owner's |
| 464 |
* real configuration. A tool that vanished from the admin list every evening |
| 465 |
* would read as a bug, not a schedule. |
| 466 |
* |
| 467 |
* tool_by_name() resolves through this same list, so an off-hours model that |
| 468 |
* emits the tool name anyway (e.g. echoing conversation history) cannot get |
| 469 |
* it executed either. |
| 470 |
* |
| 471 |
* All call sites tolerate an empty result — mxchat_fc_should_run() and |
| 472 |
* mxchat_fc_attempt() each fall back to the normal path — so this is safe |
| 473 |
* even on a site whose ONLY enabled tool is a handover. |
| 474 |
*/ |
| 475 |
private static function apply_live_agent_schedule($tools) { |
| 476 |
if (!class_exists('MxChat_Live_Agent_Schedule')) { |
| 477 |
return $tools; |
| 478 |
} |
| 479 |
$withhold = array(); |
| 480 |
if (!MxChat_Live_Agent_Schedule::is_within_hours('slack')) { |
| 481 |
$withhold[] = 'mxchat_live_agent_handover'; |
| 482 |
} |
| 483 |
if (!MxChat_Live_Agent_Schedule::is_within_hours('telegram')) { |
| 484 |
$withhold[] = 'mxchat_telegram_live_agent_handover'; |
| 485 |
} |
| 486 |
if (!MxChat_Live_Agent_Schedule::is_within_hours('webhook')) { |
| 487 |
$withhold[] = 'mxchat_webhook_live_agent_handover'; |
| 488 |
} |
| 489 |
if (empty($withhold)) { |
| 490 |
return $tools; |
| 491 |
} |
| 492 |
$out = array(); |
| 493 |
foreach ($tools as $t) { |
| 494 |
if (in_array($t['callback'], $withhold, true)) { |
| 495 |
continue; |
| 496 |
} |
| 497 |
$out[] = $t; |
| 498 |
} |
| 499 |
return $out; |
| 500 |
} |
| 501 |
|
| 502 |
/** Resolve a model-emitted tool name back to its tool entry. */ |
| 503 |
public static function tool_by_name($name, $enabled_only = true) { |
| 504 |
$list = $enabled_only ? self::enabled_tools() : self::available_tools(); |
| 505 |
foreach ($list as $t) { |
| 506 |
if ($t['name'] === $name || $t['callback'] === $name) { |
| 507 |
return $t; |
| 508 |
} |
| 509 |
} |
| 510 |
return null; |
| 511 |
} |
| 512 |
|
| 513 |
/* ------------------------------------------------------------------ * |
| 514 |
* Schema helpers |
| 515 |
* ------------------------------------------------------------------ */ |
| 516 |
|
| 517 |
/** Sanitize a callback fn into a valid API tool name (^[A-Za-z0-9_-]{1,64}$). */ |
| 518 |
public static function tool_name($fn) { |
| 519 |
$name = preg_replace('/[^A-Za-z0-9_-]/', '_', (string) $fn); |
| 520 |
return substr($name, 0, 64); |
| 521 |
} |
| 522 |
|
| 523 |
/** Turn a function name into a readable label as a fallback. */ |
| 524 |
private static function humanize($fn) { |
| 525 |
$s = preg_replace('/^mxchat_(handle_)?/', '', (string) $fn); |
| 526 |
$s = str_replace('_', ' ', $s); |
| 527 |
return ucwords(trim($s)); |
| 528 |
} |
| 529 |
|
| 530 |
/** |
| 531 |
* JSON-Schema for a tool's arguments. MxChat callbacks are message-driven, so |
| 532 |
* the default is a single natural-language `query` string (handed to the |
| 533 |
* callback as the user message). A registry entry may declare richer params |
| 534 |
* via an additive `fc_parameters` key (used by the woo tools plan c2c9b5). |
| 535 |
*/ |
| 536 |
public static function parameters_for($fn, $entry = array()) { |
| 537 |
if (is_array($entry) && isset($entry['fc_parameters']) && is_array($entry['fc_parameters'])) { |
| 538 |
return $entry['fc_parameters']; |
| 539 |
} |
| 540 |
return array( |
| 541 |
'type' => 'object', |
| 542 |
'properties' => array( |
| 543 |
'query' => array( |
| 544 |
'type' => 'string', |
| 545 |
'description' => __('The natural-language request, question, or search query to act on.', 'mxchat'), |
| 546 |
), |
| 547 |
), |
| 548 |
'required' => array('query'), |
| 549 |
); |
| 550 |
} |
| 551 |
|
| 552 |
/* ------------------------------------------------------------------ * |
| 553 |
* Provider-native tool-schema transforms (pure) |
| 554 |
* ------------------------------------------------------------------ */ |
| 555 |
|
| 556 |
/** OpenAI / xAI / DeepSeek / OpenRouter / Custom (OpenAI-compatible). */ |
| 557 |
public static function to_openai_tools($tools) { |
| 558 |
$out = array(); |
| 559 |
foreach ($tools as $t) { |
| 560 |
$out[] = array( |
| 561 |
'type' => 'function', |
| 562 |
'function' => array( |
| 563 |
'name' => $t['name'], |
| 564 |
'description' => self::clip(self::model_description($t)), |
| 565 |
'parameters' => $t['parameters'], |
| 566 |
), |
| 567 |
); |
| 568 |
} |
| 569 |
return $out; |
| 570 |
} |
| 571 |
|
| 572 |
/** Anthropic Claude. */ |
| 573 |
public static function to_anthropic_tools($tools) { |
| 574 |
$out = array(); |
| 575 |
foreach ($tools as $t) { |
| 576 |
$out[] = array( |
| 577 |
'name' => $t['name'], |
| 578 |
'description' => self::clip(self::model_description($t)), |
| 579 |
'input_schema' => $t['parameters'], |
| 580 |
); |
| 581 |
} |
| 582 |
return $out; |
| 583 |
} |
| 584 |
|
| 585 |
/** Google Gemini (functionDeclarations under a single tools entry). */ |
| 586 |
public static function to_gemini_tools($tools) { |
| 587 |
$decls = array(); |
| 588 |
foreach ($tools as $t) { |
| 589 |
$decls[] = array( |
| 590 |
'name' => $t['name'], |
| 591 |
'description' => self::clip(self::model_description($t)), |
| 592 |
'parameters' => $t['parameters'], |
| 593 |
); |
| 594 |
} |
| 595 |
return $decls ? array(array('functionDeclarations' => $decls)) : array(); |
| 596 |
} |
| 597 |
|
| 598 |
private static function clip($s, $max = 1024) { |
| 599 |
$s = (string) $s; |
| 600 |
return strlen($s) > $max ? substr($s, 0, $max) : $s; |
| 601 |
} |
| 602 |
|
| 603 |
/** |
| 604 |
* The description actually sent to the model (plan 27d3e6): the fixed |
| 605 |
* dev-authored base description PLUS the site owner's optional |
| 606 |
* "when to use" hint. The hint STEERS the model's decision to call the |
| 607 |
* tool; the base description is additive-only and never replaced. When the |
| 608 |
* hint is empty the description is unchanged, so existing installs send the |
| 609 |
* exact same schema as before. |
| 610 |
*/ |
| 611 |
private static function model_description($t) { |
| 612 |
$base = isset($t['description']) ? (string) $t['description'] : ''; |
| 613 |
$hint = isset($t['usage_hint']) ? trim((string) $t['usage_hint']) : ''; |
| 614 |
if ($hint === '') { |
| 615 |
return $base; |
| 616 |
} |
| 617 |
return ($base !== '' ? $base . "\n\n" : '') . 'When to use: ' . $hint; |
| 618 |
} |
| 619 |
|
| 620 |
/* ------------------------------------------------------------------ * |
| 621 |
* AJAX autosave for the AI Tools section |
| 622 |
* ------------------------------------------------------------------ */ |
| 623 |
|
| 624 |
/** |
| 625 |
* Persist the master toggle + the per-tool settings map. $all is every tool |
| 626 |
* shown; $checked is the subset that is on; $hints is [ fn => string ] of |
| 627 |
* the owner's "when to use" notes. We store an explicit object entry for |
| 628 |
* EVERY shown tool so unchecking one persists as enabled:false (not |
| 629 |
* "absent → default") and so the hint survives an enable/disable toggle. |
| 630 |
* Returns the stored map. |
| 631 |
*/ |
| 632 |
private static function persist($enabled, $all, $checked, $hints = array()) { |
| 633 |
update_option(self::OPT_ENABLED, $enabled ? 1 : 0); |
| 634 |
$checked_lookup = array_fill_keys($checked, true); |
| 635 |
$map = array(); |
| 636 |
foreach ($all as $fn) { |
| 637 |
$map[$fn] = array( |
| 638 |
'enabled' => isset($checked_lookup[$fn]), |
| 639 |
'usage_hint' => isset($hints[$fn]) ? self::clip_hint($hints[$fn]) : '', |
| 640 |
); |
| 641 |
} |
| 642 |
update_option(self::OPT_TOOLS, $map); |
| 643 |
return $map; |
| 644 |
} |
| 645 |
|
| 646 |
/** Count tools that are effectively enabled across both stored shapes. */ |
| 647 |
private static function count_enabled($map) { |
| 648 |
$n = 0; |
| 649 |
foreach ($map as $v) { |
| 650 |
if (is_array($v) ? !empty($v['enabled']) : (bool) $v) { |
| 651 |
$n++; |
| 652 |
} |
| 653 |
} |
| 654 |
return $n; |
| 655 |
} |
| 656 |
|
| 657 |
/** AJAX: autosave on every toggle/checkbox change in the AI Tools section. */ |
| 658 |
public static function handle_ajax_save() { |
| 659 |
if (!current_user_can('manage_options')) { |
| 660 |
wp_send_json_error(array('message' => 'forbidden'), 403); |
| 661 |
} |
| 662 |
check_ajax_referer('mxchat_fc_autosave', 'nonce'); |
| 663 |
|
| 664 |
// Presence = active (plan d450a7): the global on/off toggle is gone, so |
| 665 |
// the gate is derived server-side from whether any tool is checked — not |
| 666 |
// a client-sent flag. Empty list = off (safe default). |
| 667 |
$all = isset($_POST['all_tools']) && is_array($_POST['all_tools']) |
| 668 |
? array_map('sanitize_text_field', wp_unslash($_POST['all_tools'])) |
| 669 |
: array(); |
| 670 |
$checked = isset($_POST['tools']) && is_array($_POST['tools']) |
| 671 |
? array_map('sanitize_text_field', wp_unslash($_POST['tools'])) |
| 672 |
: array(); |
| 673 |
// Per-tool "when to use" hints: [ callback_fn => free text ]. Keyed by |
| 674 |
// callback, so sanitize the key as a text field and the value as a |
| 675 |
// textarea (preserves newlines, strips tags). |
| 676 |
$hints = array(); |
| 677 |
if (isset($_POST['hints']) && is_array($_POST['hints'])) { |
| 678 |
foreach (wp_unslash($_POST['hints']) as $fn => $hint) { |
| 679 |
$hints[sanitize_text_field($fn)] = sanitize_textarea_field($hint); |
| 680 |
} |
| 681 |
} |
| 682 |
|
| 683 |
$enabled = !empty($checked); |
| 684 |
$map = self::persist($enabled, $all, $checked, $hints); |
| 685 |
wp_send_json_success(array('enabled' => $enabled, 'enabled_count' => self::count_enabled($map))); |
| 686 |
} |
| 687 |
} |
| 688 |
|