*/ private $usage = []; /** @var array> */ private $sources = []; /** @var int */ private $round_count = 0; public function __construct( ?Client $client = null, ?WTeam_Executor_Tool_Registry $registry = null ) { $this->client = $client ? $client : WTeam_Executor_AI_Client_Factory::create(); $this->registry = $registry ? $registry : new WTeam_Executor_Tool_Registry(); } /** * @param string $model * @param array $context * @param string $image_data_url * @param int|null $max_rounds * @return array|WP_Error */ public function run( $model, array $context, $image_data_url = '', $max_rounds = null ) { $settings = WTeam_Executor_Settings::get_settings(); $max_rounds = WTeam_Executor_Settings::normalize_max_rounds( null === $max_rounds ? $settings['max_rounds'] : $max_rounds ); $ask_to_continue = ! empty( $settings['ask_to_continue'] ); $display_round_limit = isset( $context['task_round_budget']['max_rounds'] ) ? WTeam_Executor_Settings::normalize_max_rounds( $context['task_round_budget']['max_rounds'] ) : $max_rounds; $this->usage = []; $this->sources = []; $this->round_count = 0; $context['backend_tools'] = $this->registry->capabilities(); $context['wteam_executor_controls'] = WTeam_Executor_Settings::assistant_control_catalog( $settings, isset( $context['automation']['auto_actions'] ) ? (bool) $context['automation']['auto_actions'] : null ); $context['tool_trace'] = $this->bounded_tool_trace( isset( $context['tool_trace'] ) && is_array( $context['tool_trace'] ) ? $context['tool_trace'] : [] ); $seen_calls = []; $last_decision = null; $format_recovery_attempted = false; $backend_followup = false; do_action( 'wteam_executor_assistant_run_started', $context ); for ( $round = 0; $round < $max_rounds; ++$round ) { $context['execution_budget'] = [ 'current_round' => $round + 1, 'max_backend_tool_rounds' => $max_rounds, 'remaining_backend_tool_rounds' => $max_rounds - $round, ]; ++$this->round_count; $round_image = 0 === $round ? $image_data_url : ''; $request_context = $backend_followup ? $this->backend_followup_context( $context ) : $context; $decision = $this->client->assistant_step( $model, $request_context, $round_image ); $this->merge_usage( $this->client->get_last_usage() ); $this->merge_sources( $this->client->get_last_web_sources() ); if ( is_wp_error( $decision ) ) { $error_code = (string) $decision->get_error_code(); $recoverable_response_error = in_array( $error_code, [ 'wteam_executor_invalid_assistant_response', 'wteam_executor_incomplete_assistant_response', 'wteam_executor_empty_assistant_response' ], true ); if ( $recoverable_response_error && ! $format_recovery_attempted && $round < $max_rounds - 1 ) { $format_recovery_attempted = true; $backend_followup = false; $context['response_recovery'] = [ 'previous_error' => $error_code, 'instruction' => 'The previous model generation did not produce a complete Executor controller object. Re-evaluate the CURRENT goal from the same live evidence and return only the required controller schema. Do not echo Elementor/editor clipboard JSON, network payloads, HTML, or unrelated raw page data.', ]; continue; } if ( $recoverable_response_error && $round === $max_rounds - 1 ) { $continuation_state = $this->limit_text( trim( (string) ( isset( $context['state_summary'] ) ? $context['state_summary'] : '' ) ) . "\nContinuation checkpoint: the final AI generation was incomplete, so no action was accepted. Re-evaluate the current live evidence.", self::MAX_STATE_SUMMARY_CHARS ); return $this->round_limit_navigation( $display_round_limit, $ask_to_continue, $continuation_state ); } return $decision; } if ( isset( $context['response_recovery'] ) ) { unset( $context['response_recovery'] ); } $decision = $this->normalize_decision( $decision ); $last_decision = $decision; if ( 'tool' !== $decision['status'] ) { if ( $round === $max_rounds - 1 && 'blocked' === $decision['status'] ) { $continuation_state = $this->limit_text( trim( (string) $decision['state_summary'] ) . "\nContinuation checkpoint: the last decision reported a blocker without proving completion. Re-read the current live evidence.", self::MAX_STATE_SUMMARY_CHARS ); return $this->round_limit_navigation( $display_round_limit, $ask_to_continue, $continuation_state ); } return $this->to_navigation( $decision ); } $backend_followup = true; $tool_name = isset( $decision['tool']['name'] ) ? sanitize_key( (string) $decision['tool']['name'] ) : ''; $args_json = isset( $decision['tool']['arguments_json'] ) ? (string) $decision['tool']['arguments_json'] : '{}'; $arguments = json_decode( $args_json, true ); if ( ! is_array( $arguments ) ) { $this->append_tool_trace( $context, [ 'tool' => $tool_name, 'ok' => false, 'result' => [ 'error' => 'Tool arguments_json was not a valid JSON object.' ] ] ); continue; } if ( '' === $tool_name || 'none' === $tool_name ) { return $this->to_navigation( [ 'status' => 'blocked', 'message' => __( 'The assistant requested an invalid backend tool.', 'wteam-executor' ), 'state_summary' => $decision['state_summary'], 'requires_confirmation' => false, 'actions' => [], 'action' => [ 'type' => 'none', 'element_id' => '', 'target_element_id' => '', 'value' => '' ], 'tool' => [ 'name' => 'none', 'arguments_json' => '{}' ], ] ); } $precheck = apply_filters( 'wteam_executor_backend_tool_precheck', true, $tool_name, $arguments, $context, $round + 1 ); if ( is_wp_error( $precheck ) ) { $trace_result = [ 'error_code' => $precheck->get_error_code(), 'error' => $precheck->get_error_message() ]; $this->append_tool_trace( $context, [ 'round' => $round + 1, 'tool' => $tool_name, 'ok' => false, 'result' => $trace_result ] ); $context['state_summary'] = $decision['state_summary']; if ( $round === $max_rounds - 1 ) { return $this->round_limit_navigation( $display_round_limit, $ask_to_continue, $this->round_limit_state_summary( $decision, $trace_result, false ) ); } continue; } $call_hash = hash( 'sha256', $tool_name . "\n" . wp_json_encode( $arguments ) ); if ( isset( $seen_calls[ $call_hash ] ) ) { $this->append_tool_trace( $context, [ 'tool' => $tool_name, 'ok' => false, 'result' => [ 'error' => 'The same tool call was already attempted in this request. Do not repeat it.' ] ] ); continue; } $seen_calls[ $call_hash ] = true; $tool_result = $this->registry->execute( $tool_name, $arguments ); if ( is_wp_error( $tool_result ) ) { $trace_result = [ 'error_code' => $tool_result->get_error_code(), 'error' => $tool_result->get_error_message() ]; $ok = false; } else { $trace_result = $this->bounded_tool_result( $tool_result ); $ok = true; } $this->append_tool_trace( $context, [ 'round' => $round + 1, 'tool' => $tool_name, 'ok' => $ok, 'result' => $trace_result ] ); do_action( 'wteam_executor_backend_tool_executed', $tool_name, $trace_result, $ok, $context ); $context['state_summary'] = $decision['state_summary']; if ( $round === $max_rounds - 1 ) { return $this->round_limit_navigation( $display_round_limit, $ask_to_continue, $this->round_limit_state_summary( $decision, $trace_result, $ok ) ); } } $fallback_summary = is_array( $last_decision ) && ! empty( $last_decision['state_summary'] ) ? $this->limit_text( (string) $last_decision['state_summary'], self::MAX_STATE_SUMMARY_CHARS ) : $this->limit_text( isset( $context['state_summary'] ) ? (string) $context['state_summary'] : '', self::MAX_STATE_SUMMARY_CHARS ); return $this->round_limit_navigation( $display_round_limit, $ask_to_continue, $fallback_summary ); } /** @return array */ public function get_usage() { return $this->usage; } /** @return int */ public function get_round_count() { return (int) $this->round_count; } /** @return array */ private function normalize_decision( array $decision ) { $decision['status'] = isset( $decision['status'] ) ? sanitize_key( (string) $decision['status'] ) : 'blocked'; $decision['message'] = isset( $decision['message'] ) ? $this->sanitize_user_message( sanitize_textarea_field( (string) $decision['message'] ) ) : ''; $decision['state_summary'] = isset( $decision['state_summary'] ) ? $this->limit_text( sanitize_textarea_field( (string) $decision['state_summary'] ), self::MAX_STATE_SUMMARY_CHARS ) : ''; $decision['requires_confirmation'] = ! empty( $decision['requires_confirmation'] ); $actions = []; if ( isset( $decision['actions'] ) && is_array( $decision['actions'] ) ) { foreach ( array_slice( $decision['actions'], 0, 3 ) as $action ) { if ( is_array( $action ) ) { $actions[] = $action; } } } elseif ( isset( $decision['action'] ) && is_array( $decision['action'] ) && 'none' !== (string) ( isset( $decision['action']['type'] ) ? $decision['action']['type'] : 'none' ) ) { // Backward-compatible normalization for filtered/custom clients still returning one action. $actions[] = $decision['action']; } if ( 'visual_action' !== $decision['status'] ) { $actions = []; } $decision['actions'] = $actions; $decision['action'] = ! empty( $actions ) ? $actions[0] : [ 'type' => 'none', 'element_id' => '', 'target_element_id' => '', 'value' => '' ]; $decision['tool'] = isset( $decision['tool'] ) && is_array( $decision['tool'] ) ? $decision['tool'] : [ 'name' => 'none', 'arguments_json' => '{}' ]; return $decision; } /** * @param string $message * @return string */ private function sanitize_user_message( $message ) { $replacements = array_merge( WTeam_Executor_Tool_Registry::user_facing_replacements(), [ 'assistant_backend_tools_enabled' => __( 'server-side tools', 'wteam-executor' ) ] ); $replacements = apply_filters( 'wteam_executor_user_message_replacements', $replacements ); return strtr( (string) $message, is_array( $replacements ) ? $replacements : [] ); } /** @return array */ private function to_navigation( array $decision ) { $status = isset( $decision['status'] ) ? $decision['status'] : 'blocked'; if ( 'visual_action' === $status ) { $status = 'action'; } if ( 'tool' === $status ) { $status = 'blocked'; } return [ 'status' => $status, 'message' => isset( $decision['message'] ) ? $decision['message'] : '', 'state_summary' => isset( $decision['state_summary'] ) ? $decision['state_summary'] : '', 'requires_confirmation' => ! empty( $decision['requires_confirmation'] ), 'actions' => isset( $decision['actions'] ) && is_array( $decision['actions'] ) ? array_slice( $decision['actions'], 0, 3 ) : [], 'action' => isset( $decision['action'] ) && is_array( $decision['action'] ) ? $decision['action'] : [ 'type' => 'none', 'element_id' => '', 'target_element_id' => '', 'value' => '' ], 'sources' => $this->sources, ]; } /** * @param array $decision * @param mixed $trace_result * @param bool $ok * @return string */ private function round_limit_state_summary( array $decision, $trace_result, $ok ) { $base = isset( $decision['state_summary'] ) ? $this->limit_text( trim( (string) $decision['state_summary'] ), 500 ) : ''; $json = wp_json_encode( $trace_result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); if ( false === $json ) { $json = $ok ? __( 'The last backend operation completed successfully.', 'wteam-executor' ) : __( 'The last backend operation failed.', 'wteam-executor' ); } if ( strlen( $json ) > 1100 ) { $json = $this->limit_text( $json, 1100 ) . '…'; } $checkpoint = trim( $base . "\nContinuation checkpoint: the configured round batch ended immediately after a backend operation. Last operation status: " . ( $ok ? 'success' : 'failure' ) . '. Last bounded result: ' . $json ); return $this->limit_text( $checkpoint, self::MAX_STATE_SUMMARY_CHARS ); } /** * @param int $max_rounds * @param bool $ask_to_continue * @param string $state_summary * @return array */ private function round_limit_navigation( $max_rounds, $ask_to_continue, $state_summary ) { if ( $ask_to_continue ) { return [ 'status' => 'ask_user', /* translators: %d: configured execution round limit. */ 'message' => sprintf( __( 'The configured limit of %d execution rounds was reached before the task was verified as complete. Continue? (Yes) (No)', 'wteam-executor' ), (int) $max_rounds ), 'state_summary' => $state_summary, 'requires_confirmation' => false, 'actions' => [], 'action' => [ 'type' => 'none', 'element_id' => '', 'target_element_id' => '', 'value' => '' ], 'sources' => $this->sources, 'continue_prompt' => true, 'round_limit' => (int) $max_rounds, ]; } return [ 'status' => 'blocked', /* translators: %d: configured execution round limit. */ 'message' => sprintf( __( 'The configured limit of %d execution rounds was reached before the task was verified as complete.', 'wteam-executor' ), (int) $max_rounds ), 'state_summary' => $state_summary, 'requires_confirmation' => false, 'actions' => [], 'action' => [ 'type' => 'none', 'element_id' => '', 'target_element_id' => '', 'value' => '' ], 'sources' => $this->sources, 'continue_prompt' => false, 'round_limit' => (int) $max_rounds, ]; } /** * @param string $text * @param int $max * @return string */ private function limit_text( $text, $max ) { $text = (string) $text; if ( function_exists( 'mb_substr' ) ) { return mb_substr( $text, 0, (int) $max ); } return substr( $text, 0, (int) $max ); } /** @return mixed */ private function bounded_tool_result( $result ) { $json = wp_json_encode( $result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); if ( false === $json ) { return [ 'message' => __( 'Tool completed, but its result could not be encoded.', 'wteam-executor' ) ]; } if ( strlen( $json ) <= self::MAX_TOOL_RESULT_CHARS ) { return $result; } return [ 'message' => __( 'Tool completed successfully. The detailed result exceeded the assistant trace size limit.', 'wteam-executor' ), 'result_preview' => substr( $json, 0, self::MAX_TOOL_RESULT_CHARS ), 'truncated' => true, ]; } /** @return array> */ private function bounded_tool_trace( array $trace ) { $bounded = []; foreach ( array_slice( $trace, -self::MAX_TOOL_TRACE_ENTRIES ) as $entry ) { if ( is_array( $entry ) ) { $bounded[] = $entry; } } while ( count( $bounded ) > 1 ) { $json = wp_json_encode( $bounded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); if ( false === $json || strlen( $json ) <= self::MAX_TOOL_TRACE_CHARS ) { break; } array_shift( $bounded ); } return $bounded; } /** @return void */ private function append_tool_trace( array &$context, array $entry ) { $trace = isset( $context['tool_trace'] ) && is_array( $context['tool_trace'] ) ? $context['tool_trace'] : []; $trace[] = $entry; $context['tool_trace'] = $this->bounded_tool_trace( $trace ); } /** * @param array $context * @return array */ private function backend_followup_context( array $context ) { $followup = $context; if ( isset( $context['current_page'] ) && is_array( $context['current_page'] ) ) { $page = []; foreach ( [ 'screen_id', 'page_slug', 'url_path', 'page_title', 'primary_heading', 'visible_headings', 'active_admin_menu', 'editor_context', 'admin_notices', 'frames' ] as $key ) { if ( ! isset( $context['current_page'][ $key ] ) ) { continue; } $value = $context['current_page'][ $key ]; if ( '' === $value || [] === $value || null === $value ) { continue; } $page[ $key ] = $value; } $followup['current_page'] = $page; } if ( isset( $context['environment']['affected_plugin'] ) && is_array( $context['environment']['affected_plugin'] ) ) { $environment = $context['environment']; $plugin = $context['environment']['affected_plugin']; $compact_plugin = []; foreach ( [ 'plugin_file', 'active', 'network_active' ] as $key ) { if ( array_key_exists( $key, $plugin ) ) { $compact_plugin[ $key ] = $plugin[ $key ]; } } if ( isset( $plugin['header'] ) && is_array( $plugin['header'] ) ) { $header = []; foreach ( [ 'Name', 'Version', 'TextDomain', 'RequiresWP', 'RequiresPHP', 'RequiresPlugins' ] as $key ) { if ( isset( $plugin['header'][ $key ] ) && '' !== (string) $plugin['header'][ $key ] ) { $header[ $key ] = $plugin['header'][ $key ]; } } if ( $header ) { $compact_plugin['header'] = $header; } } $environment['affected_plugin'] = $compact_plugin; $followup['environment'] = $environment; } if ( isset( $followup['capture'] ) && is_array( $followup['capture'] ) && empty( $followup['capture']['image_available'] ) ) { unset( $followup['capture'] ); } if ( isset( $followup['tool_trace'] ) && is_array( $followup['tool_trace'] ) ) { $followup['tool_trace'] = $this->compact_tool_trace_for_ai( $followup['tool_trace'] ); } return $followup; } private function compact_tool_trace_for_ai( array $trace ) { foreach ( $trace as $index => $entry ) { if ( ! is_array( $entry ) || ! array_key_exists( 'result', $entry ) ) { continue; } $trace[ $index ]['result'] = $this->compact_tabular_lists_for_ai( $entry['result'] ); } return $trace; } private function compact_tabular_lists_for_ai( $value ) { if ( ! is_array( $value ) || empty( $value ) ) { return $value; } $keys = array_keys( $value ); $is_list = $keys === range( 0, count( $value ) - 1 ); if ( $is_list && count( $value ) > 1 && is_array( $value[0] ) && ! empty( $value[0] ) ) { $fields = array_keys( $value[0] ); $tabular = ! empty( $fields ); foreach ( $fields as $field ) { if ( ! is_string( $field ) ) { $tabular = false; break; } } if ( $tabular ) { foreach ( $value as $row ) { if ( ! is_array( $row ) || array_keys( $row ) !== $fields ) { $tabular = false; break; } } } if ( $tabular ) { $rows = []; foreach ( $value as $row ) { $compact_row = []; foreach ( $fields as $field ) { $compact_row[] = $this->compact_tabular_lists_for_ai( $row[ $field ] ); } $rows[] = $compact_row; } return [ 'fields' => $fields, 'rows' => $rows, ]; } } foreach ( $value as $key => $item ) { $value[ $key ] = $this->compact_tabular_lists_for_ai( $item ); } return $value; } /** @return void */ private function merge_usage( array $usage ) { foreach ( [ 'input_tokens', 'output_tokens', 'total_tokens' ] as $key ) { if ( isset( $usage[ $key ] ) && is_numeric( $usage[ $key ] ) ) { $this->usage[ $key ] = isset( $this->usage[ $key ] ) ? (int) $this->usage[ $key ] + (int) $usage[ $key ] : (int) $usage[ $key ]; } } } /** @return void */ private function merge_sources( array $sources ) { $map = []; foreach ( $this->sources as $existing ) { if ( is_array( $existing ) && ! empty( $existing['url'] ) ) { $map[ (string) $existing['url'] ] = $existing; } } foreach ( $sources as $source ) { if ( ! is_array( $source ) || empty( $source['url'] ) ) { continue; } $url = esc_url_raw( (string) $source['url'] ); if ( ! $url ) { continue; } $map[ $url ] = [ 'title' => isset( $source['title'] ) ? sanitize_text_field( (string) $source['title'] ) : '', 'url' => $url, ]; } $this->sources = array_slice( array_values( $map ), 0, 5 ); } }