optionKey = $optionKey; add_filter('ncf_sanitize_field_value', [$this, 'allowHtmlInMarkdownFields'], 10, 4); } /** * Allows HTML in the markdown-editor-backed textarea fields. * * NCF's default textarea sanitizer strips HTML; the "agent-mod-md-" fields * on the settings page are rendered with EasyMDE and expect their HTML * output preserved on save. * * @param mixed $sanitized NCF's default sanitized value. * @param mixed $raw_value Raw, unslashed input value. * @param string $field_type NCF field type. * @param string $field_name NCF field (meta key) name. * * @return mixed * @since 1.0.8 */ public function allowHtmlInMarkdownFields($sanitized, $raw_value, string $field_type, string $field_name) { $markdown_fields = ['role', 'goal', 'global_system_prompt']; if ('textarea' === $field_type && in_array($field_name, $markdown_fields, true)) { return wp_kses_post($raw_value); } return $sanitized; } // ------------------------------------------------------------------------- // Settings Getters — each reads the saved setting and returns it when set, // applying backwards-compatible filters. // ------------------------------------------------------------------------- /** * @return int * @since 1.0.0 */ public function getMaxToolCalls(): int { $saved = (int) ($this->getSettings()['agent_mod_ai_limits']['max_tool_calls'] ?? 0); $value = $saved > 0 ? $saved : Constants::AI_MAX_TOOL_CALLS; return (int) $value; } /** * @return int * @since 1.0.0 */ public function getMaxSearchResults(): int { $saved = (int) ($this->getSettings()['agent_mod_ai_limits']['max_search_results'] ?? 0); $value = $saved > 0 ? $saved : Constants::AI_MAX_SEARCH_RESULTS; return (int) $value; } /** * Short schema hint telling the model how many items a list ability returns per call. * * @return string * @since 1.3.2 */ public function getSearchLimitHint(): string { return sprintf( /* translators: %d: Max Search Results setting value. */ __('Returns at most %d items per call (Max Search Results setting).', 'agent-mod'), $this->getMaxSearchResults() ); } /** * Notice added to a list ability result that hit the Max Search Results cap. * * Tells the model where the cap comes from so it can point the user at the * setting instead of retrying the same call. * * @param int $limit The cap that was applied. * @param bool $paginated Whether the ability can page past the cap. * * @return string * @since 1.3.2 */ public function getSearchLimitNotice(int $limit, bool $paginated = false): string { if ($paginated) { return sprintf( /* translators: %d: Max Search Results setting value. */ __('Results are limited to %d per call by the "Max Search Results" setting (AgentMod > Settings > AI Limits). Fetch the remaining pages with "paged" while pagination.has_more is true; never repeat a call with the same arguments. If many pages remain, tell the user about this limit and suggest raising that setting or narrowing the request.', 'agent-mod'), $limit ); } return sprintf( /* translators: %d: Max Search Results setting value. */ __('Results are limited to %d per call by the "Max Search Results" setting (AgentMod > Settings > AI Limits). If the user asked for all items, tell them about this limit and suggest raising that setting or narrowing the request; do not retry the same call.', 'agent-mod'), $limit ); } /** * @return int * @since 1.0.0 */ public function getMaxFullContentPosts(): int { $saved = (int) ($this->getSettings()['agent_mod_ai_limits']['max_full_content_posts'] ?? 0); $value = $saved > 0 ? $saved : Constants::AI_MAX_FULL_CONTENT_POSTS; return (int) $value; } /** * Maximum length (characters) of a free-text answer to an ask-user or * request-approval dialog. * * @return int * @since 1.3.2 */ public function getMaxAnswerLength(): int { $saved = (int) ($this->getSettings()['agent_mod_ai_limits']['max_answer_length'] ?? 0); return $saved > 0 ? $saved : Constants::AI_MAX_ANSWER_LENGTH; } /** * @return int * @since 1.0.0 */ public function getAttachmentMaxBytes(): int { $saved = (int) ($this->getSettings()['agent_mod_attachments']['attachment_max_bytes'] ?? 0); $value = $saved > 0 ? $saved : Constants::AI_ATTACHMENT_MAX_BYTES; return (int) $value; } /** * @return int * @since 1.0.0 */ public function getAttachmentMaxCount(): int { $saved = (int) ($this->getSettings()['agent_mod_attachments']['attachment_max_count'] ?? 0); $value = $saved > 0 ? $saved : Constants::AI_ATTACHMENT_MAX_COUNT; return (int) $value; } /** * @return string[] * @since 1.0.0 */ public function getAttachmentMimeTypes(): array { $raw = trim((string) ($this->getSettings()['agent_mod_attachments']['attachment_mime_types'] ?? '')); if ('' === $raw) { $value = Constants::AI_ATTACHMENT_MIME_TYPES; } else { $value = array_values(array_filter(array_map('trim', explode("\n", $raw)))); if (empty($value)) { $value = Constants::AI_ATTACHMENT_MIME_TYPES; } } return (array) $value; } /** * Returns the user-managed base system prompt when saved, else the default. * * @return string * @since 1.0.0 */ public function getSystemPrompt(): string { $saved = trim((string) ($this->getSettings()['agent_mod_chat_behaviour']['global_system_prompt'] ?? '')); $value = '' !== $saved ? $saved : Constants::aiDefaultSystemPrompt(); return (string) $value; } /** * @return bool * @since 1.0.6 */ public function isSiteContextEnabled(): bool { $saved = $this->getSettings()['agent_mod_chat_behaviour']['site_context_enabled'] ?? null; $value = $saved !== null ? (bool) $saved : Constants::AI_CONTEXT_ENABLED; return (bool) $value; } /** * @return bool * @since 1.0.6 */ public function isWebSearchEnabled(): bool { $saved = $this->getSettings()['agent_mod_chat_behaviour']['web_search_enabled'] ?? null; $value = $saved !== null ? (bool) $saved : Constants::AI_WEB_SEARCH_ENABLED; return (bool) $value; } /** * @return string * @since 1.0.5 */ public function getRole(): string { $saved = trim((string) ($this->getSettings()['agent_mod_chat_behaviour']['role'] ?? '')); $value = '' !== $saved ? $saved : Constants::AI_AGENT_DEFAULT_ROLE; return (string) $value; } /** * @return string * @since 1.0.5 */ public function getGoal(): string { $saved = trim((string) ($this->getSettings()['agent_mod_chat_behaviour']['goal'] ?? '')); $value = '' !== $saved ? $saved : Constants::AI_AGENT_DEFAULT_GOAL; return (string) $value; } /** * Ability source of the settings-based (default) agent. * * Always 'all' since dynamic tool loading: the agent finds the abilities it * needs itself, so the settings page no longer offers a selection. A saved * 'selected' value from earlier versions is ignored on purpose. Agents that * carry their own ability list (e.g. PRO agents) still pass it explicitly. * * @return string * @since 1.1.0 */ public function getAbilitySource(): string { return 'all'; } /** * Allowed abilities of the settings-based (default) agent. * * Always empty: see getAbilitySource(). Returning the stale saved list would * let a selection the settings page no longer shows leak into agents that * fall back to it. * * @return string[] * @since 1.1.0 */ public function getAllowedAbilities(): array { return []; } /** * Tool loading strategy: 'dynamic' or 'all'. * * @return string * @since 1.3.4 */ public function getToolLoading(): string { $saved = (string) ($this->getSettings()['agent_mod_abilities']['tool_loading'] ?? ''); return in_array($saved, ['dynamic', 'all'], true) ? $saved : Constants::AI_TOOL_LOADING; } /** * Resolves the allowed abilities to their display details. * * Uses wp_get_abilities() to index the registry once (not the REST * abilities list), so an allowed ability is always included here even * when it is excluded from REST discovery (e.g. core/get-user-info, * which core registers with show_in_rest => false for privacy reasons). * Names that are no longer registered (stale entries, or garbage values * such as a select field's placeholder) are dropped without calling * wp_get_ability() per name, which would raise a _doing_it_wrong notice * for every unregistered name. * * @return array * @since 1.0.5 */ public function getAllowedAbilitiesDetailed(): array { if (! function_exists('wp_get_abilities')) { return []; } $registry = []; foreach (wp_get_abilities() as $ability) { $registry[$ability->get_name()] = $ability; } $details = []; foreach ($this->getAllowedAbilities() as $name) { $ability = $registry[(string) $name] ?? null; if (null === $ability) { continue; } $details[] = [ 'name' => $ability->get_name(), 'label' => $ability->get_label(), 'meta' => [ 'annotations' => [ 'readonly' => AbilityResolver::isReadonly($ability), ], ], ]; } return $details; } /** * Returns the site-wide default AI model together with its provider. * * The setting stores a single opaque ":" value, so the two * can never drift apart. An empty provider means "Use Auto" — let the AI * Client pick. * * @return array{provider: string, model: string|null} * @since 1.1.0 */ public function getDefaultModel(): array { return ProviderInfoService::splitModelValue( (string) ($this->getSettings()['agent_mod_ai_model']['default_model'] ?? '') ); } /** * @return string[] * @since 1.1.0 */ public function getPersonalityTraits(): array { $saved = $this->getSettings()['agent_mod_chat_behaviour']['personality_traits'] ?? []; // Token field values can sometimes come as a comma separated string if not handled natively as an array, // but NCF's token_field normally saves as an array or comma string. We'll handle both. if (is_string($saved)) { $saved = array_filter(array_map('trim', explode(',', $saved))); } return is_array($saved) ? $saved : []; } /** * Returns the configured preset prompts for the chat composer. * * The NCF repeater stores its rows as a JSON string (older saves may hold a * real array) — both shapes are accepted. Rows without a prompt are * dropped; a missing label falls back to a truncated prompt excerpt. * * @return array * @since 1.0.7 */ public function getPresetPrompts(): array { $raw = $this->getSettings()['agent_mod_chat_panel']['preset_prompts'] ?? []; if (is_string($raw)) { $decoded = json_decode($raw, true); $raw = is_array($decoded) ? $decoded : []; } if (! is_array($raw)) { return []; } $presets = []; foreach ($raw as $row) { if (! is_array($row)) { continue; } $prompt = sanitize_textarea_field((string) ($row['prompt'] ?? '')); if ('' === trim($prompt)) { continue; } $label = sanitize_text_field((string) ($row['label'] ?? '')); if ('' === trim($label)) { $label = wp_html_excerpt($prompt, 30, '…'); } $presets[] = [ 'label' => $label, 'prompt' => $prompt, ]; } $value = ! empty($presets) ? $presets : Constants::aiDefaultPresetPrompts(); return $value; } /** * Returns the saved settings array, or an empty array when no settings are saved yet. * * @return array * @since 1.0.0 */ private function getSettings(): array { return (array) get_option($this->optionKey, []); } }