* @author Maximum Software Inc * @license GPL-3.0-only * @link https://agenistrator.com/ * * SPDX-FileCopyrightText: 2025-2026 Alex Ruban * SPDX-FileCopyrightText: 2025-2026 Maximum Software Inc * SPDX-License-Identifier: GPL-3.0-only */ namespace Agenistrator; // Direct access prevention if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Chat class. */ class Chat implements Maintenance { use Singleton; /** Agent iteration result: stop the loop */ private const ITERATION_STOP = 0; /** Agent iteration result: empty response, may retry */ private const ITERATION_EMPTY = 1; /** Agent iteration result: continue the loop */ private const ITERATION_CONTINUE = 2; /** Agent loop result: completed naturally */ private const LOOP_COMPLETE = 'complete'; /** Agent loop result: timeout, needs resume */ private const LOOP_TIMEOUT = 'timeout'; /** Agent loop result: rate limited, needs resume after wait */ private const LOOP_RATE_LIMIT = 'rate_limit'; /** Agent loop result: stopped by user */ private const LOOP_STOPPED = 'stopped'; /** Maximum consecutive empty responses before stopping the agent loop */ private const MAX_CONSECUTIVE_EMPTY_RESPONSES = 3; /** Agent loop iterations before LLM watchdog evaluates loop progress */ private const ITERATIONS_BEFORE_WATCHDOG = 10; /** * Register all hooks related to chat functionality * @since 1.0.0 */ public function register_hooks() { // Register chat hooks add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) ); add_action( 'admin_footer', array( $this, 'display_global_chat_interface' ) ); // Register chat AJAX handlers $chat_ajax_handlers = array( 'agenistrator_chat_send_message' => 'ajax_send_message', 'agenistrator_chat_clear_chat_log' => 'ajax_clear_chat_log', 'agenistrator_stop_agent_loop' => 'ajax_stop_agent_loop', 'agenistrator_resume_agent_loop' => 'ajax_resume_agent_loop', 'agenistrator_report_issue' => 'ajax_report_issue', ); foreach ( $chat_ajax_handlers as $action => $callback ) { add_action( 'wp_ajax_' . $action, array( $this, $callback ) ); } } /** * Plugin activation tasks for chat * @since 1.0.0 */ public function activate_plugin() { // Chat log table creation is now handled by Chat_Log_Manager // No specific activation tasks needed for Chat class } /** * Plugin deactivation tasks for chat * @since 1.0.0 */ public function deactivate_plugin() { // No deactivation tasks needed } /** * Uninstall plugin * @since 1.0.0 */ public function uninstall_plugin() { // Chat log table deletion is now handled by Chat_Log_Manager // No specific uninstall tasks needed for Chat class } /** * Last event ID for stop detection * @var int|null */ private $last_event_id = null; /** * Flag for LLM-requested termination * @var bool */ private $llm_terminated = false; /** * Cached user ID * @var int|null */ private $user_id = null; /** * Cached thread instance * @var Thread|null */ private $thread = null; /** * Load HTML templates for the chat script * * @return array Associative array of template name => template content * @since 1.0.0 */ public function load_js_script_templates() { $templates = array(); // Define templates for the chat script $script_templates = array( 'chat-message-user.html', 'chat-message-assistant.html', 'chat-message-tool-call.html', 'chat-message-tool-result.html', 'chat-error-message.html', 'chat-api-error.html', 'chat-status.html', ); // Load each template $templates_dir = plugin_dir_path( __DIR__ ) . 'admin/html/'; foreach ( $script_templates as $template_file ) { $template_path = $templates_dir . $template_file; if ( file_exists( $template_path ) ) { // Get the template name without the .html extension for use as the key $template_name = basename( $template_file, '.html' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $templates[ $template_name ] = file_get_contents( $template_path ); } } return $templates; } /** * Get localized strings for the chat script * * @return array Associative array of string keys => localized values * @since 1.0.0 */ public function get_js_strings() { // Define chat-specific strings only (common strings come from Admin class) return array( // Chat-specific strings 'network_error' => __( 'Sorry, there was a network error. Please try again.', 'agenistrator' ), 'error_message_format' => __( '{strong}Error:{/strong} {error_details}', 'agenistrator' ), 'try_again_message' => __( 'Please try again or refresh the page.', 'agenistrator' ), 'running_tool' => __( 'Running tool "{tool_name}"...', 'agenistrator' ), 'confirm_clear_chat' => __( 'Are you sure you want to clear the chat log?', 'agenistrator' ), 'no_arguments' => __( 'No arguments', 'agenistrator' ), 'send_button_text' => __( 'Send', 'agenistrator' ), 'stop_button_text' => __( 'Stop', 'agenistrator' ), 'rate_limit_waiting' => __( 'Waiting for rate limit...', 'agenistrator' ), // Console messages 'session_storage_read_error' => __( 'Could not read session data from sessionStorage', 'agenistrator' ), 'session_storage_write_error' => __( 'Could not save session data to sessionStorage', 'agenistrator' ), 'sse_not_supported' => __( 'SSE not supported by this browser', 'agenistrator' ), 'sse_connection_error' => __( 'SSE connection error', 'agenistrator' ), 'sse_error_event_received' => __( 'SSE error event received: {error_data}', 'agenistrator' ), 'failed_to_clear_chat_log' => __( 'Failed to clear chat log: {error_details}', 'agenistrator' ), 'server_error_clearing_chat_log' => __( 'Server error clearing chat log: {error_details}', 'agenistrator' ), // SSE parsing error messages 'error_parsing_message_event' => __( 'Error parsing message event: {error_details}', 'agenistrator' ), 'error_parsing_stream_chunk_event' => __( 'Error parsing stream chunk event: {error_details}', 'agenistrator' ), 'error_parsing_control_event' => __( 'Error parsing control event: {error_details}', 'agenistrator' ), 'error_parsing_log_loaded_event' => __( 'Error parsing log loaded event: {error_details}', 'agenistrator' ), // Markdown library error messages 'markdown_parse_error' => __( 'Markdown error: {error_details}', 'agenistrator' ), 'markdown_library_not_loaded' => __( 'Markdown library not loaded. Please contact your administrator.', 'agenistrator' ), 'sanitization_library_not_loaded' => __( 'HTML sanitization library not loaded. Please contact your administrator.', 'agenistrator' ), // Report issue messages 'prompt_issue_description' => __( "In a few words, please describe the issue you're experiencing with the agent:", 'agenistrator' ), 'confirm_report_issue' => __( "This will create a diagnostic report containing:\n• Your issue description\n• Your chat history with the agent\n• Plugin internal information (event log, etc)\n• Plugin configuration (model settings, enabled tools)\n• Error log entries (Agenistrator-specific)\n• WordPress stats (number of posts, etc)\n• Action Scheduler status (if available)\n• Site health data (PHP, database, server, plugins, theme)\nNo API keys or passwords are included.\n\nWould you like to email this report to the plugin developers?", 'agenistrator' ), 'report_sent_success' => __( 'Report created and sent to the plugin developers. You can also view and download reports from the Status page.', 'agenistrator' ), 'report_created_success' => __( 'Report created without sending it to the plugin developers. You can view and download it from the Status page.', 'agenistrator' ), 'report_issue_error' => __( 'Failed to create report: {error_details}', 'agenistrator' ), // UI elements 'cursor_character' => __( '▎', 'agenistrator' ), 'tool_icon' => __( '🛠️', 'agenistrator' ), 'tool_result_success_icon' => __( '✅', 'agenistrator' ), 'tool_result_error_icon' => __( '❌', 'agenistrator' ), // Tool call/result labels 'tool_call_label' => __( 'Tool Call:', 'agenistrator' ), 'tool_result_label' => __( 'Tool Result:', 'agenistrator' ), 'arguments_label' => __( 'Arguments:', 'agenistrator' ), ); } /** * Register the CSS and JavaScript for the chat area. * * @since 1.0.0 */ public function enqueue_scripts() { // Only enqueue scripts for users with manage_options capability if ( ! current_user_can( 'manage_options' ) ) { return; } // Enqueue chat CSS globally on all admin pages (conflict-free) with common CSS as dependency wp_register_style( 'nprogress', plugin_dir_url( __FILE__ ) . 'css/lib/nprogress.css', array(), '0.2.0' ); wp_register_style( 'agenistrator-chat', plugin_dir_url( __FILE__ ) . 'css/chat.css', array( 'agenistrator-common', 'nprogress' ), AGENISTRATOR_VERSION ); wp_enqueue_style( 'agenistrator-chat' ); // Register third-party libraries wp_register_script( 'eventsource-polyfill', plugin_dir_url( __FILE__ ) . 'js/lib/eventsource.min.js', array(), '1.0.31', true ); wp_register_script( 'marked', plugin_dir_url( __FILE__ ) . 'js/lib/marked.js', array(), '18.1.1', true ); wp_register_script( 'dompurify', plugin_dir_url( __FILE__ ) . 'js/lib/purify.min.js', array(), '3.4.17', true ); wp_register_script( 'nprogress', plugin_dir_url( __FILE__ ) . 'js/lib/nprogress.js', array(), '0.2.0', true ); // Register SSE client script (depends on EventSource polyfill) wp_register_script( 'agenistrator-chat-sse', plugin_dir_url( __FILE__ ) . 'js/chat-sse.js', array( 'jquery', 'agenistrator-common', 'eventsource-polyfill' ), AGENISTRATOR_VERSION, true ); // Register Chat API script wp_register_script( 'agenistrator-chat-api', plugin_dir_url( __FILE__ ) . 'js/chat-api.js', array( 'jquery', 'agenistrator-common' ), AGENISTRATOR_VERSION, true ); // Register chat script wp_register_script( 'agenistrator-chat', plugin_dir_url( __FILE__ ) . 'js/chat.js', array( 'jquery', 'agenistrator-common', 'agenistrator-chat-sse', 'agenistrator-chat-api', 'marked', 'dompurify', 'nprogress' ), AGENISTRATOR_VERSION, true ); // Get localized strings for the chat script $strings = $this->get_js_strings(); // Get templates for the chat script $templates = $this->load_js_script_templates(); // Build tool configurations map for JS $tool_manager = Tool_Manager::get_instance(); $tools_map = array(); foreach ( $tool_manager->get_tools() as $id => $tool ) { $tools_map[ $id ] = array( 'name' => $tool->get_name(), 'description' => $tool->get_description(), 'enabled' => $tool_manager->is_tool_enabled( $id ), 'parameters' => $tool->get_parameters(), ); } // Prepare data for the script $script_data = array( 'chat_position' => Settings::get_instance()->get_config( 'chat_position', 'bottom-right' ), 'tools' => $tools_map, 'strings' => $strings, 'templates' => $templates, 'sse_url' => get_option( 'permalink_structure' ) ? home_url( '/agenistrator/sse/' ) : add_query_arg( 'agenistrator_sse', '1', home_url( '/' ) ), ); // Localize and enqueue the script with chat-specific object name wp_localize_script( 'agenistrator-chat', 'agenistrator_chat', $script_data ); wp_enqueue_script( 'agenistrator-chat' ); } /** * Display global chat interface * @since 1.0.0 */ public function display_global_chat_interface() { // Only display chat interface for users with manage_options capability if ( ! current_user_can( 'manage_options' ) ) { return; } // Get chat position from Settings class $chat_position = Settings::get_instance()->get_config( 'chat_position', 'bottom-right' ); // Render chat button $this->render_chat_button( $chat_position ); // Render chat widget (hidden by default) $this->render_chat_widget( $chat_position ); } /** * Render the chat button */ private function render_chat_button( $chat_position ) { $attributes = array( 'chat_position' => esc_attr( $chat_position ), 'open_chat_title' => esc_attr__( 'Open Agenistrator', 'agenistrator' ), ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Template system outputs pre-escaped HTML; all $attributes values are escaped above echo String_Utils::render( 'chat-button', $attributes ); } /** * Render the chat widget */ private function render_chat_widget( $chat_position ) { // Prepare attributes for the template $attributes = array( 'chat_position' => esc_attr( $chat_position ), 'chat_title' => esc_html__( 'Agenistrator', 'agenistrator' ), 'clear_chat_title' => esc_attr__( 'Clear chat history', 'agenistrator' ), 'maximize_chat_title' => esc_attr__( 'Maximize chat', 'agenistrator' ), 'restore_chat_title' => esc_attr__( 'Restore chat size', 'agenistrator' ), 'close_chat_title' => esc_attr__( 'Close chat', 'agenistrator' ), 'chat_placeholder' => esc_attr__( 'Ask me anything about your WordPress site...', 'agenistrator' ), 'send_button' => esc_html__( 'Send', 'agenistrator' ), 'report_issue_link' => esc_html__( 'Agent not working right? Help fix it by submitting a report.', 'agenistrator' ), ); // Render the template // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Template system outputs pre-escaped HTML; all $attributes values are escaped above echo String_Utils::render( 'chat', $attributes ); } /** * AJAX handler for sending a chat message * @since 1.0.0 */ public function ajax_send_message() { // Verify nonce check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } // Get user message // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Intentional: unsanitized to preserve user's markdown/HTML formatting. Client-side rendering uses marked.js + DOMPurify. UTF-8 validated. $message = isset( $_REQUEST['message'] ) ? wp_check_invalid_utf8( wp_unslash( $_REQUEST['message'] ), true ) : ''; if ( empty( $message ) ) { throw new Ajax_Exception( __( 'No message provided', 'agenistrator' ), 400 ); } // Get thread once $thread = $this->get_thread(); // Check if thread is already processing // TODO: There is a small race window between this check and sending ACTION_PROCESSING. // Consider using database locking for atomic check-and-set if this becomes an issue. $event_queue = Event_Queue::get_instance(); if ( $event_queue->is_thread_processing( $thread->get_id() ) ) { throw new Ajax_Exception( __( 'Agent is currently processing. Please wait for the current request to complete.', 'agenistrator' ), 409, 'thread_busy' ); } // Create a User_Message instance with per-message relevant information $user_message = new User_Message( $message, array( 'relevant_information' => $this->build_user_message_relevant_information( $message ), ) ); $user_message_id = Chat_Log_Manager::get_instance()->add_message( $thread, $user_message ); if ( ! $user_message_id ) { throw new Ajax_Exception( __( 'Failed to save user message', 'agenistrator' ), 500 ); } // Validate that a chat model is configured if ( ! LLM_Manager::get_instance()->is_chat_model_configured() ) { // Create a markdown response with a link to the settings page $settings_url = admin_url( 'admin.php?page=agenistrator-settings' ); $response_message = String_Utils::replace_tags( __( "I'm sorry, but no chat model is configured. Please [configure a provider and a chat model in settings]({settings_url}).", 'agenistrator' ), array( 'settings_url' => $settings_url ) ); // Create an Assistant_Message instance and store in database $assistant_message = new Assistant_Message( $response_message ); Chat_Log_Manager::get_instance()->add_message( $thread, $assistant_message ); // Send ready event to reset UI state (frontend sets processing before AJAX call) $this->send_control_event( Events\Control_Event::ACTION_READY ); wp_send_json_success(); return; } // Send control event to set processing state $this->send_control_event( Events\Control_Event::ACTION_PROCESSING ); $this->run_agent_loop_and_handle_result(); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * Run agent loop and handle the result * * Shared logic for ajax_send_message and ajax_resume_agent_loop */ private function run_agent_loop_and_handle_result() { try { // Run the agent loop $loop_result = $this->run_agent_loop(); // Handle loop result switch ( $loop_result['status'] ) { case self::LOOP_TIMEOUT: // Keep processing state - frontend will resume wp_send_json_success( array( 'status' => 'timeout' ) ); break; case self::LOOP_RATE_LIMIT: // Keep processing state - frontend will resume after wait wp_send_json_success( array( 'status' => 'rate_limit', 'wait_time' => $loop_result['wait_time'], ) ); break; case self::LOOP_COMPLETE: case self::LOOP_STOPPED: default: // Send control event to set ready state $this->send_control_event( Events\Control_Event::ACTION_READY ); wp_send_json_success( array( 'status' => 'complete' ) ); break; } } catch ( \Throwable $e ) { // Push error event to the UI $error_event = new Events\Thread_Error_Event( $this->get_user_id(), $this->get_thread()->get_id(), $e->getMessage(), 500 ); $error_event->push(); // Send control event to set ready state $this->send_control_event( Events\Control_Event::ACTION_READY ); wp_send_json_error( $e->getMessage(), 500 ); } } /** * Run the agent loop * * This method implements an agent loop where the LLM can use tools iteratively * until it decides to provide a final response without tool calls. * * @return array Loop result with 'status' and optional 'wait_time' * @throws \Exception If an unrecoverable error occurs */ private function run_agent_loop() { // Allow script to continue after client disconnect so we can detect and handle it gracefully ignore_user_abort( true ); $user_id = $this->get_user_id(); $thread = $this->get_thread(); // Initialize last event ID to current position for stop detection $this->last_event_id = Event_Queue::get_instance()->get_latest_event_id( $user_id ); // Load persisted state or use defaults $loop_state = $thread->get_loop_state(); $ttl = $loop_state['ttl'] ?? self::ITERATIONS_BEFORE_WATCHDOG; $iteration = $loop_state['iteration'] ?? 0; $consecutive_empty_responses = $loop_state['consecutive_empty_responses'] ?? 0; // Prepare initial chat context ['chat_model' => $chat_model, 'context' => $context] = $this->prepare_chat_context(); $current_model_id = LLM_Manager::get_instance()->get_chat_model_id(); while ( true ) { ++$iteration; --$ttl; // Detect chat model changes and rebuild context if needed $configured_model_id = LLM_Manager::get_instance()->get_chat_model_id(); if ( $configured_model_id !== $current_model_id ) { ['chat_model' => $chat_model, 'context' => $context] = $this->prepare_chat_context(); $current_model_id = $configured_model_id; } // Refresh available tools at each iteration (may have changed via import/unimport) $context->set_available_tools( $this->get_available_tools() ); // Set context-wide relevant information (cached, only updates if changed) $context->set_relevant_information( $this->build_context_relevant_information() ); // Prune context once per iteration (used for rate limiting and LLM call) $context = Context_Pruner::get_instance()->prune( $context, $chat_model ); $token_data = $context->count_tokens(); // Check rate limit before LLM call $estimated_output = Model_Info::get_instance()->get_default_max_output(); $estimated_total = $token_data['total'] + $estimated_output; $wait_time = Rate_Limiter::get_instance()->get_wait_time( $chat_model, $estimated_total ); if ( $wait_time > 0 ) { // Save state and return rate limit status $this->save_loop_state( $ttl + 1, $iteration - 1, $consecutive_empty_responses ); // Send control event for UI synchronization $this->send_control_event( Events\Control_Event::ACTION_RATE_LIMIT_WAIT, array( 'wait_time' => $wait_time ) ); return array( 'status' => self::LOOP_RATE_LIMIT, 'wait_time' => (int) ceil( $wait_time ), ); } // Check if we should stop due to problematic conditions if ( $ttl <= 0 ) { $watchdog_message = $this->run_watchdog( $chat_model, $context, $iteration ); if ( null !== $watchdog_message ) { $stop_message = new Assistant_Message( $watchdog_message ); Chat_Log_Manager::get_instance()->add_message( $thread, $stop_message ); $this->clear_loop_state(); return array( 'status' => self::LOOP_COMPLETE ); } else { $ttl = self::ITERATIONS_BEFORE_WATCHDOG; // Check if user requested stop if ( $this->is_stopped() ) { $this->clear_loop_state(); return array( 'status' => self::LOOP_STOPPED ); } } } // Run a single agent iteration $iteration_result = $this->run_agent_iteration( $chat_model, $context ); // Handle iteration result if ( self::ITERATION_STOP === $iteration_result ) { // Final response received, we're done $this->clear_loop_state(); return array( 'status' => self::LOOP_COMPLETE ); } elseif ( self::ITERATION_EMPTY === $iteration_result ) { // Empty response with no tool calls - track consecutive occurrences ++$consecutive_empty_responses; if ( $consecutive_empty_responses >= self::MAX_CONSECUTIVE_EMPTY_RESPONSES ) { // Too many consecutive empty responses - likely a provider issue $error_response = __( 'The LLM is not responding. This may be due to a temporary issue with the LLM provider. Please try again later or consider switching to a different model.', 'agenistrator' ); $error_message = new Assistant_Message( $error_response ); Chat_Log_Manager::get_instance()->add_message( $thread, $error_message ); $this->clear_loop_state(); return array( 'status' => self::LOOP_COMPLETE ); } // Continue loop to retry } else { // Tool calls processed, reset empty response counter $consecutive_empty_responses = 0; } // Check execution time at the end of each iteration to ensure progress before yielding $available_time = PHP_Utils::get_available_execution_time( 0.5, 15, 300 ); if ( $available_time <= 15 ) { // Save state and return timeout status $this->save_loop_state( $ttl, $iteration, $consecutive_empty_responses ); return array( 'status' => self::LOOP_TIMEOUT ); } } } /** * Save loop state for resume * * @param int $ttl Current TTL value * @param int $iteration Current iteration count * @param int $consecutive_empty_responses Current consecutive empty response count */ private function save_loop_state( $ttl, $iteration, $consecutive_empty_responses ) { $this->get_thread()->set_loop_state( array( 'ttl' => $ttl, 'iteration' => $iteration, 'consecutive_empty_responses' => $consecutive_empty_responses, ) ); } /** * Clear loop state */ private function clear_loop_state() { $this->get_thread()->clear_loop_state(); } /** * Run a single agent iteration * * @param LLM_Chat $chat_model The chat model instance * @param Context $context The conversation context * @return int ITERATION_STOP, ITERATION_EMPTY, or ITERATION_CONTINUE */ private function run_agent_iteration( $chat_model, $context ) { // Check if user requested stop before starting iteration if ( $this->is_stopped() ) { return self::ITERATION_STOP; } // Create a new message for this iteration $message_id = Chat_Log_Manager::get_instance()->create_message( $this->get_thread(), 'assistant' ); $tool_calls = array(); $response = ''; $has_error = false; $was_stopped = false; $chunk_sequence = 0; // Create streaming callback for this iteration $stream_callback = $this->create_stream_callback( $response, $tool_calls, $message_id, $chunk_sequence, $has_error, $was_stopped ); // Generate streaming completion (token usage is recorded by the chat model) $success = $chat_model->generate_streaming_completion( $context, $stream_callback ); // If user stopped during streaming, finalize the partial message (may have useful content) if ( $was_stopped ) { $this->finalize_iteration( $message_id, $response, $context ); return self::ITERATION_STOP; } // If there was an error during streaming if ( $has_error || ! $success ) { $error_response = '' !== $response ? $response : __( 'An error occurred during processing.', 'agenistrator' ); $this->finalize_iteration( $message_id, $error_response, $context ); return self::ITERATION_STOP; } // Send final stream chunk event to indicate completion $final_stream_event = new Events\Stream_Chunk_Event( $this->get_user_id(), $this->get_thread()->get_id(), $message_id, '', $chunk_sequence, true ); $final_stream_event->push(); // Determine iteration outcome $have_empty_response = empty( trim( $response ) ); $have_tool_calls = ! empty( $tool_calls ); // Handle the response message if ( $have_empty_response ) { // Delete empty message (whether we have tool calls or not) Chat_Log_Manager::get_instance()->delete_message( $message_id ); } else { // Finalize message with content and add to context $this->finalize_iteration( $message_id, $response, $context ); } // Process tool calls if ( $have_tool_calls ) { return $this->process_tool_calls( $tool_calls, $context ); } // Empty response with no tool calls - signal for potential retry if ( $have_empty_response ) { return self::ITERATION_EMPTY; } // Non-empty response with no tool calls - final response return self::ITERATION_STOP; } /** * Finalize a single agent iteration * * @param Context $context The conversation context to add the message to * @param int $message_id The message ID * @param string $response The complete response content */ private function finalize_iteration( $message_id, $response, $context ) { // Final update to ensure the message is complete in chat log Chat_Log_Manager::get_instance()->update_message( $message_id, $response, time() ); // Create an Assistant_Message instance for the complete response $assistant_message = new Assistant_Message( $response, null, null, $message_id ); // Add the assistant message to context for next iteration $context->add_message( $assistant_message ); // Create a message event for the complete assistant response $assistant_message_event = new Events\Message_Event( $this->get_user_id(), $this->get_thread()->get_id(), $assistant_message, true ); $assistant_message_event->push(); } /** Watchdog system message describing the watchdog role */ private const WATCHDOG_SYSTEM_MESSAGE = "You are a watchdog monitoring an AI assistant conversation for pathological behavior. Your role is to transparently allow the conversation to continue UNLESS you detect critical issues: 1. **Infinite Loop**: The assistant repeats actions without progress - Same tool calls with identical arguments appearing multiple times - Circular reasoning patterns - Same errors recurring without different approaches 2. **Stuck State**: The assistant appears unable to proceed - Repeatedly apologizing without taking action - Making the same failed attempt multiple times - No meaningful progress despite multiple iterations 3. **Uncharacteristic Behavior**: The assistant is behaving abnormally - Responses unrelated to WordPress assistance - Harmful, offensive, or completely off-topic content - Confused or incoherent responses Be PERMISSIVE by default. Only recommend stopping if you detect CLEAR evidence of the above issues. Normal tool usage, multi-step tasks, and error recovery attempts should continue. If everything looks fine, answer exactly '1' to continue. If you detect issues, explain VERY briefly what you observed and ask the user how they would like to proceed."; /** Maximum recent messages to include in watchdog analysis */ private const WATCHDOG_MAX_MESSAGES = 25; /** * Detect if the agent loop should stop due to problematic conditions * * @param LLM_Chat $chat_model The chat model instance * @param Context $context The current context * @param int $iteration Current iteration number * @return string|null Null to continue, or a message explaining why the loop should stop */ private function run_watchdog( $chat_model, $context, $iteration ) { try { $messages = $context->get_messages(); // Compute tool call statistics $tool_call_counts = array(); foreach ( $messages as $message ) { if ( $message instanceof Tool_Call_Message ) { $signature = $message->get_tool_id() . ':' . \wp_json_encode( $message->get_arguments() ); $hash = md5( $signature ); $tool_call_counts[ $hash ] = ( $tool_call_counts[ $hash ] ?? 0 ) + 1; } } $total_tool_calls = array_sum( $tool_call_counts ); $unique_tool_calls = count( $tool_call_counts ); $repeated_tool_calls = count( array_filter( $tool_call_counts, fn( $c ) => $c >= 3 ) ); // Create watchdog-specific context with custom system message $watchdog_context = $chat_model->create_context( self::WATCHDOG_SYSTEM_MESSAGE ); // Copy recent messages from original context $recent_messages = array_slice( $messages, -self::WATCHDOG_MAX_MESSAGES ); foreach ( $recent_messages as $message ) { $watchdog_context->add_message( $message ); } // Add analysis prompt at the end $analysis_prompt = String_Utils::replace_tags( "Analyze the above conversation for pathological behavior.\n\n" . "Statistics:\n" . "- Iteration: {iteration}\n" . "- Total tool calls: {total_tool_calls}\n" . "- Unique tool call signatures: {unique_tool_calls}\n" . "- Repeated tool calls (same args 3+ times): {repeated_tool_calls}\n\n" . "If everything looks fine, answer exactly '1' to continue.\n" . "If you detect issues, explain VERY briefly what you observed and ask the user how they would like to proceed.\n" . "Refer to the agent as 'I' or 'me' (first person) and refer to the user as 'you' (second person).", array( 'iteration' => $iteration, 'total_tool_calls' => $total_tool_calls, 'unique_tool_calls' => $unique_tool_calls, 'repeated_tool_calls' => $repeated_tool_calls, ) ); $watchdog_context->add_user_message( $analysis_prompt ); // Prune context if too large $watchdog_context = Context_Pruner::get_instance()->prune( $watchdog_context, $chat_model ); // Get watchdog verdict (sync, no streaming) $response = $chat_model->generate_sync_completion( $watchdog_context ); $content = isset( $response['content'] ) ? trim( $response['content'] ) : ''; // Return null (continue) if LLM says 1, otherwise return the explanation message return '1' === $content ? null : $content; } catch ( \Throwable $e ) { // Log error // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production error logging for watchdog failures error_log( String_Utils::replace_tags( __( 'Agenistrator: Watchdog failed: {error}', 'agenistrator' ), array( 'error' => $e->getMessage() ) ) ); // Allow continuation (be permissive) return null; } } /** * Create a stream callback function for the current iteration * * @param string &$response Reference to accumulate response for this iteration * @param array &$tool_calls Reference to collect tool calls * @param int $message_id The message ID for events * @param int &$chunk_sequence Reference to track chunk sequence * @param bool &$has_error Reference to track error state * @param bool &$was_stopped Reference to track if user requested stop * @return callable The streaming callback function that returns bool (true to continue, false to stop) */ private function create_stream_callback( &$response, &$tool_calls, $message_id, &$chunk_sequence, &$has_error, &$was_stopped ) { return function ( $data ) use ( &$response, &$tool_calls, $message_id, &$chunk_sequence, &$has_error, &$was_stopped ) { // Check if user requested stop during streaming if ( $this->is_stopped() ) { $was_stopped = true; return false; // Signal to stop streaming } if ( ! isset( $data['type'] ) ) { return true; // Continue streaming } switch ( $data['type'] ) { case 'content': // Regular content token $response .= $data['content']; Chat_Log_Manager::get_instance()->update_message( $message_id, $response, time() ); // Create stream chunk event $stream_event = new Events\Stream_Chunk_Event( $this->get_user_id(), $this->get_thread()->get_id(), $message_id, $data['content'], $chunk_sequence, false ); $stream_event->push(); // Increment chunk sequence for next chunk ++$chunk_sequence; break; case 'tool_call_complete': if ( isset( $data['tool_call'] ) && $data['tool_call'] instanceof Tool_Call ) { $tool_calls[] = $data['tool_call']; } break; case 'error': $has_error = true; $error_message = isset( $data['message'] ) ? $data['message'] : __( 'Unknown error', 'agenistrator' ); $response = $error_message; // Send error event with message_id for message-related errors $error_event = new Events\Thread_Error_Event( $this->get_user_id(), $this->get_thread()->get_id(), $error_message, 500, array(), // details $message_id // message_id for message-related errors ); $error_event->push(); break; } return true; // Continue streaming }; } /** * Get tools available for context (definitions sent to LLM) * * Returns always-imported tools (core) + session-imported tools. * * @return array Array of tool instances */ private function get_available_tools() { $tool_manager = Tool_Manager::get_instance(); // Start with always-imported tools (core tools) $tools = $tool_manager->get_always_imported_tools(); // Add imported tools for this thread $imported_ids = $this->get_thread()->get_imported_tools(); foreach ( $imported_ids as $tool_id ) { $tool = $tool_manager->get_tool( $tool_id ); if ( $tool ) { $tools[ $tool_id ] = $tool; } } return $tools; } /** * Process tool calls through their complete lifecycle * * @param array $tool_calls Array of Tool_Call instances * @param Context $context The conversation context to update * @return int ITERATION_STOP if stopped or LLM terminated, ITERATION_CONTINUE otherwise */ private function process_tool_calls( $tool_calls, $context ) { $thread = $this->get_thread(); foreach ( $tool_calls as $tool_call ) { // Check if user requested stop before each tool execution if ( $this->is_stopped() ) { return self::ITERATION_STOP; } $call_id = $tool_call->get_call_id(); // Create and save tool call message to database BEFORE execution $tool_call_message = new Tool_Call_Message( $tool_call->get_tool_id(), $tool_call->get_arguments(), $call_id ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- $tool_call_message_id intentionally unused, reserved for future use $tool_call_message_id = Chat_Log_Manager::get_instance()->add_message( $thread, $tool_call_message ); // Add tool call message to context BEFORE execution $context->add_message( $tool_call_message ); // Execute the tool call try { $tool_call->execute(); // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Error state is captured in Tool_Call object; we continue to process the result } catch ( \Throwable $e ) { // Tool execution failed - error is already set in Tool_Call // Continue to process the result } // Tool result handled by permanent message events // Create and save tool result message to database if executed if ( $tool_call->is_executed() ) { $tool_result_message = new Tool_Result_Message( $call_id, $tool_call->get_tool_id(), $tool_call->get_result(), $tool_call->is_success() ); Chat_Log_Manager::get_instance()->add_message( $thread, $tool_result_message ); // Add tool result message to context $context->add_message( $tool_result_message ); } // Check if tool signaled termination via Chat::signal_termination() if ( $this->is_terminated() ) { return self::ITERATION_STOP; } } return self::ITERATION_CONTINUE; } /** * Prepare the context for a chat session * * @return array Context data with 'chat_model' and 'context' keys * @throws \Exception If chat model creation fails */ private function prepare_chat_context() { $chat_model = LLM_Manager::get_instance()->get_chat_model(); $context = $chat_model->create_context(); $context->load_persistent_messages( $this->get_thread() ); return array( 'chat_model' => $chat_model, 'context' => $context, ); } /** * Build context-wide relevant information (injected after system message, rarely changes) * * @return string Formatted context-wide relevant information */ private function build_context_relevant_information() { $info = array(); // Build user-controlled WordPress data $wp_data = array(); // Site information $wp_data[] = String_Utils::replace_tags( __( 'Site: {title} ({url})', 'agenistrator' ), array( 'title' => get_bloginfo( 'name' ), 'url' => home_url(), ) ); $tagline = get_bloginfo( 'description' ); if ( ! empty( $tagline ) ) { $wp_data[] = String_Utils::replace_tags( __( 'Tagline: {tagline}', 'agenistrator' ), array( 'tagline' => $tagline ) ); } $wp_data[] = String_Utils::replace_tags( __( 'Site Language/Locale: {locale}', 'agenistrator' ), array( 'locale' => get_locale() ) ); // Current user $user = wp_get_current_user(); $name_parts = array_filter( array( $user->first_name, $user->last_name ) ); $name = ! empty( $name_parts ) ? implode( ' ', $name_parts ) : $user->display_name; $wp_data[] = String_Utils::replace_tags( __( 'Current User: {name} (@{username}), {email}', 'agenistrator' ), array( 'name' => $name, 'username' => $user->user_login, 'email' => $user->user_email, ) ); $wp_data[] = String_Utils::replace_tags( __( 'User Language/Locale: {locale}', 'agenistrator' ), array( 'locale' => get_user_locale( $user->ID ) ) ); // Use persistent content guard for token caching optimization $guard = $this->get_thread()->get_content_guard(); $info[] = Content_Guard::wrap( implode( "\n", $wp_data ), $guard ); // Semantic search warning (not user-controlled, no content guard needed) if ( ! Embeddings_Manager::get_instance()->is_search_operational() ) { $info[] = __( "Note: Semantic search is not currently operational. You may lack access to site content relevant to the user's request. Consider mentioning this if content-specific questions arise.", 'agenistrator' ); } return implode( "\n\n", $info ); } /** * Build per-message relevant information (attached to user message, changes per message) * * @param string $message The user message content * @return string Formatted per-message relevant information */ // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- $message intentionally unused, reserved for future use private function build_user_message_relevant_information( $message ) { $info = array(); // Date/time with timezone (system-provided, no content guard needed) $tz = wp_timezone(); $now = new \DateTime( 'now', $tz ); $tz_name = $tz->getName(); $tz_offset = $now->format( 'P' ); $timezone = ( $tz_name !== $tz_offset ) ? String_Utils::replace_tags( __( '{tz_name}, {tz_offset}', 'agenistrator' ), array( 'tz_name' => $tz_name, 'tz_offset' => $tz_offset, ) ) : $tz_name; $info[] = String_Utils::replace_tags( __( 'Current Date/Time: {datetime} ({timezone})', 'agenistrator' ), array( 'datetime' => current_time( 'Y-m-d H:i:s' ), 'timezone' => $timezone, ) ); return implode( "\n", $info ); } /** Maximum seconds to wait for ready event after stop */ private const STOP_READY_WAIT_SECONDS = 5; /** * AJAX handler for stopping the agent loop * @since 1.0.0 */ public function ajax_stop_agent_loop() { // Check nonce check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } $this->stop_agent_loop_and_wait_for_ready(); wp_send_json_success(); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * Stop the agent loop and ensure ready state * * Sends stop event, clears loop state, and waits for ready event from * the agent loop. If no ready event is received within timeout, sends * ready event ourselves to ensure thread doesn't get stuck. */ private function stop_agent_loop_and_wait_for_ready() { $user_id = $this->get_user_id(); $thread = $this->get_thread(); $event_queue = Event_Queue::get_instance(); // Capture event position before sending any events (to avoid race conditions) $last_event_id = $event_queue->get_latest_event_id( $user_id ); // Send rate limit resume event if rate limit wait is active (for multi-tab UI sync) if ( $event_queue->is_rate_limit_active( $thread->get_id() ) ) { $this->send_control_event( Events\Control_Event::ACTION_RATE_LIMIT_RESUME ); } // Send stop control event $this->send_control_event( Events\Control_Event::ACTION_STOP ); // Clear any persisted loop state $this->clear_loop_state(); // Wait for ready event from agent loop (if running), or send it ourselves if ( ! $this->wait_for_ready_event( self::STOP_READY_WAIT_SECONDS, $last_event_id ) ) { $this->send_control_event( Events\Control_Event::ACTION_READY ); } } /** * Wait for ACTION_READY event for the current thread * * Polls the event queue for a ready event. Used by stop handler to wait * for the agent loop to finish gracefully before sending ready ourselves. * * @param int $max_wait_seconds Maximum seconds to wait. * @param int $last_event_id Event ID to start polling from. * @return bool True if ready event received, false if timeout. */ private function wait_for_ready_event( $max_wait_seconds, $last_event_id ) { $user_id = $this->get_user_id(); $thread_id = $this->get_thread()->get_id(); $event_queue = Event_Queue::get_instance(); $start_time = time(); while ( time() - $start_time < $max_wait_seconds ) { sleep( 1 ); $events = $event_queue->get_events( $user_id, $last_event_id ); foreach ( $events as $event ) { if ( ! $event instanceof Events\Control_Event ) { continue; } if ( $event->get_thread_id() !== $thread_id ) { continue; } if ( $event->get_action() === Events\Control_Event::ACTION_READY ) { return true; } } // Update position for next poll if ( ! empty( $events ) ) { $last_event = end( $events ); $last_event_id = $last_event->get_id(); } } return false; } /** * AJAX handler for resuming the agent loop after timeout or rate limit * @since 1.0.0 */ public function ajax_resume_agent_loop() { // Check nonce check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } $thread_id = $this->get_thread()->get_id(); // Verify thread is in processing state (resume is only valid during processing) $event_queue = Event_Queue::get_instance(); if ( ! $event_queue->is_thread_processing( $thread_id ) ) { throw new Ajax_Exception( __( 'Cannot resume: thread is not processing.', 'agenistrator' ), 400, 'not_processing' ); } // Send rate limit resume event if resuming from rate limit (for multi-tab UI sync) if ( $event_queue->is_rate_limit_active( $thread_id ) ) { $this->send_control_event( Events\Control_Event::ACTION_RATE_LIMIT_RESUME ); } $this->run_agent_loop_and_handle_result(); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * Check if the agent loop should be stopped * * @return bool True if stop was requested, false otherwise */ private function is_stopped() { // Check if client disconnected (page closed/navigated away) // flush() forces PHP to attempt output, which detects broken connections // However, this may not be enough and `print(' '); ob_flush();` may be necessary flush(); $thread = $this->get_thread(); if ( connection_aborted() ) { $stop_response = __( 'Aborted.', 'agenistrator' ); $stop_message = new Assistant_Message( $stop_response ); Chat_Log_Manager::get_instance()->add_message( $thread, $stop_message ); return true; } try { $user_id = $this->get_user_id(); $thread_id = $thread->get_id(); // Check for new events since our last position $events = Event_Queue::get_instance()->get_events( $user_id, $this->last_event_id // only events after our last position ); // Look for stop control events for this thread $found_stop = false; foreach ( $events as $event ) { if ( ! $event instanceof Events\Control_Event ) { continue; } if ( $event->get_thread_id() !== $thread_id ) { continue; } if ( $event->get_action() === Events\Control_Event::ACTION_STOP ) { $found_stop = true; break; } } // Update our position to the last event ID (events are sorted by ID ascending) if ( ! empty( $events ) ) { $last_event = end( $events ); $this->last_event_id = $last_event->get_id(); } // If we found a stop event, add the stop message if ( $found_stop ) { $stop_response = __( 'Stopped by user.', 'agenistrator' ); $stop_message = new Assistant_Message( $stop_response ); Chat_Log_Manager::get_instance()->add_message( $thread, $stop_message ); } return $found_stop; } catch ( \Throwable $e ) { // If we can't check, assume not stopped return false; } } /** * Signal that the LLM wants to terminate the agent loop * * Called by tools (e.g., Terminate_Tool) to request termination. * @since 1.0.0 */ public function signal_termination() { $this->llm_terminated = true; } /** * Check if the LLM has requested termination * * @return bool True if termination was requested, false otherwise */ private function is_terminated() { return $this->llm_terminated; } /** * AJAX handler for clearing chat log * @since 1.0.0 */ public function ajax_clear_chat_log() { // Check nonce check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } // get_thread() will call get_user_id() which validates user is logged in $thread = $this->get_thread(); // Stop the agent loop if it's running (waits for ready to avoid race conditions) if ( Event_Queue::get_instance()->is_thread_processing( $thread->get_id() ) ) { $this->stop_agent_loop_and_wait_for_ready(); } // Delete all messages Chat_Log_Manager::get_instance()->delete_all_messages( $thread ); // Clear thread state (imported tools, etc.) $thread->clear(); // Add welcome message after deleting all messages Chat_Log_Manager::get_instance()->add_welcome_message( $thread ); wp_send_json_success(); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * AJAX handler for reporting an agent issue * @since 1.0.0 */ public function ajax_report_issue() { // Check nonce check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } $user_id = $this->get_user_id(); $thread = $this->get_thread(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Report description is serialised to JSON inside a ZIP for developer diagnostics; never rendered as HTML by this plugin. UTF-8 validated. $description = isset( $_POST['description'] ) ? wp_check_invalid_utf8( wp_unslash( $_POST['description'] ), true ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Boolean flag compared to literal, no sanitization needed. $send_email = isset( $_POST['send_email'] ) && '1' === wp_unslash( $_POST['send_email'] ); $reporter = Reporter::get_instance(); $report_id = $reporter->generate_report( $user_id, $thread->get_id(), $description ); if ( $send_email ) { $reporter->send_report( $report_id ); } wp_send_json_success( array( 'report_id' => $report_id ) ); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * Get the current user ID * * @return int The current user ID * @throws \Exception If user is not logged in */ private function get_user_id() { if ( null === $this->user_id ) { $this->user_id = get_current_user_id(); } if ( ! $this->user_id ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'You must be logged in to use the chat.', 'agenistrator' ) ); } return $this->user_id; } /** * Get the current thread * * @return Thread The current thread instance */ private function get_thread() { if ( null === $this->thread ) { $this->thread = Thread_Manager::get_instance()->get_or_create_thread( $this->get_user_id() ); } return $this->thread; } /** * Send a control event to synchronize chat state across browser windows/tabs * * @param string $action The control action ('processing', 'ready', 'clear') * @param array $params Additional parameters (optional) */ private function send_control_event( $action, $params = array() ) { $control_event = new Events\Control_Event( $this->get_user_id(), $this->get_thread()->get_id(), $action, $params ); $control_event->push(); } }