PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.24
MxChat – AI Chatbot & Content Generation for WordPress v3.2.24
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
mxchat-basic / includes / class-mxchat-tool-registry.php

class-mxchat-tool-registry.php in MxChat – AI Chatbot & Content Generation for WordPress 3.2.24, at includes/class-mxchat-tool-registry.php

688 lines 34.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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